import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); export async function buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }) { const settings = config.components; if (!settings) { if (pkg.components?.length) throw Error("Declared components require checked component build settings"); return []; } const declarations = config.group ? (pkg.components ?? []).filter((entry) => Object.hasOwn(settings.entries, entry.id)) : (pkg.components ?? []); const entries = Object.entries(settings.entries); if ( entries.length !== declarations.length || entries.some(([id]) => !declarations.some((component) => component.id === id)) ) throw Error("Component build entries differ from package declarations"); const runtime = JSON.parse(await fs.readFile(path.join(settings.runtime, "runtime.json"), "utf8")); if ( runtime.sdkCodeDigest !== digest(await fs.readFile(path.join(settings.sdk, "dist/index.js"))) || runtime.sdkTypeDigest !== digest(await fs.readFile(path.join(settings.sdk, "dist/index.d.ts"))) ) throw Error("Component SDK differs from selected shared runtime"); const sharedFiles = []; for (const file of runtime.files) { compiler.artifactPath(file.path); const bytes = await fs.readFile(path.join(settings.runtime, file.path)); if (bytes.length !== file.bytes || digest(bytes) !== file.digest) throw Error("Modified component runtime"); const target = "share/quixos/component-shared/" + file.path; await fs.mkdir(path.dirname(path.join(output, target)), { recursive: true }); await fs.writeFile(path.join(output, target), bytes); sharedFiles.push(await recordFile(target, file.mediaType)); } const sharedModules = Object.fromEntries( compiler.componentSharedModules.map((name) => { const file = sharedFiles.find((file) => file.path === "share/quixos/component-shared/" + runtime.modules[name]); if (!file) throw Error("Missing shared component module " + name); return [name, file.path]; }), ); const imports = Object.fromEntries( Object.entries(sharedModules).map(([name, file]) => [ name, compiler.contentArtifactUrl(sharedFiles.find((value) => value.path === file)), ]), ); const core = runtime.files.find((file) => file.path === "core.mjs"); const selected = JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8")).files.find( (file) => file.path === "core.mjs", ); if (core?.digest !== selected?.digest) throw Error("Component runtime core differs from portable runtime core"); const work = path.resolve("work-components"); await fs.cp(config.source, work, { recursive: true, dereference: false }); const writable = async (directory) => { await fs.chmod(directory, 0o755); for (const item of await fs.readdir(directory, { withFileTypes: true })) { const file = path.join(directory, item.name); if (item.isSymbolicLink()) throw Error("Authored component source cannot contain symlinks"); if (item.isDirectory()) await writable(file); else await fs.chmod(file, 0o644); } }; await writable(work); const modules = path.join(work, "node_modules"); await fs.mkdir(path.join(modules, "@quixos"), { recursive: true }); if (config.nodeModules) for (const item of await fs.readdir(config.nodeModules, { withFileTypes: true })) { if (["@quixos", "react", "react-dom", "scheduler", "@types", "csstype"].includes(item.name)) throw Error("Application dependencies cannot substitute selected React/client types"); await fs.symlink(path.join(config.nodeModules, item.name), path.join(modules, item.name)); } for (const item of await fs.readdir(path.join(settings.dependencies, "node_modules"))) { await fs.symlink(path.join(settings.dependencies, "node_modules", item), path.join(modules, item)); } await fs.symlink(settings.sdk, path.join(modules, "@quixos/camino-react")); await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" })); const bindingPath = compiler.artifactPath(settings.bindingOutput), bindings = compiler.generateComponentClientBindings(plan, pkg.revision), assets = compiler.componentAssetDeclarations(); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); await fs.writeFile(path.join(work, bindingPath), bindings); const assetPath = "component-assets.d.ts"; await fs.writeFile(path.join(work, assetPath), assets); await fs.writeFile(path.join(output, "share/quixos/generated/client.ts"), bindings); await fs.writeFile(path.join(output, "share/quixos/generated/component-assets.d.ts"), assets); generated.set("component-client", { file: path.join(work, bindingPath), source: bindings }); generated.set("component-assets", { file: path.join(work, assetPath), source: assets }); // Public props are type-only inputs owned by imported interface resources. // Augment the generated nominal registry without importing an implementation. const propsImports = [], propsMembers = [], seenProps = new Set(); for (const root of settings.contracts ?? []) { const companion = JSON.parse(await fs.readFile(path.join(root, "react-contracts.json"), "utf8")); if (companion.schemaVersion !== 1) throw Error("Unsupported React props companion"); const contracts = plan.definition.workspace.interfaceImports.filter( (iface) => iface.revisionId === companion.interfaceRevisionId || iface.application?.definitionId === companion.interfaceRevisionId, ); if (!contracts.length) continue; for (const [memberId, entry] of Object.entries(companion.members)) { if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid props type export"); const alias = `Props${propsImports.length}`; propsImports.push( `import type {${entry.export} as ${alias}} from ${JSON.stringify(path.join(root, "types", compiler.artifactPath(entry.path)))};`, ); for (const iface of contracts) { if ( !(iface.template?.members ?? iface.members).some( (member) => member.kind === "module" && member.id === memberId, ) ) throw Error("Props companion names an unknown module member"); const key = JSON.stringify([iface.revisionId, memberId]); if (seenProps.has(key)) throw Error("Duplicate React props companion for " + key); seenProps.add(key); propsMembers.push(`${JSON.stringify(key)}: ${alias};`); } } } const propsPath = "component-props.d.ts"; await fs.writeFile( path.join(work, propsPath), propsImports.join("\n") + `\nexport {};\ndeclare module ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))} { interface ModuleProps {${propsMembers.join("\n")}} }\n`, ); const components = []; for (const [id, entry] of entries) { const declaration = declarations.find((component) => component.id === id), source = compiler.artifactPath(entry.entry); if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name"); const key = digest(Buffer.from(id)).slice(0, 24), directory = "share/quixos/components/" + key; const witness = path.join(work, "component-check-" + key + ".tsx"); const module = pkg.modules.find((module) => module.id === id); await fs.writeFile( witness, `import type {Modules} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Modules[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n` + (module.contract === "org.quixos.react.subject-only/1" ? `import type {ComponentProps} from "@quixos/camino-react";declare const subject:ComponentProps<${JSON.stringify(declaration.subject)}>['subject'];const mount = ;\n` : ""), ); execFileSync( config.node, [ config.tsc, "--noEmit", "--strict", "--skipLibCheck", "false", "--target", "es2024", "--lib", "es2024,dom,dom.iterable,esnext.disposable", "--module", "nodenext", "--moduleResolution", "nodenext", "--jsx", "react-jsx", path.basename(witness), assetPath, propsPath, ], { cwd: work, stdio: "inherit" }, ); const metadata = path.join(work, "component-meta-" + key + ".json"); const facade = "component-entry-" + key + ".ts"; await fs.writeFile( path.join(work, facade), `export {${entry.export}} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};\n`, ); await fs.mkdir(path.join(output, directory), { recursive: true }); execFileSync( config.esbuild, [ facade, "--bundle", "--platform=browser", "--format=esm", "--target=es2024", "--jsx=automatic", '--define:process.env.NODE_ENV="production"', "--metafile=" + metadata, "--outfile=" + path.join(output, directory, "module.mjs"), ...Object.entries(imports).flatMap(([name, url]) => ["--alias:" + name + "=" + url, "--external:" + url]), ...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map( (extension) => "--loader:." + extension + "=dataurl", ), ], { cwd: work, stdio: "inherit" }, ); const meta = JSON.parse(await fs.readFile(metadata, "utf8")), outputs = Object.entries(meta.outputs); const main = outputs.find(([file]) => file.endsWith("/module.mjs"))?.[1]; if (!main?.exports.includes(entry.export)) throw Error("Component bundle lacks declared export"); if ( Object.keys(meta.inputs).some((file) => /camino-replica-engine|camino-replica-client|replicache|node_modules\/(?:react|react-dom|scheduler)(?:\/|$)/.test( file, ), ) ) throw Error("Component bundled an internal engine/client or duplicate React"); if ( outputs.some(([, value]) => value.imports.some((imported) => imported.external && !Object.values(imports).includes(imported.path)), ) ) throw Error("Component has an undeclared external module or asset"); const files = [...sharedFiles]; for (const [file] of outputs) { const absolute = path.resolve(work, file), relative = path.relative(output, absolute); compiler.artifactPath(relative); if (!relative.startsWith(directory + "/")) throw Error("Component output escaped declared artifact closure"); files.push(await recordFile(relative, relative.endsWith(".css") ? "text/css" : "text/javascript")); } const body = { id, subject: declaration.subject, compatibility: "org.quixos.react.esm/1", path: directory + "/module.mjs", exportName: entry.export, sharedModules, files, }; components.push({ ...body, contentDigest: compiler.componentArtifactDigest(body) }); } return components; }