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.
203 lines
9.0 KiB
JavaScript
203 lines
9.0 KiB
JavaScript
import { installDependencies } from "./install-dependencies.mjs";
|
|
import { buildService } from "./build-service.mjs";
|
|
import { buildComponents } from "./build-components.mjs";
|
|
/** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { builtinModules } from "node:module";
|
|
import { execFileSync } from "node:child_process";
|
|
import { pathToFileURL } from "node:url";
|
|
const config = JSON.parse(await fs.readFile(process.argv[2], "utf8"));
|
|
const compiler = await import(pathToFileURL(config.compiler));
|
|
const contract = JSON.parse(await fs.readFile(config.buildContract, "utf8"));
|
|
compiler.checkPackageBuildContract(contract);
|
|
const pkg = contract.definition.package;
|
|
if (pkg.revision !== config.packageRevisionId) throw Error("Invalid build contract package");
|
|
const output = process.env.out;
|
|
if (!output) throw Error("Missing Nix output");
|
|
const digest = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
const generatorDigest = digest(await fs.readFile(config.compiler));
|
|
const runtime =
|
|
config.portable || config.remote
|
|
? JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8"))
|
|
: { files: [] };
|
|
const relative = (p) => compiler.artifactPath(p);
|
|
const implementations = [];
|
|
const generated = new Map();
|
|
const recordFile = async (p, mediaType) => {
|
|
const bytes = await fs.readFile(path.join(output, relative(p)));
|
|
return { path: p, digest: digest(bytes), bytes: bytes.length, mediaType };
|
|
};
|
|
const copyRuntime = async () => {
|
|
await fs.mkdir(path.join(output, "share/quixos/shared"), { recursive: true });
|
|
for (const file of runtime.files) {
|
|
relative(file.path);
|
|
const bytes = await fs.readFile(path.join(config.sharedRuntime, file.path));
|
|
if (bytes.length !== file.bytes || digest(bytes) !== file.digest) throw Error("Shared runtime mismatch");
|
|
await fs.writeFile(path.join(output, "share/quixos/shared", file.path), bytes);
|
|
}
|
|
};
|
|
await copyRuntime();
|
|
const sharedFiles = await Promise.all(
|
|
runtime.files.map((f) => recordFile(`share/quixos/shared/${f.path}`, f.mediaType)),
|
|
);
|
|
const coreFile = sharedFiles.find((f) => f.path.endsWith("/core.mjs"));
|
|
const coreURL = coreFile ? compiler.contentArtifactUrl(coreFile) : null;
|
|
const coreServer = path.join(config.sharedRuntime, "core.mjs");
|
|
await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true });
|
|
const portable = config.portable;
|
|
for (const target of Object.keys(portable?.entries ?? {}))
|
|
if (!["browser", "server"].includes(target)) throw Error(`Unsupported portable entry target ${target}`);
|
|
for (const settings of [portable, config.remote].filter(Boolean))
|
|
if (!/^[$A-Z_a-z][$\w]*$/.test(settings.registryExport))
|
|
throw Error("Registry export must be a JavaScript identifier");
|
|
const targets = portable ? [...new Set([...portable.targets, "server"])] : [];
|
|
const builds = targets.map((target) => ({ target, mode: target, settings: portable }));
|
|
if (config.remote) builds.push({ target: "server", mode: "remote", settings: config.remote });
|
|
for (const { target, mode, settings } of builds) {
|
|
const executionTarget = target === "browser" ? "target:org.quixos.execution.javascript@1" : "server";
|
|
if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`);
|
|
const work = path.resolve(`work-${mode}`);
|
|
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 package 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,
|
|
selected: { "@quixos/camino-replica-core": config.core },
|
|
reserved: ["@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" }));
|
|
const bindingPath = relative(settings.bindingOutput);
|
|
const source =
|
|
mode === "remote"
|
|
? compiler.generateRemoteTypeScriptBindings(contract.definition.bindings, pkg.revision)
|
|
: compiler.generatePortableTypeScriptBindings(contract.definition.bindings, pkg.revision, {
|
|
target: executionTarget,
|
|
});
|
|
await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true });
|
|
await fs.writeFile(path.join(work, bindingPath), source);
|
|
generated.set(mode, { file: path.join(work, bindingPath), source });
|
|
await fs.writeFile(
|
|
path.join(
|
|
output,
|
|
mode === "remote" ? "share/quixos/generated/remote.ts" : compiler.generatedBindingPath(executionTarget),
|
|
),
|
|
source,
|
|
);
|
|
const entry = relative(settings.entries?.[target] ?? settings.entry);
|
|
execFileSync(
|
|
config.node,
|
|
[
|
|
config.tsc,
|
|
"--noEmit",
|
|
"--strict",
|
|
"--skipLibCheck",
|
|
"false",
|
|
"--target",
|
|
"es2024",
|
|
"--lib",
|
|
"es2024,dom,esnext.disposable",
|
|
"--module",
|
|
"nodenext",
|
|
"--moduleResolution",
|
|
"nodenext",
|
|
entry,
|
|
],
|
|
{ cwd: work, stdio: "inherit" },
|
|
);
|
|
const bundlePath =
|
|
mode === "remote" ? "share/quixos/remote/server/module.mjs" : `share/quixos/portable/${target}/module.mjs`;
|
|
await fs.mkdir(path.dirname(path.join(output, bundlePath)), { recursive: true });
|
|
const coreImport = target === "browser" ? coreURL : coreServer;
|
|
const metadata = path.join(work, "bundle-meta.json");
|
|
execFileSync(
|
|
config.esbuild,
|
|
[
|
|
entry,
|
|
"--bundle",
|
|
`--platform=${target === "browser" ? "browser" : "node"}`,
|
|
"--format=esm",
|
|
"--minify-whitespace",
|
|
"--target=es2024",
|
|
`--outfile=${path.join(output, bundlePath)}`,
|
|
`--metafile=${metadata}`,
|
|
`--alias:@quixos/camino-replica-core=${coreImport}`,
|
|
`--external:${coreImport}`,
|
|
],
|
|
{ cwd: work, stdio: "inherit" },
|
|
);
|
|
const meta = JSON.parse(await fs.readFile(metadata, "utf8"));
|
|
const built = Object.values(meta.outputs).find((o) => o.entryPoint);
|
|
if (!built?.exports.includes(settings.registryExport)) throw Error("Bundle lacks the declared registry export");
|
|
// Portable modules must contain their ordinary source dependencies and share only the checked SDK.
|
|
if (
|
|
Object.values(meta.outputs).some((o) =>
|
|
o.imports.some(
|
|
(i) =>
|
|
i.external &&
|
|
i.path !== coreImport &&
|
|
!(target === "server" && builtinModules.includes(i.path.replace(/^node:/, ""))),
|
|
),
|
|
)
|
|
)
|
|
throw Error("Unexpected external import in portable module");
|
|
const exports = pkg.exports
|
|
.filter((e) =>
|
|
mode === "remote"
|
|
? e.execution.kind === "remote"
|
|
: e.execution.kind !== "remote" && e.execution.targets.includes(executionTarget),
|
|
)
|
|
.map((e) => e.id);
|
|
const body = {
|
|
id: `${mode}-module`,
|
|
target: executionTarget,
|
|
compatibility: "quixos-js-module-v1",
|
|
path: bundlePath,
|
|
ports: "quixos-transaction-ports-v1",
|
|
registryExport: settings.registryExport,
|
|
exports,
|
|
files: [await recordFile(bundlePath, "text/javascript"), ...sharedFiles],
|
|
};
|
|
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
|
|
}
|
|
const components =
|
|
config.group && !config.components
|
|
? []
|
|
: await buildComponents({ config, compiler, contract, pkg, output, recordFile, generated });
|
|
if (!config.group || config.service.entry)
|
|
await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile });
|
|
|
|
const manifest = compiler.parseArtifactGroup({
|
|
schemaVersion: 1,
|
|
packageRevisionId: pkg.revision,
|
|
bindingDigest: contract.digest,
|
|
versions: contract.definition.versions,
|
|
implementations,
|
|
...(components.length ? { components } : {}),
|
|
});
|
|
for (const { file, source } of generated.values())
|
|
if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build");
|
|
const { receipt, sources } = compiler.expectedGroupReceipt(contract, manifest, generatorDigest);
|
|
for (const [file, bytes] of sources)
|
|
if (!Buffer.from(bytes).equals(await fs.readFile(path.join(output, file))))
|
|
throw Error("Receipt generation differs from compiled bindings");
|
|
await fs.writeFile(path.join(output, "share/quixos/package-artifacts.json"), JSON.stringify(manifest, null, 2));
|
|
await fs.writeFile(path.join(output, "share/quixos/build-receipt.json"), JSON.stringify(receipt, null, 2));
|