214 lines
9.3 KiB
JavaScript
214 lines
9.3 KiB
JavaScript
/** 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 plan = JSON.parse(await fs.readFile(config.plan, "utf8"));
|
|
const pkg = plan.definition.packages.find((p) => p.revision === config.packageRevisionId);
|
|
if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid checked execution plan/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
|
|
? 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;
|
|
if (portable && !/^[$A-Z_a-z][$\w]*$/.test(portable.registryExport))
|
|
throw Error("Registry export must be a JavaScript identifier");
|
|
const targets = portable ? [...new Set([...portable.targets, "server"])] : [];
|
|
for (const target of targets) {
|
|
if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`);
|
|
const work = path.resolve(`work-${target}`);
|
|
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 });
|
|
if (config.nodeModules) {
|
|
for (const item of await fs.readdir(config.nodeModules, { withFileTypes: true })) {
|
|
if (item.name === "@quixos") throw Error("Application dependencies cannot substitute the checked SDK");
|
|
await fs.symlink(path.join(config.nodeModules, item.name), path.join(modules, item.name));
|
|
}
|
|
}
|
|
await fs.symlink(config.core, path.join(modules, "@quixos/camino-replica-core"));
|
|
await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" }));
|
|
const bindingPath = relative(portable.bindingOutput);
|
|
const source = compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target });
|
|
await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true });
|
|
await fs.writeFile(path.join(work, bindingPath), source);
|
|
generated.set(target, { file: path.join(work, bindingPath), source });
|
|
await fs.writeFile(path.join(output, "share/quixos/generated", `${target}.ts`), source);
|
|
const entry = relative(portable.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 = `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",
|
|
"--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(portable.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) => e.execution.kind !== "remote" && e.execution.targets.includes(target))
|
|
.map((e) => e.id);
|
|
const body = {
|
|
id: `${target}-module`,
|
|
target,
|
|
compatibility: "quixos-js-module-v1",
|
|
path: bundlePath,
|
|
ports: "quixos-transaction-ports-v1",
|
|
registryExport: portable.registryExport,
|
|
exports,
|
|
files: [await recordFile(bundlePath, "text/javascript"), ...sharedFiles],
|
|
};
|
|
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
|
|
}
|
|
await fs.mkdir(path.join(output, "bin"), { recursive: true });
|
|
await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true });
|
|
let serviceEntry;
|
|
if (config.service.entry) {
|
|
if (!/\.m?js$/.test(config.service.entry))
|
|
throw Error("Custom service entry must be JavaScript; use a separate typed/language build before packaging");
|
|
serviceEntry = path.join(config.source, relative(config.service.entry));
|
|
} else {
|
|
if (!portable) throw Error("Generated service requires a portable registry");
|
|
if (pkg.exports.some((e) => e.execution.kind === "remote" || !e.execution.targets.includes("server")))
|
|
throw Error("Generated service does not cover every package export; provide a custom service");
|
|
serviceEntry = path.resolve("service-entry.mjs");
|
|
await fs.writeFile(
|
|
serviceEntry,
|
|
`import { ${portable.registryExport} as registry } from ${JSON.stringify(path.join(output, "share/quixos/portable/server/module.mjs"))};\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)};\nservePortableRegistry(registry);\n`,
|
|
);
|
|
}
|
|
const serviceModule = "share/quixos/service/main.mjs";
|
|
execFileSync(
|
|
config.esbuild,
|
|
[
|
|
serviceEntry,
|
|
"--bundle",
|
|
"--platform=node",
|
|
"--format=esm",
|
|
"--target=es2024",
|
|
`--external:${coreServer}`,
|
|
`--outfile=${path.join(output, serviceModule)}`,
|
|
],
|
|
{ stdio: "inherit" },
|
|
);
|
|
await fs.writeFile(
|
|
path.join(output, "bin/service"),
|
|
`#!${config.shell}\nexec ${config.node} ${output}/${serviceModule} "$@"\n`,
|
|
{ mode: 0o755 },
|
|
);
|
|
const serviceBody = {
|
|
id: "service",
|
|
target: "server",
|
|
compatibility: "quixos-service-v1",
|
|
path: "bin/service",
|
|
ports: "quixos-transaction-ports-v1",
|
|
exports: pkg.exports.map((e) => e.id),
|
|
files: [
|
|
await recordFile("bin/service", "text/x-shellscript"),
|
|
await recordFile(serviceModule, "text/javascript"),
|
|
...sharedFiles,
|
|
],
|
|
};
|
|
implementations.unshift({ ...serviceBody, contentDigest: compiler.implementationDigest(serviceBody) });
|
|
const manifest = compiler.parsePackageArtifacts({
|
|
schemaVersion: 1,
|
|
packageRevisionId: pkg.revision,
|
|
bindingDigest: pkg.bindingDigest,
|
|
sourceDigest: pkg.sourceDigest,
|
|
versions: plan.definition.versions,
|
|
implementations,
|
|
});
|
|
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.expectedArtifactReceipt(plan, 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));
|