Files
quixos-nix-helpers/build-components.mjs
T
Timothy J. Aveni 6227d64b08 Build Camino packages from explicit contract inputs
Project source-free capability contracts and keep exact source provenance in package assembly. Reuse compiled groups across unrelated graph and dependency implementation edits. Keep React public inputs separate from workspace query lowering and retain explicit type-companion dependencies.

Allow independent execution-package checks, scope resource compilation to declared dependencies, and memoize source closure traversal. Document generic-specialization limits and measured Reader rebuild behavior.

Verified protocol, artifact, package, React, and managed activation checks; retained-Git independent/workspace artifact equality and incompatible type dependency rejection; nix fmt -- --check and lint. One-off Reader measurements recompiled 1 of 13 groups for a child edit and 0 for a workspace QX default edit.
2026-09-30 22:55:07 -07:00

269 lines
12 KiB
JavaScript

import { installDependencies } from "./install-dependencies.mjs";
import fs from "node:fs/promises";
import path from "node:path";
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import { pathToFileURL } from "node:url";
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
export async function buildComponents({ config, compiler, contract, 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 });
await installDependencies({
destination: modules,
application: config.nodeModules,
defaults: [path.join(settings.dependencies, "node_modules")],
selected: { "@quixos/camino-react": settings.sdk },
reserved: [
"react",
"react-dom",
"scheduler",
"csstype",
"@types/react",
"@types/react-dom",
"@types/prop-types",
"@quixos/camino-react",
"@quixos/camino-replica-core",
"@quixos/camino-replica-client",
"@quixos/camino-replica-engine",
],
});
const packageFile = path.join(work, "package.json");
const packageMetadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => {
if (error.code === "ENOENT") return {};
throw error;
});
await fs.writeFile(packageFile, JSON.stringify({ ...packageMetadata, type: "module" }));
// CommonJS dependencies may require React. An ESM bridge lets esbuild lower
// that require while retaining the one host-owned runtime instance.
const sharedAliases = [];
for (const [name, url] of Object.entries(imports)) {
const shim = path.join(work, "shared-" + digest(Buffer.from(name)).slice(0, 16) + ".mjs");
await fs.writeFile(
shim,
`export * from ${JSON.stringify(url)};` +
(["@quixos/camino-react", "@quixos/camino-react/ui"].includes(name)
? ""
: `export {default} from ${JSON.stringify(url)};`),
);
sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url);
}
const bindingPath = compiler.artifactPath(settings.bindingOutput),
bindings = contract.definition.component,
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 = contract.definition.moduleInterfaces.filter(
(iface) =>
iface.revisionId === companion.interfaceRevisionId || iface.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.members.includes(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 = [];
const { checkControls } = await import(pathToFileURL(path.join(settings.sdk, "lint-controls.mjs")));
const { default: ts } = await import(pathToFileURL(path.resolve(path.dirname(config.tsc), "../lib/typescript.js")));
const checkedControls = new Set();
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 "react";declare const props:Pick<ComponentProps<Modules[${JSON.stringify(declaration.displayName)}]>,"subject"|"input">;const mount = <Component {...props}/>;\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"),
...sharedAliases,
...["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);
for (const file of Object.keys(meta.inputs)) {
if (!/\.[cm]?[jt]sx?$/.test(file) || file.includes("node_modules/") || checkedControls.has(file)) continue;
checkedControls.add(file);
const errors = checkControls(ts, await fs.readFile(path.resolve(work, file), "utf8"), file);
if (errors.length) throw Error(errors.join("\n"));
}
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;
}