From f352ac0c3b17ca5ba2c9de9818184c41d457b9ae Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 00:25:53 -0700 Subject: [PATCH 01/10] Build checked portable package artifacts and shared runtime assets --- PORTABLE_ARTIFACTS.md | 56 +++++++++ build-portable-artifacts.mjs | 213 +++++++++++++++++++++++++++++++++++ portable-artifacts.nix | 53 +++++++++ portable-service.mjs | 151 +++++++++++++++++++++++++ quixos-package-helpers.nix | 1 + 5 files changed, 474 insertions(+) create mode 100644 PORTABLE_ARTIFACTS.md create mode 100644 build-portable-artifacts.mjs create mode 100644 portable-artifacts.nix create mode 100644 portable-service.mjs diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md new file mode 100644 index 0000000..cdff6aa --- /dev/null +++ b/PORTABLE_ARTIFACTS.md @@ -0,0 +1,56 @@ +# Checked portable artifacts + +`mkCaminoPortableArtifacts` is the fresh package builder. It regenerates candidate +bindings, typechecks the entry's dependency closure, bundles declared targets, +generates a process service when requested, and emits content manifests and +receipts. It has no migration step or package-defined shell hooks. + +```nix +# Inside a per-system flake output: +quixosPackages.checkedArtifacts = + { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: + helpers.mkCaminoPortableArtifacts { + inherit pkgs plan core sharedRuntime typescript packageRevisionId; + protocol = generator; + src = ./.; + portable = { + entry = "src/portable.ts"; + bindingOutput = "src/gen/qx.ts"; + registryExport = "registry"; + targets = [ "browser" "server" ]; + }; + }; +``` + +The handler entry exports `registry = createRegistry(handlers)` from the generated +bindings. One registry contains many exports. The builder always supplies a server +implementation for the generated process service. A service-only package instead +omits `portable` and supplies `service.entry`, an ordinary JavaScript process entry +using the versioned stdin/stdout protocol. Custom services own their lifecycle and +may hold external resources. This first helper supports JavaScript custom service +entries; other languages can emit the same checked artifact contract independently. + +Additional portable source dependencies can be provided as a locked `nodeModules` directory. +The workspace supplies the exact SDK and compiler; packages cannot replace those +with their own versions. Authored source symlinks and non-bundled portable imports +other than the SDK and server Node builtins are rejected. This is build closure +checking, not a purity proof or security sandbox. + +The core/Automerge bundle and Replicache bundle are built once. Browser modules +import the core through a content-addressed gateway URL, independent of package +identity. Each manifest covers the shared bytes it uses. The gateway retains its +workspace authentication and explicit file registry; it never interprets an HTTP +path as a Nix store path. Applications cannot import Replicache through the SDK. + +`checked-artifacts.nix` evaluates a package's `checkedArtifacts` output without +activation. `loadCheckedArtifactWorld` then verifies manifest bytes, the trusted +generator identity, and exact regenerated binding sources. Receipts are consistency +evidence from a trusted Nix build, not cryptographic attestations of remote builds. +The separate manifest-only loader is useful for candidate diagnostics; activation +must use the checked loader. + +The root `camino-artifact-builds` check exercises actual reader/service outputs, +module and process execution, browser shared imports, rejection recovery, and +altered receipt rejection. No running workspace uses these outputs yet. Installing +the new world into the coordinator/replica host is LF-08/LF-11 integration work; +no adapter pretends this ABI works in the old server dispatcher. diff --git a/build-portable-artifacts.mjs b/build-portable-artifacts.mjs new file mode 100644 index 0000000..4c58c19 --- /dev/null +++ b/build-portable-artifacts.mjs @@ -0,0 +1,213 @@ +/** 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)); diff --git a/portable-artifacts.nix b/portable-artifacts.nix new file mode 100644 index 0000000..9d050e4 --- /dev/null +++ b/portable-artifacts.nix @@ -0,0 +1,53 @@ +# Fresh artifact builder. Candidate plans and runtime dependencies are supplied by the workspace compiler. +{ + pkgs, + src, + plan, + protocol, + core, + sharedRuntime, + typescript ? core.dependencies, + packageRevisionId, + portable ? null, + service ? { }, + nodeModules ? null, +}: +let + compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; + config = pkgs.writeText "camino-artifact-build.json" ( + builtins.toJSON { + inherit + plan + compiler + packageRevisionId + core + sharedRuntime + nodeModules + service + ; + source = src; + portable = + if portable == null then + null + else + { + inherit (portable) entry; + registryExport = portable.registryExport or "registry"; + bindingOutput = portable.bindingOutput or "src/gen/qx.ts"; + targets = + portable.targets or [ + "browser" + "server" + ]; + }; + node = "${pkgs.nodejs_24}/bin/node"; + tsc = "${typescript}/node_modules/typescript/bin/tsc"; + esbuild = "${pkgs.esbuild}/bin/esbuild"; + shell = "${pkgs.runtimeShell}"; + serviceAdapter = ./portable-service.mjs; + } + ); +in +pkgs.runCommand "camino-checked-artifacts" { } '' + ${pkgs.nodejs_24}/bin/node ${./build-portable-artifacts.mjs} ${config} +'' diff --git a/portable-service.mjs b/portable-service.mjs new file mode 100644 index 0000000..402a5e1 --- /dev/null +++ b/portable-service.mjs @@ -0,0 +1,151 @@ +/** Generated-service transport. The supervisor owns process isolation, deadlines and termination. + * stdout is reserved for this protocol; application diagnostics belong on stderr. */ +export function servePortableRegistry( + registry, + { maxBytes = 4 * 1024 * 1024, maxInvocations = 64, maxPortCalls = 10000 } = {}, +) { + const abi = "quixos-transaction-ports-v1"; + const active = new Map(); + const pending = new Map(); + let buffer = Buffer.alloc(0), + stopped = false; + const send = (message) => { + const encoded = JSON.stringify(message); + if (Buffer.byteLength(encoded) > maxBytes) throw Error("SERVICE_MESSAGE_LIMIT"); + // A slow or absent coordinator must not cause an unbounded stdout queue. + if (process.stdout.writableLength + Buffer.byteLength(encoded) > maxBytes * 2) throw Error("SERVICE_BACKPRESSURE"); + process.stdout.write(encoded + "\n"); + }; + const errorBody = (error) => ({ + code: typeof error?.code === "string" ? error.code.slice(0, 1024) : "PACKAGE_ERROR", + message: String(error?.message ?? error).slice(0, 4096), + }); + const cancel = (id, reason) => { + active.delete(id); + for (const [key, call] of pending) + if (call.invocation === id) { + pending.delete(key); + call.reject(reason); + } + }; + const fatal = (error) => { + if (stopped) return; + stopped = true; + for (const id of active.keys()) cancel(id, error); + process.stderr.write(String(error?.message ?? error) + "\n"); + process.exitCode = 1; + process.stdin.destroy(); + }; + const receive = (message) => { + if (!message || message.abi !== abi) throw Error("SERVICE_ABI_MISMATCH"); + if (message.kind === "shutdown") { + for (const id of active.keys()) cancel(id, Error("SERVICE_SHUTDOWN")); + stopped = true; + process.stdin.destroy(); + return; + } + if (message.kind === "cancel") { + cancel(message.invocation, Error("INVOCATION_CANCELLED")); + return; + } + if (message.kind === "port-result") { + const key = JSON.stringify([message.invocation, message.sequence]); + const call = pending.get(key); + if (!call && !active.has(message.invocation)) return; // A canceled call may already have a reply in flight. + if (!call || message.frame !== call.frame) throw Error("UNEXPECTED_PORT_RESULT"); + pending.delete(key); + if (message.status === "returned") call.resolve(message.output); + else if (message.status === "failed") + call.reject( + Object.assign(Error(String(message.error?.message ?? "Port failed")), { + code: String(message.error?.code ?? "PORT_FAILED"), + }), + ); + else throw Error("INVALID_PORT_RESULT"); + return; + } + if ( + message.kind !== "invoke" || + typeof message.invocation !== "string" || + typeof message.frame !== "string" || + !message.invocation || + !message.frame || + message.invocation.length > 4096 || + message.frame.length > 4096 + ) + throw Error("INVALID_INVOCATION"); + if (active.has(message.invocation) || active.size >= maxInvocations) throw Error("INVOCATION_LIMIT_OR_DUPLICATE"); + const token = {}; + active.set(message.invocation, token); + let sequence = 0; + const channel = (request) => { + if (active.get(message.invocation) !== token) return Promise.reject(Error("EXPIRED_INVOCATION")); + if (++sequence > maxPortCalls || pending.size >= maxPortCalls) return Promise.reject(Error("PORT_CALL_LIMIT")); + const key = JSON.stringify([message.invocation, sequence]); + return new Promise((resolve, reject) => { + pending.set(key, { invocation: message.invocation, frame: message.frame, resolve, reject }); + try { + send({ + abi, + kind: "port", + frame: message.frame, + invocation: message.invocation, + sequence, + portId: request.portId, + action: { ...request.action, ...(request.key === undefined ? {} : { key: request.key }) }, + input: request.input, + }); + } catch (error) { + pending.delete(key); + reject(error); + } + }); + }; + void registry + .invoke( + { exportId: message.exportId, workspace: message.workspace, receiver: message.receiver, input: message.input }, + channel, + ) + .then( + (output) => { + if (active.get(message.invocation) === token) + send({ abi, invocation: message.invocation, status: "returned", output }); + }, + (error) => { + if (active.get(message.invocation) === token) + send({ abi, invocation: message.invocation, status: "failed", error: errorBody(error) }); + }, + ) + .catch(fatal) + .finally(() => { + if (active.get(message.invocation) === token) cancel(message.invocation, Error("INVOCATION_ENDED")); + }); + }; + process.stdin.on("data", (bytes) => { + if (stopped) return; + try { + // Consume each line before retaining the remainder; combined transport chunks may contain many frames. + let offset = 0; + while (offset < bytes.length) { + const newline = bytes.indexOf(10, offset); + const end = newline < 0 ? bytes.length : newline; + if (buffer.length + end - offset > maxBytes) throw Error("SERVICE_MESSAGE_LIMIT"); + buffer = Buffer.concat([buffer, bytes.subarray(offset, end)]); + if (newline < 0) break; + receive(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(buffer))); + buffer = Buffer.alloc(0); + offset = newline + 1; + if (stopped) break; + } + } catch (error) { + fatal(error); + } + }); + process.stdin.on("end", () => { + if (buffer.length) fatal(Error("TRUNCATED_SERVICE_MESSAGE")); + for (const id of active.keys()) cancel(id, Error("COORDINATOR_DISCONNECTED")); + }); + process.stdin.on("error", fatal); + process.stdout.on("error", fatal); + send({ abi, kind: "ready", exports: registry.exports }); +} diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index c042e22..5f46f9d 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -846,6 +846,7 @@ let ); in { + mkCaminoPortableArtifacts = import ./portable-artifacts.nix; inherit mkQuixosPackageFlake mkTsPackageServer From 9257198eecd68893d14c67669d8b1f9d3e76aedb Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 19:58:05 -0700 Subject: [PATCH 02/10] Add cooperative cancellation for asynchronous package invocations --- portable-service.mjs | 38 +++++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/portable-service.mjs b/portable-service.mjs index 402a5e1..812437e 100644 --- a/portable-service.mjs +++ b/portable-service.mjs @@ -20,14 +20,21 @@ export function servePortableRegistry( code: typeof error?.code === "string" ? error.code.slice(0, 1024) : "PACKAGE_ERROR", message: String(error?.message ?? error).slice(0, 4096), }); - const cancel = (id, reason) => { - active.delete(id); + const rejectPorts = (id, reason) => { for (const [key, call] of pending) if (call.invocation === id) { pending.delete(key); call.reject(reason); } }; + const cancel = (id, reason) => { + const token = active.get(id); + if (token) { + token.cancelled = true; + token.controller.abort(reason); + } + rejectPorts(id, reason); + }; const fatal = (error) => { if (stopped) return; stopped = true; @@ -45,13 +52,17 @@ export function servePortableRegistry( return; } if (message.kind === "cancel") { + const token = active.get(message.invocation); + if (token && token.frame !== message.frame) throw Error("INVALID_CANCELLATION_FRAME"); cancel(message.invocation, Error("INVOCATION_CANCELLED")); + // A completed result may already be in flight. Acknowledge without resurrecting its context. + if (!token) send({ abi, kind: "cancelled", invocation: message.invocation, frame: message.frame }); return; } if (message.kind === "port-result") { const key = JSON.stringify([message.invocation, message.sequence]); const call = pending.get(key); - if (!call && !active.has(message.invocation)) return; // A canceled call may already have a reply in flight. + if (!call && (!active.has(message.invocation) || active.get(message.invocation).cancelled)) return; if (!call || message.frame !== call.frame) throw Error("UNEXPECTED_PORT_RESULT"); pending.delete(key); if (message.status === "returned") call.resolve(message.output); @@ -75,11 +86,12 @@ export function servePortableRegistry( ) throw Error("INVALID_INVOCATION"); if (active.has(message.invocation) || active.size >= maxInvocations) throw Error("INVOCATION_LIMIT_OR_DUPLICATE"); - const token = {}; + const token = { frame: message.frame, controller: new AbortController(), cancelled: false }; active.set(message.invocation, token); let sequence = 0; const channel = (request) => { - if (active.get(message.invocation) !== token) return Promise.reject(Error("EXPIRED_INVOCATION")); + if (active.get(message.invocation) !== token || token.cancelled) + return Promise.reject(Error("EXPIRED_INVOCATION")); if (++sequence > maxPortCalls || pending.size >= maxPortCalls) return Promise.reject(Error("PORT_CALL_LIMIT")); const key = JSON.stringify([message.invocation, sequence]); return new Promise((resolve, reject) => { @@ -105,21 +117,29 @@ export function servePortableRegistry( .invoke( { exportId: message.exportId, workspace: message.workspace, receiver: message.receiver, input: message.input }, channel, + Object.freeze({ signal: token.controller.signal, effect: message.effect }), ) .then( (output) => { - if (active.get(message.invocation) === token) + if (active.get(message.invocation) === token && !token.cancelled) send({ abi, invocation: message.invocation, status: "returned", output }); }, (error) => { - if (active.get(message.invocation) === token) + if (active.get(message.invocation) === token && !token.cancelled) send({ abi, invocation: message.invocation, status: "failed", error: errorBody(error) }); }, ) .catch(fatal) .finally(() => { - if (active.get(message.invocation) === token) cancel(message.invocation, Error("INVOCATION_ENDED")); - }); + if (active.get(message.invocation) === token) { + active.delete(message.invocation); + token.controller.abort(Error("INVOCATION_ENDED")); + rejectPorts(message.invocation, Error("INVOCATION_ENDED")); + if (token.cancelled && !stopped) + send({ abi, kind: "cancelled", invocation: message.invocation, frame: token.frame }); + } + }) + .catch(fatal); }; process.stdin.on("data", (bytes) => { if (stopped) return; From ca0efe72526220678e4680f10bc2ba8bcee98e6c Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 20:58:01 -0700 Subject: [PATCH 03/10] Build checked asynchronous service registries with Nix --- build-portable-artifacts.mjs | 71 ++++++++++++++++++++++++++---------- portable-artifacts.nix | 10 +++++ 2 files changed, 61 insertions(+), 20 deletions(-) diff --git a/build-portable-artifacts.mjs b/build-portable-artifacts.mjs index 4c58c19..80a5c2c 100644 --- a/build-portable-artifacts.mjs +++ b/build-portable-artifacts.mjs @@ -14,9 +14,10 @@ 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 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(); @@ -42,12 +43,15 @@ 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"); +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"])] : []; -for (const target of targets) { +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) { if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`); - const work = path.resolve(`work-${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); @@ -69,13 +73,16 @@ for (const target of targets) { } 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 }); + const bindingPath = relative(settings.bindingOutput); + const source = + mode === "remote" + ? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision) + : 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); + generated.set(mode, { file: path.join(work, bindingPath), source }); + await fs.writeFile(path.join(output, "share/quixos/generated", `${mode}.ts`), source); + const entry = relative(settings.entry); execFileSync( config.node, [ @@ -96,7 +103,8 @@ for (const target of targets) { ], { cwd: work, stdio: "inherit" }, ); - const bundlePath = `share/quixos/portable/${target}/module.mjs`; + 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"); @@ -117,7 +125,7 @@ for (const target of targets) { ); 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"); + 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) => @@ -131,15 +139,19 @@ for (const target of targets) { ) throw Error("Unexpected external import in portable module"); const exports = pkg.exports - .filter((e) => e.execution.kind !== "remote" && e.execution.targets.includes(target)) + .filter((e) => + mode === "remote" + ? e.execution.kind === "remote" + : e.execution.kind !== "remote" && e.execution.targets.includes(target), + ) .map((e) => e.id); const body = { - id: `${target}-module`, + id: `${mode}-module`, target, compatibility: "quixos-js-module-v1", path: bundlePath, ports: "quixos-transaction-ports-v1", - registryExport: portable.registryExport, + registryExport: settings.registryExport, exports, files: [await recordFile(bundlePath, "text/javascript"), ...sharedFiles], }; @@ -153,13 +165,32 @@ if (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"))) + const modules = implementations.filter((implementation) => implementation.target === "server"); + if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id)))) throw Error("Generated service does not cover every package export; provide a custom service"); + if (!modules.length) throw Error("Generated service requires a checked registry"); serviceEntry = path.resolve("service-entry.mjs"); + const imports = modules + .map( + (module, index) => + `import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`, + ) + .join("\n"); 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`, + imports + + `\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)}; +const registries = [${modules.map((_, index) => "registry" + index).join(",")}]; +const owners = new Map(); +for (const registry of registries) for (const id of registry.exports) { + if (owners.has(id)) throw Error("Duplicate registry export " + id); + owners.set(id, registry); +} +servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, context) { + const registry = owners.get(request.exportId); + if (!registry) throw Error("Unknown export"); + return registry.invoke(request, channel, context); +}});\n`, ); } const serviceModule = "share/quixos/service/main.mjs"; diff --git a/portable-artifacts.nix b/portable-artifacts.nix index 9d050e4..2fbe763 100644 --- a/portable-artifacts.nix +++ b/portable-artifacts.nix @@ -9,6 +9,7 @@ typescript ? core.dependencies, packageRevisionId, portable ? null, + remote ? null, service ? { }, nodeModules ? null, }: @@ -40,6 +41,15 @@ let "server" ]; }; + remote = + if remote == null then + null + else + { + inherit (remote) entry; + registryExport = remote.registryExport or "registry"; + bindingOutput = remote.bindingOutput or "src/gen/remote.ts"; + }; node = "${pkgs.nodejs_24}/bin/node"; tsc = "${typescript}/node_modules/typescript/bin/tsc"; esbuild = "${pkgs.esbuild}/bin/esbuild"; From 66512e84bb53ad8c917ddc00f558a5c61d48e316 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Sun, 20 Sep 2026 21:51:43 -0700 Subject: [PATCH 04/10] Compile and dispatch authoritative acceptance hooks with target-specific package roots --- build-portable-artifacts.mjs | 4 +++- portable-artifacts.nix | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/build-portable-artifacts.mjs b/build-portable-artifacts.mjs index 80a5c2c..18f2fa0 100644 --- a/build-portable-artifacts.mjs +++ b/build-portable-artifacts.mjs @@ -43,6 +43,8 @@ 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"); @@ -82,7 +84,7 @@ for (const { target, mode, settings } of builds) { await fs.writeFile(path.join(work, bindingPath), source); generated.set(mode, { file: path.join(work, bindingPath), source }); await fs.writeFile(path.join(output, "share/quixos/generated", `${mode}.ts`), source); - const entry = relative(settings.entry); + const entry = relative(settings.entries?.[target] ?? settings.entry); execFileSync( config.node, [ diff --git a/portable-artifacts.nix b/portable-artifacts.nix index 2fbe763..32102d8 100644 --- a/portable-artifacts.nix +++ b/portable-artifacts.nix @@ -33,6 +33,7 @@ let else { inherit (portable) entry; + entries = portable.entries or { }; registryExport = portable.registryExport or "registry"; bindingOutput = portable.bindingOutput or "src/gen/qx.ts"; targets = From 95c9c3db7ac5b68fb93344f0a4b1a46a7935485f Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 21 Sep 2026 12:05:50 -0700 Subject: [PATCH 05/10] Add independently owned reconnectable execution units (WI-03) --- build-portable-artifacts.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build-portable-artifacts.mjs b/build-portable-artifacts.mjs index 18f2fa0..7ffcec0 100644 --- a/build-portable-artifacts.mjs +++ b/build-portable-artifacts.mjs @@ -117,6 +117,7 @@ for (const { target, mode, settings } of builds) { "--bundle", `--platform=${target === "browser" ? "browser" : "node"}`, "--format=esm", + "--minify-whitespace", "--target=es2024", `--outfile=${path.join(output, bundlePath)}`, `--metafile=${metadata}`, @@ -203,6 +204,7 @@ execFileSync( "--bundle", "--platform=node", "--format=esm", + "--minify-whitespace", "--target=es2024", `--external:${coreServer}`, `--outfile=${path.join(output, serviceModule)}`, @@ -211,7 +213,7 @@ execFileSync( ); await fs.writeFile( path.join(output, "bin/service"), - `#!${config.shell}\nexec ${config.node} ${output}/${serviceModule} "$@"\n`, + `#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${serviceModule}" "$@"\n`, { mode: 0o755 }, ); const serviceBody = { From daa20ca79f62de8022f887e7e2de66c666879e86 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 21 Sep 2026 15:24:05 -0700 Subject: [PATCH 06/10] Build checked component artifacts and shared React session SDK (WI-09) --- PORTABLE_ARTIFACTS.md | 26 ++- portable-artifacts.nix => artifacts.nix | 14 +- ...table-artifacts.mjs => build-artifacts.mjs | 119 ++++++------ build-components.mjs | 183 ++++++++++++++++++ quixos-package-helpers.nix | 2 +- 5 files changed, 278 insertions(+), 66 deletions(-) rename portable-artifacts.nix => artifacts.nix (77%) rename build-portable-artifacts.mjs => build-artifacts.mjs (77%) create mode 100644 build-components.mjs diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md index cdff6aa..99284a2 100644 --- a/PORTABLE_ARTIFACTS.md +++ b/PORTABLE_ARTIFACTS.md @@ -1,6 +1,6 @@ -# Checked portable artifacts +# Checked capability and component artifacts -`mkCaminoPortableArtifacts` is the fresh package builder. It regenerates candidate +`mkCaminoArtifacts` is the fresh package builder. It regenerates candidate bindings, typechecks the entry's dependency closure, bundles declared targets, generates a process service when requested, and emits content manifests and receipts. It has no migration step or package-defined shell hooks. @@ -9,7 +9,7 @@ receipts. It has no migration step or package-defined shell hooks. # Inside a per-system flake output: quixosPackages.checkedArtifacts = { plan, generator, core, sharedRuntime, typescript, packageRevisionId }: - helpers.mkCaminoPortableArtifacts { + helpers.mkCaminoArtifacts { inherit pkgs plan core sharedRuntime typescript packageRevisionId; protocol = generator; src = ./.; @@ -51,6 +51,20 @@ must use the checked loader. The root `camino-artifact-builds` check exercises actual reader/service outputs, module and process execution, browser shared imports, rejection recovery, and -altered receipt rejection. No running workspace uses these outputs yet. Installing -the new world into the coordinator/replica host is LF-08/LF-11 integration work; -no adapter pretends this ABI works in the old server dispatcher. +altered receipt rejection. The managed workspace host verifies these receipts +before activation. No adapter pretends this ABI works in the old server dispatcher. + +Component packages add `components = { inherit (react) sdk runtime; entries; }`. +The ordinary execution candidate checker supplies the selected React build inputs +when the package declares components. Each entry maps a component ID to +`{ entry = "src/card.tsx"; export = "Card"; }`. See the +[React SDK](../camino-react/README.md) for bindings and hooks. CSS is emitted as a +checked artifact; local image/font imports are inlined. The helper rejects +undeclared external assets and substituted React/client dependencies. +Component-only packages produce no backend service. + +`camino-component-artifacts` checks bad subject/SDK/dependency/asset inputs and +proves that a component-only edit preserves a mixed package's service digest. +Run `nix run .#camino-component-source-qualification` from the monorepo to +materialize separate retained Git resources and compile an ordinary Nix candidate. +This is a source/build qualification harness; it does not deploy a workspace. diff --git a/portable-artifacts.nix b/artifacts.nix similarity index 77% rename from portable-artifacts.nix rename to artifacts.nix index 32102d8..2f43b76 100644 --- a/portable-artifacts.nix +++ b/artifacts.nix @@ -12,6 +12,7 @@ remote ? null, service ? { }, nodeModules ? null, + components ? null, }: let compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; @@ -26,6 +27,15 @@ let nodeModules service ; + components = + if components == null then + null + else + { + inherit (components) sdk runtime entries; + dependencies = components.sdk.dependencies; + bindingOutput = components.bindingOutput or "src/gen/client.ts"; + }; source = src; portable = if portable == null then @@ -59,6 +69,6 @@ let } ); in -pkgs.runCommand "camino-checked-artifacts" { } '' - ${pkgs.nodejs_24}/bin/node ${./build-portable-artifacts.mjs} ${config} +pkgs.runCommand "camino-checked-artifacts" { passthru.buildConfig = config; } '' + ${pkgs.nodejs_24}/bin/node ${./.}/build-artifacts.mjs ${config} '' diff --git a/build-portable-artifacts.mjs b/build-artifacts.mjs similarity index 77% rename from build-portable-artifacts.mjs rename to build-artifacts.mjs index 7ffcec0..5000bc7 100644 --- a/build-portable-artifacts.mjs +++ b/build-artifacts.mjs @@ -1,3 +1,4 @@ +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"; @@ -160,29 +161,31 @@ for (const { target, mode, settings } of builds) { }; 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 { - const modules = implementations.filter((implementation) => implementation.target === "server"); - if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id)))) - throw Error("Generated service does not cover every package export; provide a custom service"); - if (!modules.length) throw Error("Generated service requires a checked registry"); - serviceEntry = path.resolve("service-entry.mjs"); - const imports = modules - .map( - (module, index) => - `import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`, - ) - .join("\n"); - await fs.writeFile( - serviceEntry, - imports + - `\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)}; +const components = await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }); +if (pkg.exports.length || config.service.entry) { + 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 { + const modules = implementations.filter((implementation) => implementation.target === "server"); + if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id)))) + throw Error("Generated service does not cover every package export; provide a custom service"); + if (!modules.length) throw Error("Generated service requires a checked registry"); + serviceEntry = path.resolve("service-entry.mjs"); + const imports = modules + .map( + (module, index) => + `import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`, + ) + .join("\n"); + await fs.writeFile( + serviceEntry, + imports + + `\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)}; const registries = [${modules.map((_, index) => "registry" + index).join(",")}]; const owners = new Map(); for (const registry of registries) for (const id of registry.exports) { @@ -194,42 +197,43 @@ servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, con if (!registry) throw Error("Unknown export"); return registry.invoke(request, channel, context); }});\n`, + ); + } + const serviceModule = "share/quixos/service/main.mjs"; + execFileSync( + config.esbuild, + [ + serviceEntry, + "--bundle", + "--platform=node", + "--format=esm", + "--minify-whitespace", + "--target=es2024", + `--external:${coreServer}`, + `--outfile=${path.join(output, serviceModule)}`, + ], + { stdio: "inherit" }, ); + await fs.writeFile( + path.join(output, "bin/service"), + `#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${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 serviceModule = "share/quixos/service/main.mjs"; -execFileSync( - config.esbuild, - [ - serviceEntry, - "--bundle", - "--platform=node", - "--format=esm", - "--minify-whitespace", - "--target=es2024", - `--external:${coreServer}`, - `--outfile=${path.join(output, serviceModule)}`, - ], - { stdio: "inherit" }, -); -await fs.writeFile( - path.join(output, "bin/service"), - `#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${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, @@ -237,6 +241,7 @@ const manifest = compiler.parsePackageArtifacts({ sourceDigest: pkg.sourceDigest, versions: plan.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"); diff --git a/build-components.mjs b/build-components.mjs new file mode 100644 index 0000000..c40f756 --- /dev/null +++ b/build-components.mjs @@ -0,0 +1,183 @@ +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 = 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 }); + 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 + ".ts"); + await fs.writeFile( + witness, + `import type {Components} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Components[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\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, + ], + { cwd: work, stdio: "inherit" }, + ); + const metadata = path.join(work, "component-meta-" + key + ".json"); + await fs.mkdir(path.join(output, directory), { recursive: true }); + execFileSync( + config.esbuild, + [ + source, + "--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: "quixos-react-component-v1", + path: directory + "/module.mjs", + exportName: entry.export, + sharedModules, + files, + }; + components.push({ ...body, contentDigest: compiler.componentArtifactDigest(body) }); + } + return components; +} diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index 5f46f9d..346ec3e 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -846,7 +846,7 @@ let ); in { - mkCaminoPortableArtifacts = import ./portable-artifacts.nix; + mkCaminoArtifacts = import ./artifacts.nix; inherit mkQuixosPackageFlake mkTsPackageServer From 0d7d5daea60ab25685e1af1ad68eea0aea199b68 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Mon, 21 Sep 2026 21:49:52 -0700 Subject: [PATCH 07/10] Load checked installations independently of the running compiler (WI-16) Keep compiler-dependent checks in Nix builds; load stored plans and programs with original provenance and runtime artifact validation. Allow an identical executable world to receive a new activation epoch after a toolchain rebuild. Compiler-update/altered-program regression, coordinator and full sandboxed integration checks pass; lint and formatting pass. Default platform/source wiring remains the next task. --- PORTABLE_ARTIFACTS.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/PORTABLE_ARTIFACTS.md b/PORTABLE_ARTIFACTS.md index 99284a2..0f4618a 100644 --- a/PORTABLE_ARTIFACTS.md +++ b/PORTABLE_ARTIFACTS.md @@ -43,16 +43,20 @@ workspace authentication and explicit file registry; it never interprets an HTTP path as a Nix store path. Applications cannot import Replicache through the SDK. `checked-artifacts.nix` evaluates a package's `checkedArtifacts` output without -activation. `loadCheckedArtifactWorld` then verifies manifest bytes, the trusted +activation. During the checked Nix build, `loadCheckedArtifactWorld` verifies manifest bytes, the selected generator identity, and exact regenerated binding sources. Receipts are consistency evidence from a trusted Nix build, not cryptographic attestations of remote builds. -The separate manifest-only loader is useful for candidate diagnostics; activation -must use the checked loader. +The build stores the checked plan, kernel/query programs, their digests and original +compiler provenance in its immutable candidate output. Runtime loading validates +that output's identities, artifact contents and supported runtime contracts. It +does not regenerate bindings or programs with the currently running compiler. +Nix owns rebuilding and cache reuse when a toolchain input changes. The root `camino-artifact-builds` check exercises actual reader/service outputs, module and process execution, browser shared imports, rejection recovery, and -altered receipt rejection. The managed workspace host verifies these receipts -before activation. No adapter pretends this ABI works in the old server dispatcher. +altered receipt rejection at the build boundary. The managed workspace host loads +the resulting checked candidate before activation. No adapter pretends this ABI +works in the old server dispatcher. Component packages add `components = { inherit (react) sdk runtime; entries; }`. The ordinary execution candidate checker supplies the selected React build inputs From 85734e30038b98c3ce7b93db4bc647285d259db2 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Tue, 22 Sep 2026 13:16:23 -0700 Subject: [PATCH 08/10] Implement typed module contracts and conformance bindings --- build-components.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build-components.mjs b/build-components.mjs index c40f756..c6fe63f 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -171,7 +171,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec const body = { id, subject: declaration.subject, - compatibility: "quixos-react-component-v1", + compatibility: "org.quixos.react.esm/1", path: directory + "/module.mjs", exportName: entry.export, sharedModules, From 0da022e216b26e1cea917d4dd7861e25f15a76d0 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Tue, 22 Sep 2026 13:51:32 -0700 Subject: [PATCH 09/10] Build composable Camino packages with explicit type and source dependencies --- artifacts.nix | 3 ++ assemble-package.mjs | 103 +++++++++++++++++++++++++++++++++++++ assemble-package.nix | 29 +++++++++++ build-artifacts.mjs | 81 +++-------------------------- build-components.mjs | 60 +++++++++++++++++++-- build-react-contracts.mjs | 90 ++++++++++++++++++++++++++++++++ build-service.mjs | 88 +++++++++++++++++++++++++++++++ quixos-package-helpers.nix | 6 ++- react-contracts.nix | 21 ++++++++ react-package.nix | 34 ++++++++++++ typescript-package.nix | 41 +++++++++++++++ typescript-service.nix | 30 +++++++++++ 12 files changed, 508 insertions(+), 78 deletions(-) create mode 100644 assemble-package.mjs create mode 100644 assemble-package.nix create mode 100644 build-react-contracts.mjs create mode 100644 build-service.mjs create mode 100644 react-contracts.nix create mode 100644 react-package.nix create mode 100644 typescript-package.nix create mode 100644 typescript-service.nix diff --git a/artifacts.nix b/artifacts.nix index 2f43b76..8e2542a 100644 --- a/artifacts.nix +++ b/artifacts.nix @@ -13,6 +13,7 @@ service ? { }, nodeModules ? null, components ? null, + group ? false, }: let compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs"; @@ -26,6 +27,7 @@ let sharedRuntime nodeModules service + group ; components = if components == null then @@ -35,6 +37,7 @@ let inherit (components) sdk runtime entries; dependencies = components.sdk.dependencies; bindingOutput = components.bindingOutput or "src/gen/client.ts"; + contracts = components.contracts or [ ]; }; source = src; portable = diff --git a/assemble-package.mjs b/assemble-package.mjs new file mode 100644 index 0000000..56e226e --- /dev/null +++ b/assemble-package.mjs @@ -0,0 +1,103 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { buildService } from "./build-service.mjs"; + +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((pkg) => pkg.revision === config.packageRevisionId); +if (!pkg || compiler.hexDigest(plan.definition) !== plan.digest) throw Error("Invalid package assembly context"); +const output = process.env.out; +if (!output) throw Error("Package assembly requires an output directory"); +const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); +const generatorDigest = digest(await fs.readFile(config.compiler)); +const implementations = [], + components = [], + files = new Map(), + ids = new Set(); +const recordFile = async (name, mediaType) => { + const bytes = await fs.readFile(path.join(output, compiler.artifactPath(name))); + return { path: name, bytes: bytes.length, digest: digest(bytes), mediaType }; +}; +await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true }); +for (const root of config.groups) { + const manifest = compiler.parsePackageArtifacts( + JSON.parse(await fs.readFile(path.join(root, "share/quixos/package-artifacts.json"), "utf8")), + ); + if ( + manifest.packageRevisionId !== pkg.revision || + manifest.bindingDigest !== pkg.bindingDigest || + manifest.sourceDigest !== pkg.sourceDigest || + JSON.stringify(manifest.versions) !== JSON.stringify(plan.definition.versions) + ) + throw Error("Artifact group belongs to a different checked package"); + const receipt = JSON.parse(await fs.readFile(path.join(root, "share/quixos/build-receipt.json"), "utf8")); + const expected = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); + if (JSON.stringify(receipt) !== JSON.stringify(expected.receipt)) + throw Error("Artifact group has an invalid build receipt"); + const read = await compiler.artifactFileReader({ [pkg.revision]: root }); + for (const artifact of [...manifest.implementations, ...(manifest.components ?? [])]) { + const key = `${artifact.compatibility === "org.quixos.react.esm/1" ? "module" : "implementation"}:${artifact.id}`; + if (ids.has(key)) throw Error("Duplicate artifact group export " + key); + ids.add(key); + for (const file of artifact.files) { + const bytes = await read(pkg.revision, file.path, file.bytes); + if (bytes.length !== file.bytes || digest(bytes) !== file.digest) + throw Error("Modified group asset " + file.path); + const previous = files.get(file.path); + if (previous && JSON.stringify(previous) !== JSON.stringify(file)) + throw Error("Conflicting group asset " + file.path); + if (!previous) { + await fs.mkdir(path.dirname(path.join(output, file.path)), { recursive: true }); + await fs.writeFile(path.join(output, file.path), bytes, { mode: file.path.startsWith("bin/") ? 0o755 : 0o644 }); + files.set(file.path, file); + } + } + } + for (const [name, bytes] of expected.sources) { + const actual = await fs.readFile(path.join(root, name)); + if (!Buffer.from(bytes).equals(actual)) throw Error("Modified group type witness " + name); + await fs.writeFile(path.join(output, name), bytes); + } + implementations.push(...manifest.implementations); + components.push(...(manifest.components ?? [])); +} +if (implementations.filter((entry) => entry.compatibility === "quixos-service-v1").length > 1) + throw Error("A package can have only one process service owner"); +if (!implementations.some((entry) => entry.compatibility === "quixos-service-v1")) + await buildService({ + config: { ...config, service: {} }, + compiler, + pkg, + output, + coreServer: path.join(config.sharedRuntime, "core.mjs"), + sharedFiles: [...files.values()].filter((file) => file.path.startsWith("share/quixos/shared/")), + implementations, + recordFile, + }); +const manifest = compiler.parsePackageArtifacts({ + schemaVersion: 1, + packageRevisionId: pkg.revision, + bindingDigest: pkg.bindingDigest, + sourceDigest: pkg.sourceDigest, + versions: plan.definition.versions, + implementations, + ...(components.length ? { components } : {}), +}); +const { receipt, sources } = compiler.expectedArtifactReceipt(plan, manifest, generatorDigest); +for (const [name, bytes] of sources) { + const actual = await fs.readFile(path.join(output, name)); + if (!Buffer.from(bytes).equals(actual)) throw Error("Assembly lacks checked generated source " + name); +} +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)); +// Check package coverage with the same public validator used at installation. +const packagePlan = { definition: { ...plan.definition, packages: [pkg] }, digest: "" }; +packagePlan.digest = compiler.hexDigest(packagePlan.definition); +await compiler.checkExecutionWorld( + packagePlan, + [manifest], + await compiler.artifactFileReader({ [pkg.revision]: output }), +); diff --git a/assemble-package.nix b/assemble-package.nix new file mode 100644 index 0000000..8550622 --- /dev/null +++ b/assemble-package.nix @@ -0,0 +1,29 @@ +{ + pkgs, + context, + artifacts, +}: +let + groups = map (artifact: artifact.caminoGroup or artifact) artifacts; + config = pkgs.writeText "camino-package-assembly.json" ( + builtins.toJSON { + inherit groups; + inherit (context) plan packageRevisionId sharedRuntime; + compiler = "${context.generator}/libexec/quixos-protocol/execution-world.mjs"; + node = "${pkgs.nodejs_24}/bin/node"; + esbuild = "${pkgs.esbuild}/bin/esbuild"; + shell = "${pkgs.runtimeShell}"; + serviceAdapter = ./portable-service.mjs; + } + ); +in +pkgs.runCommand "camino-package" + { + passthru = { + caminoGroups = groups; + assemblyConfig = config; + }; + } + '' + ${pkgs.nodejs_24}/bin/node ${./.}/assemble-package.mjs ${config} + '' diff --git a/build-artifacts.mjs b/build-artifacts.mjs index 5000bc7..7565a81 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -1,3 +1,4 @@ +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"; @@ -161,79 +162,13 @@ for (const { target, mode, settings } of builds) { }; implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) }); } -const components = await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }); -if (pkg.exports.length || config.service.entry) { - 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 { - const modules = implementations.filter((implementation) => implementation.target === "server"); - if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id)))) - throw Error("Generated service does not cover every package export; provide a custom service"); - if (!modules.length) throw Error("Generated service requires a checked registry"); - serviceEntry = path.resolve("service-entry.mjs"); - const imports = modules - .map( - (module, index) => - `import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`, - ) - .join("\n"); - await fs.writeFile( - serviceEntry, - imports + - `\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)}; -const registries = [${modules.map((_, index) => "registry" + index).join(",")}]; -const owners = new Map(); -for (const registry of registries) for (const id of registry.exports) { - if (owners.has(id)) throw Error("Duplicate registry export " + id); - owners.set(id, registry); -} -servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, context) { - const registry = owners.get(request.exportId); - if (!registry) throw Error("Unknown export"); - return registry.invoke(request, channel, context); -}});\n`, - ); - } - const serviceModule = "share/quixos/service/main.mjs"; - execFileSync( - config.esbuild, - [ - serviceEntry, - "--bundle", - "--platform=node", - "--format=esm", - "--minify-whitespace", - "--target=es2024", - `--external:${coreServer}`, - `--outfile=${path.join(output, serviceModule)}`, - ], - { stdio: "inherit" }, - ); - await fs.writeFile( - path.join(output, "bin/service"), - `#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${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 components = + config.group && !config.components + ? [] + : await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated }); +if (!config.group || config.service.entry) + await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile }); + const manifest = compiler.parsePackageArtifacts({ schemaVersion: 1, packageRevisionId: pkg.revision, diff --git a/build-components.mjs b/build-components.mjs index c6fe63f..c74636b 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -9,7 +9,9 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec if (pkg.components?.length) throw Error("Declared components require checked component build settings"); return []; } - const declarations = pkg.components ?? []; + 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 || @@ -86,6 +88,46 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec 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), @@ -93,10 +135,14 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec 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 + ".ts"); + const witness = path.join(work, "component-check-" + key + ".tsx"); + const module = pkg.modules.find((module) => module.id === id); await fs.writeFile( witness, - `import type {Components} from ${JSON.stringify("./" + bindingPath.replace(/\.ts$/, ".js"))};import {${entry.export} as Component} from ${JSON.stringify("./" + source.replace(/\.[cm]?tsx?$/, ".js"))};const checked:Components[${JSON.stringify(declaration.displayName)}]=Component;export default checked;\n`, + `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, @@ -118,15 +164,21 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec "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, [ - source, + facade, "--bundle", "--platform=browser", "--format=esm", diff --git a/build-react-contracts.mjs b/build-react-contracts.mjs new file mode 100644 index 0000000..185d858 --- /dev/null +++ b/build-react-contracts.mjs @@ -0,0 +1,90 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +const config = JSON.parse(await fs.readFile(process.argv[2], "utf8")); +const resource = JSON.parse(await fs.readFile(config.interface, "utf8")); +if (resource.kind !== "interface") throw Error("React props belong to an interface resource"); +const iface = resource.revision; +const output = process.env.out; +if (!output) throw Error("Missing Nix output"); +const work = path.resolve("contracts-source"); +const declarations = []; +await fs.cp(config.source, work, { recursive: true }); +const writable = async (directory) => { + await fs.chmod(directory, 0o755); + for (const entry of await fs.readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + if (entry.isSymbolicLink()) throw Error("Contract source cannot contain symlinks"); + if (entry.isDirectory()) await writable(file); + else { + await fs.chmod(file, 0o644); + if (/\.d\.[cm]?ts$/.test(file)) declarations.push(path.relative(work, file)); + } + } +}; +await writable(work); +await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" })); +if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules")); +const members = Object.create(null); +let index = 0; +for (const [id, entry] of Object.entries(config.members)) { + const member = (iface.template?.members ?? iface.members).find((member) => member.id === id); + if ( + member?.kind !== "module" || + !["org.quixos.react.component/1", "org.quixos.react.subject-only/1"].includes(member.contract) + ) + throw Error("Props entry is not a React module member: " + id); + if ( + !/^[$A-Z_a-z][$\w]*$/.test(entry.export) || + path.isAbsolute(entry.entry) || + entry.entry.split("/").some((part) => part === ".." || !part) + ) + throw Error("Invalid props type entry"); + const filename = `props-${index++}.ts`; + const typeImport = JSON.stringify("./" + entry.entry.replace(/\.[cm]?tsx?$/, ".js")); + await fs.writeFile( + path.join(work, filename), + `import type {${entry.export} as Props} from ${typeImport}; +type Assert = T; +type ObjectProps = Assert; +type ReservedSubject = Assert<"subject" extends keyof Props ? false : true>; +${member.contract === "org.quixos.react.subject-only/1" ? "type SubjectOnly = Assert<{} extends Props ? true : false>;" : ""} +export type {Props};\n`, + ); + members[id] = { path: filename.replace(/\.ts$/, ".d.ts"), export: "Props" }; +} +await fs.mkdir(path.join(output, "types"), { recursive: true }); +if (!Object.keys(members).length) throw Error("React type companion declares no module members"); +execFileSync( + config.node, + [ + config.tsc, + "--declaration", + "--emitDeclarationOnly", + "--strict", + "--target", + "es2024", + "--module", + "nodenext", + "--moduleResolution", + "nodenext", + "--rootDir", + work, + "--outDir", + path.join(output, "types"), + ...Object.values(members).map((entry) => path.join(work, entry.path.replace(/\.d\.ts$/, ".ts"))), + ], + { cwd: work, stdio: "inherit" }, +); +// TypeScript emits declarations for implementation sources, but does not copy +// authored declarations which those emitted files may import. +for (const file of declarations) { + await fs.mkdir(path.dirname(path.join(output, "types", file)), { recursive: true }); + await fs.copyFile(path.join(work, file), path.join(output, "types", file)); +} +await fs.writeFile(path.join(output, "package.json"), JSON.stringify({ type: "module" })); +if (config.dependencies) await fs.symlink(config.dependencies, path.join(output, "node_modules")); +await fs.writeFile( + path.join(output, "react-contracts.json"), + JSON.stringify({ schemaVersion: 1, interfaceRevisionId: iface.revisionId, members }, null, 2), +); diff --git a/build-service.mjs b/build-service.mjs new file mode 100644 index 0000000..5e3ca09 --- /dev/null +++ b/build-service.mjs @@ -0,0 +1,88 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +export async function buildService({ + config, + compiler, + pkg, + output, + coreServer, + sharedFiles, + implementations, + recordFile, +}) { + const relative = compiler.artifactPath; + if (pkg.exports.length || config.service.entry) { + 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 { + const modules = implementations.filter((implementation) => implementation.target === "server"); + if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id)))) + throw Error("Generated service does not cover every package export; provide a custom service"); + if (!modules.length) throw Error("Generated service requires a checked registry"); + serviceEntry = path.resolve("service-entry.mjs"); + const imports = modules + .map( + (module, index) => + `import { ${module.registryExport} as registry${index} } from ${JSON.stringify(path.join(output, module.path))};`, + ) + .join("\n"); + await fs.writeFile( + serviceEntry, + imports + + `\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)}; +const registries = [${modules.map((_, index) => "registry" + index).join(",")}]; +const owners = new Map(); +for (const registry of registries) for (const id of registry.exports) { + if (owners.has(id)) throw Error("Duplicate registry export " + id); + owners.set(id, registry); +} +servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, context) { + const registry = owners.get(request.exportId); + if (!registry) throw Error("Unknown export"); + return registry.invoke(request, channel, context); +}});\n`, + ); + } + const serviceModule = "share/quixos/service/main.mjs"; + execFileSync( + config.esbuild, + [ + serviceEntry, + "--bundle", + "--platform=node", + "--format=esm", + "--minify-whitespace", + "--target=es2024", + `--external:${coreServer}`, + `--outfile=${path.join(output, serviceModule)}`, + ], + { stdio: "inherit" }, + ); + await fs.writeFile( + path.join(output, "bin/service"), + `#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${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) }); + } +} diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index 346ec3e..3fa177e 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -846,7 +846,11 @@ let ); in { - mkCaminoArtifacts = import ./artifacts.nix; + mkCaminoReactPackage = import ./react-package.nix; + mkCaminoReactContracts = import ./react-contracts.nix; + mkCaminoTypeScriptPackage = import ./typescript-package.nix; + mkCaminoTypeScriptService = import ./typescript-service.nix; + assembleCaminoPackage = import ./assemble-package.nix; inherit mkQuixosPackageFlake mkTsPackageServer diff --git a/react-contracts.nix b/react-contracts.nix new file mode 100644 index 0000000..acd5b9b --- /dev/null +++ b/react-contracts.nix @@ -0,0 +1,21 @@ +{ + pkgs, + context, + src, + members, + dependencies ? null, +}: +let + config = pkgs.writeText "camino-react-contracts.json" ( + builtins.toJSON { + source = src; + interface = context.interface; + inherit members dependencies; + node = "${pkgs.nodejs_24}/bin/node"; + tsc = "${context.typescript}/node_modules/typescript/bin/tsc"; + } + ); +in +pkgs.runCommand "camino-react-contracts" { } '' + ${pkgs.nodejs_24}/bin/node ${./.}/build-react-contracts.mjs ${config} +'' diff --git a/react-package.nix b/react-package.nix new file mode 100644 index 0000000..e76ad74 --- /dev/null +++ b/react-package.nix @@ -0,0 +1,34 @@ +{ + pkgs, + context, + src, + modules, + bindingOutput ? "src/gen/client.ts", + dependencies ? null, +}: +let + group = import ./artifacts.nix { + inherit pkgs src; + inherit (context) + plan + core + sharedRuntime + typescript + packageRevisionId + ; + protocol = context.generator; + group = true; + nodeModules = dependencies; + components = { + inherit bindingOutput; + inherit (context.react) sdk runtime; + entries = modules; + contracts = context.react.contracts or [ ]; + }; + }; + package = import ./assemble-package.nix { + inherit pkgs context; + artifacts = [ group ]; + }; +in +package // { caminoGroup = group; } diff --git a/typescript-package.nix b/typescript-package.nix new file mode 100644 index 0000000..950dcbb --- /dev/null +++ b/typescript-package.nix @@ -0,0 +1,41 @@ +{ + pkgs, + context, + src, + entry, + targets ? [ + "browser" + "server" + ], + entries ? { }, + bindingOutput ? "src/gen/qx.ts", + dependencies ? null, +}: +let + group = import ./artifacts.nix { + inherit pkgs src; + inherit (context) + plan + core + sharedRuntime + typescript + packageRevisionId + ; + protocol = context.generator; + group = true; + nodeModules = dependencies; + portable = { + inherit + entry + entries + targets + bindingOutput + ; + }; + }; + package = import ./assemble-package.nix { + inherit pkgs context; + artifacts = [ group ]; + }; +in +package // { caminoGroup = group; } diff --git a/typescript-service.nix b/typescript-service.nix new file mode 100644 index 0000000..61ceea6 --- /dev/null +++ b/typescript-service.nix @@ -0,0 +1,30 @@ +{ + pkgs, + context, + src, + entry, + bindingOutput ? "src/gen/remote.ts", + dependencies ? null, + service ? { }, +}: +let + group = import ./artifacts.nix { + inherit pkgs src service; + inherit (context) + plan + core + sharedRuntime + typescript + packageRevisionId + ; + protocol = context.generator; + group = true; + nodeModules = dependencies; + remote = { inherit entry bindingOutput; }; + }; + package = import ./assemble-package.nix { + inherit pkgs context; + artifacts = [ group ]; + }; +in +package // { caminoGroup = group; } From 785c54992fc47ccea98cc4d11966b693d8b28a93 Mon Sep 17 00:00:00 2001 From: "Timothy J. Aveni" Date: Tue, 22 Sep 2026 14:42:54 -0700 Subject: [PATCH 10/10] Make Camino examples and scaffolds complete Yarn and Nix packages --- .yarn/plugins/yarn-plugin-nixify.cjs | 1 + build-artifacts.mjs | 21 ++++++----- build-components.mjs | 49 +++++++++++++++++++------ build-react-contracts.mjs | 7 +++- install-dependencies.mjs | 53 ++++++++++++++++++++++++++++ quixos-package-helpers.nix | 2 ++ yarn-dependencies.nix | 29 +++++++++++++++ yarn-shell.nix | 22 ++++++++++++ 8 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 .yarn/plugins/yarn-plugin-nixify.cjs create mode 100644 install-dependencies.mjs create mode 100644 yarn-dependencies.nix create mode 100644 yarn-shell.nix diff --git a/.yarn/plugins/yarn-plugin-nixify.cjs b/.yarn/plugins/yarn-plugin-nixify.cjs new file mode 100644 index 0000000..44f7f79 --- /dev/null +++ b/.yarn/plugins/yarn-plugin-nixify.cjs @@ -0,0 +1 @@ +module.exports={name:"yarn-plugin-nixify",factory:function(e){var t;return(()=>{"use strict";var n={d:(e,t)=>{for(var r in t)n.o(t,r)&&!n.o(e,r)&&Object.defineProperty(e,r,{enumerable:!0,get:t[r]})},o:(e,t)=>Object.prototype.hasOwnProperty.call(e,t),r:e=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})}},r={};n.r(r),n.d(r,{default:()=>E});const a=e("@yarnpkg/core"),o=e("clipanion");class i extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String({required:!1})}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd),n=await a.Cache.find(e),r=e.makeFetcher();return(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async e=>{if(this.locator){const{locatorHash:o}=a.structUtils.parseLocator(this.locator,!0),i=t.originalPackages.get(o);if(!i)return void e.reportError(0,`Invalid locator: ${this.locator}`);await r.fetch(i,{checksums:t.storedChecksums,project:t,cache:n,fetcher:r,report:e})}else await e.startTimerPromise("Resolution step",(async()=>{await t.resolveEverything({report:e,lockfileOnly:!0})})),await e.startTimerPromise("Fetch step",(async()=>{await t.fetchEverything({cache:n,report:e,fetcher:r})}))}))).exitCode()}}i.paths=[["nixify","fetch"]];const s=e("@yarnpkg/fslib"),c=e("crypto");class l extends o.Command{constructor(...e){super(...e),this.locator=o.Option.String(),this.source=o.Option.String(),this.installLocation=o.Option.String()}async execute(){const e=await a.Configuration.find(this.context.cwd,this.context.plugins),{project:t}=await a.Project.find(e,this.context.cwd);return await t.restoreInstallState({restoreResolutions:!1}),(await a.StreamReport.start({configuration:e,stdout:this.context.stdout},(async n=>{await t.resolveEverything({report:n,lockfileOnly:!0});const r=a.structUtils.parseLocator(this.locator,!0),o=t.storedPackages.get(r.locatorHash);if(!o)return void n.reportError(0,`Invalid locator: ${this.locator}`);const i=s.ppath.join(t.cwd,this.installLocation);await s.xfs.mkdirpPromise(s.ppath.dirname(i)),await a.execUtils.execvp("cp",["-R",this.source,i],{cwd:t.cwd,strict:!0}),await a.execUtils.execvp("chmod",["-R","u+w",i],{cwd:t.cwd,strict:!0});const l=(0,c.createHash)("sha512");l.update(process.versions.node),e.triggerHook((e=>e.globalHashGeneration),t,(e=>{l.update("\0"),l.update(e)}));const d=l.digest("hex"),p=new Map,h=e=>{let n=p.get(e.locatorHash);if(void 0!==n)return n;const r=t.storedPackages.get(e.locatorHash);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");const o=(0,c.createHash)("sha512");o.update(e.locatorHash),p.set(e.locatorHash,"");for(const e of r.dependencies.values()){const n=t.storedResolutions.get(e.descriptorHash);if(void 0===n)throw new Error(`Assertion failed: The resolution (${a.structUtils.prettyDescriptor(t.configuration,e)}) should have been registered`);const r=t.storedPackages.get(n);if(void 0===r)throw new Error("Assertion failed: The package should have been registered");o.update(h(r))}return n=o.digest("hex"),p.set(e.locatorHash,n),n},u=(0,c.createHash)("sha512").update(d).update(h(o)).update(i).digest("hex");t.storedBuildState.set(o.locatorHash,u),await t.persistInstallStateFile()}))).exitCode()}}l.paths=[["nixify","inject-build"]];const d=e("@yarnpkg/plugin-pnp"),p=JSON.stringify,h=(e,t,n=!1)=>t.split("\n").map((t=>t||n?e+t:t)).join("\n"),u=(e,t)=>{let n=e;for(const[e,r]of Object.entries(t))if("string"==typeof r&&(n=n.replace(new RegExp(`@@${e}@@`,"g"),r)),"boolean"==typeof r)for(;;){const t=n.split("\n"),a=t.indexOf(`#@@ IF ${e}`),o=t.indexOf(`#@@ ENDIF ${e}`);if(-1===a||o{if(!n)return;const o=s.npath.toPortablePath(this.binDir);for(const[r,a]of n.manifest.bin){const n=s.ppath.join(o,r),i=s.ppath.join(t.cwd,s.npath.toPortablePath(a));await this.writeWrapper(n,i,{configuration:e,project:t})}if(e.get("installNixBinariesForDependencies")){await t.resolveEverything({report:r,lockfileOnly:!0});const n=await a.scriptUtils.getPackageAccessibleBinaries(t.topLevelWorkspace.anchoredLocator,{project:t});for(const[r,[a,i]]of n.entries()){const n=s.ppath.join(o,r);await this.writeWrapper(n,s.npath.toPortablePath(i),{configuration:e,project:t})}}}))).exitCode()}async writeWrapper(e,t,{configuration:n,project:r}){let a;switch(n.get("nodeLinker")){case"pnp":{const e=(0,d.getPnpPath)(r),n=[];await s.xfs.existsPromise(e.cjs)&&n.push(`--require "${s.npath.fromPortablePath(e.cjs)}"`),await s.xfs.existsPromise(e.esmLoader)&&n.push(`--experimental-loader "${(0,f.pathToFileURL)(s.npath.fromPortablePath(e.esmLoader)).href}"`),a=u("#!/bin/sh\nexport NODE_OPTIONS='@@NODE_OPTIONS@@'\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,NODE_OPTIONS:n.join(" "),BINARY_PATH:t});break}case"node-modules":a=u("#!/bin/sh\nexec '@@NODE_PATH@@' '@@BINARY_PATH@@' \"$@\"\n",{NODE_PATH:process.execPath,BINARY_PATH:t});break;default:throw Error("Assertion failed: Invalid nodeLinker")}await s.xfs.writeFilePromise(e,a),await s.xfs.chmodPromise(e,493)}}g.paths=[["nixify","install-bin"]];const m=e("os"),y=e("@yarnpkg/plugin-patch"),b=(e,t)=>(0,c.createHash)(e).update(t).digest(),x=(e,t,{storePath:n="/nix/store",recursive:r=!1}={})=>{const[a,o]=t.split("-"),i=Buffer.from(o,"base64").toString("hex"),c=b("sha256",`fixed:out:${r?"r:":""}${a}:${i}:`).toString("hex"),l=(e=>{let t="",n=[...e].reverse().map((e=>e.toString(2).padStart(8,"0"))).join("");for(;n;)t+="0123456789abcdfghijklmnpqrsvwxyz"[parseInt(n.slice(0,5),2)],n=n.slice(5);return t})(((e,t)=>{const n=Buffer.alloc(20);for(let t=0;te.replace(/^\.+/,"").replace(/[^a-zA-Z0-9+._?=-]+/g,"-").slice(0,207)||"unknown",w=(e,t="sha512")=>t+"-"+Buffer.from(e,"hex").toString("base64"),k=e=>Buffer.from(e.split("-")[1],"base64").toString("hex"),I=2**32-1,P=(e,...t)=>{let n=0;const r=t.map((e=>{const t=Buffer.from(e);if(t.byteLength>I)throw Error(`NAR string too long: ${t.byteLength}`);return n+=8+8*Math.ceil(t.byteLength/8),t})),a=Buffer.alloc(n);let o=0;for(const e of r)a.writeUInt32LE(e.byteLength,o),e.copy(a,o+8),o+=8+8*Math.ceil(e.byteLength/8);e.write(a)},$=async(e,t,n)=>{if(t>I)throw Error(`NAR string too long: ${t}`);const r=Buffer.alloc(8);r.writeUInt32LE(t),e.write(r);for await(const t of n)e.write(t);const a=8-t%8;8!==a&&e.write(Buffer.alloc(a))},N=a.YarnVersion?.startsWith("3.")||!1,E={commands:[i,l,g],hooks:{afterAllInstalled:async(e,t)=>{!1!==t.persistProject&&e.configuration.get("enableNixify")&&await(async(e,t)=>{const{configuration:n,cwd:r}=e,{cache:o,report:i}=t,l=await s.xfs.realpathPromise(s.npath.toPortablePath((0,m.tmpdir)()));if(e.cwd.startsWith(l))return void i.reportInfo(0,`Skipping Nixify, because ${e.cwd} appears to be a temporary directory`);const d=n.get("nixExprPath"),f=n.get("yarnPath");let g;if(null===f){let e=(await s.xfs.readFilePromise(process.argv[1])).toString();if(e.startsWith("#!/nix/store/")){const t=e.substring(e.indexOf("\n")+1);e=`#!/usr/bin/env node\n${t}`}const t=a.hashUtils.makeHash(Buffer.from(e));g=["fetchurl {",` url = "https://repo.yarnpkg.com/${a.YarnVersion}/packages/yarnpkg-cli/bin/yarn.js";`,` hash = "${w(t)}";`,"}"].join("\n ")}else f.startsWith(r)?g="./"+s.ppath.relative(s.ppath.dirname(d),f):(g=p(f),i.reportWarning(0,`The Yarn path ${f} is outside the project - it may not be reachable by the Nix build`));const b=n.get("cacheFolder");let I;if(b.startsWith(r))I=p(s.ppath.relative(r,b));else{if(N||!n.get("enableGlobalCache"))throw Error(`The cache folder ${b} is outside the project, this is currently not supported`);I='".yarn/cache"'}const E=new Set;for(const e of n.sources.values())for(const t of e.split(", "))t.startsWith("<")||E.add(t);for(const e of E)s.ppath.resolve(r,e).startsWith(r)||i.reportWarning(0,`The config file ${e} is outside the project - it may not be reachable by the Nix build`);const D="./"+s.ppath.relative(s.ppath.dirname(d),s.ppath.resolve(r,"yarn.lock")),S=new Map,_=new Set(await s.xfs.readdirPromise(o.cwd)),L={unstablePackages:e.conditionalLocators};for(const t of e.storedPackages.values()){const{locatorHash:n}=t,r=e.storedChecksums.get(n),a=N?o.getLocatorPath(t,r||null,L):o.getLocatorPath(t,r||null);if(!a)continue;if(!_.has(s.ppath.basename(a)))continue;const i=r?o.getChecksumFilename(t,r):o.getVersionFilename(t);S.set(i,{pkg:t,checksum:r,cachePath:a})}const O=new Map,A=n.get("individualNixPackaging");let j="",T="";if(A){for(const[e,{pkg:t,checksum:n,cachePath:r}]of S.entries()){const o=a.structUtils.stringifyLocator(t),i=n?n.split("/").pop():await a.hashUtils.checksumFile(r);O.set(o,{cachePath:r,filename:e,hash:w(i)})}j="cacheEntries = {\n";for(const e of[...O.keys()].sort()){const t=O.get(e);j+=`${p(e)} = { ${[`filename = ${p(t.filename)};`,`hash = "${t.hash}";`].join(" ")} };\n`}j+="};"}else{const e=(0,c.createHash)("sha512");P(e,"nix-archive-1","(","type","directory");for(const t of[...S.keys()].sort()){const{cachePath:n}=S.get(t),{size:r}=await s.xfs.statPromise(n);P(e,"entry","(","name",t,"node","(","type","regular","contents"),await $(e,r,s.xfs.createReadStream(n)),P(e,")",")")}P(e,")"),e.end();for await(const t of e)T=w(t)}const C=n.get("isolatedNixBuilds");let R=new Set,F=[],B=[];const H=n.get("nodeLinker"),U=n.get("pnpUnpluggedFolder"),M=(t,n=new Set)=>{const r=a.structUtils.stringifyLocator(t);if(O.has(r)&&n.add(r),a.structUtils.isVirtualLocator(t)){const r=e.storedPackages.get(a.structUtils.devirtualizeLocator(t).locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}if(t.reference.startsWith("patch:")){const r=e.storedPackages.get(y.patchUtils.parseLocator(t).sourceLocator.locatorHash);if(!r)throw Error("Assertion failed: The locator should have been registered");M(r,n)}for(const r of t.dependencies.values()){const t=e.storedResolutions.get(r.descriptorHash);if(!t)throw Error("Assertion failed: The descriptor should have been registered");const a=e.storedPackages.get(t);if(!a)throw Error("Assertion failed: The locator should have been registered");M(a,n)}return n};for(const t of e.storedBuildState.keys()){const n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");if(!C.includes(n.name))continue;let r;if("pnp"!==H)throw Error(`The nodeLinker ${H} is not supported for isolated Nix builds`);r=s.ppath.relative(e.cwd,s.ppath.join(U,a.structUtils.slugifyLocator(n),a.structUtils.getIdentVendorPath(n)));let o=n;if(a.structUtils.isVirtualLocator(o)){const{locatorHash:t}=a.structUtils.devirtualizeLocator(o),n=e.storedPackages.get(t);if(!n)throw Error("Assertion failed: The locator should have been registered");o=n}const i=a.structUtils.stringifyLocator(o),c=a.structUtils.stringifyLocator(n),l=`isolated.${p(i)}`;if(!R.has(o)){R.add(o);const e=[`pname = ${p(n.name)};`,`version = ${p(n.version)};`,`reference = ${p(o.reference)};`];if(A){const t=[...M(n)].sort().map((e=>`${p(e)}\n`)).join("");t&&e.push(`locators = [\n${t}];`)}const t=`override${V=n.name,V.split(/[^a-zA-Z0-9]+/g).filter((e=>e)).map((e=>{return(t=e).slice(0,1).toUpperCase()+t.slice(1);var t})).join("")}Attrs`;B.push(`${l} = optionalOverride (args.${t} or null) (mkIsolatedBuild { ${e.join(" ")} });`)}0===F.length&&F.push("# Copy in isolated builds."),F.push(`echo 'injecting build for ${n.name}'`,"yarn nixify inject-build \\",` ${p(c)} \\`,` \${${l}} \\`,` ${p(r)}`)}var V;if(F.length>0&&F.push("echo 'running yarn install'"),null==t.mode||0===C.length){const t=e.topLevelWorkspace.manifest.name,o=t?a.structUtils.stringifyIdent(t):"workspace",c=u("# This file is generated by running \"yarn install\" inside your project.\n# Manual changes might be lost - proceed with caution!\n\n{ lib, stdenv, nodejs, git, cacert, fetchurl, writeShellScript, writeShellScriptBin }:\n{ src, overrideAttrs ? null, ... } @ args:\n\nlet\n\n yarnBin = @@YARN_BIN@@;\n\n cacheFolder = @@CACHE_FOLDER@@;\n lockfile = @@LOCKFILE@@;\n\n # Call overrideAttrs on a derivation if a function is provided.\n optionalOverride = fn: drv:\n if fn == null then drv else drv.overrideAttrs fn;\n\n # Simple stub that provides the global yarn command.\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${nodejs}/bin/node' '${yarnBin}' \"$@\"\n '';\n\n # Common attributes between Yarn derivations.\n drvCommon = {\n # Make sure the build uses the right Node.js version everywhere.\n buildInputs = [ nodejs yarn ];\n # All dependencies should already be cached.\n yarn_enable_network = \"0\";\n # Tell node-gyp to use the provided Node.js headers for native code builds.\n npm_config_nodedir = nodejs;\n };\n\n # Comman variables that we set in a Nix build, but not in a Nix shell.\n buildVars = ''\n # Make Yarn produce friendlier logging for automated builds.\n export CI=1\n # Tell node-pre-gyp to never fetch binaries / always build from source.\n export npm_config_build_from_source=true\n '';\n\n#@@ IF COMBINED_DRV\n cacheDrv = stdenv.mkDerivation {\n name = \"yarn-cache\";\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cp --reflink=auto --recursive '${src}' ./src\n cd ./src/\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$out\" \\\n yarn nixify fetch\n rm $out/.gitignore\n '';\n outputHashMode = \"recursive\";\n outputHash = \"@@COMBINED_HASH@@\";\n };\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n # Create derivations for fetching dependencies.\n cacheDrvs = let\n in lib.mapAttrs (locator: { filename, hash }: stdenv.mkDerivation {\n name = lib.strings.sanitizeDerivationName locator;\n buildInputs = [ yarn git cacert ];\n buildCommand = ''\n cd '${src}'\n ${buildVars}\n HOME=\"$TMP\" yarn_enable_global_cache=false yarn_cache_folder=\"$TMP\" \\\n yarn nixify fetch ${lib.escapeShellArg locator}\n # Because we change the cache dir, Yarn may generate a different name.\n mv \"$TMP/$(sed 's/-[^-]*\\.[^-]*$//' <<< \"$outputFilename\")\"-* $out\n '';\n outputFilename = filename;\n outputHash = hash;\n }) cacheEntries;\n\n # Create a shell snippet to copy dependencies from a list of derivations.\n mkCacheBuilderForDrvs = drvs:\n writeShellScript \"collect-yarn-cache\" (lib.concatMapStrings (drv: ''\n cp --reflink=auto ${drv} '${drv.outputFilename}'\n '') drvs);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n#@@ IF NEED_ISOLATED_BUILD_SUPPRORT\n#@@ IF INDIVIDUAL_DRVS\n # Create a shell snippet to copy dependencies from a list of locators.\n mkCacheBuilderForLocators = let\n pickCacheDrvs = map (locator: cacheDrvs.${locator});\n in locators:\n mkCacheBuilderForDrvs (pickCacheDrvs locators);\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Create a derivation that builds a module in isolation.\n mkIsolatedBuild = { pname, version, reference, locators ? [] }: stdenv.mkDerivation (drvCommon // {\n inherit pname version;\n dontUnpack = true;\n\n configurePhase = ''\n ${buildVars}\n unset yarn_enable_nixify # plugin is not present\n '';\n\n buildPhase = ''\n mkdir -p .yarn/cache\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* .yarn/cache/\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd .yarn/cache > /dev/null\n source ${mkCacheBuilderForLocators locators}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n echo '{ \"dependencies\": { \"${pname}\": \"${reference}\" } }' > package.json\n install -m 0600 ${lockfile} ./yarn.lock\n export yarn_global_folder=\"$TMP\"\n export yarn_enable_global_cache=false\n export yarn_enable_immutable_installs=false\n yarn\n '';\n\n installPhase = ''\n unplugged=( .yarn/unplugged/${pname}-*/node_modules/* )\n if [[ ! -e \"''${unplugged[@]}\" ]]; then\n echo >&2 \"Could not find the unplugged path for ${pname}\"\n exit 1\n fi\n\n mv \"$unplugged\" $out\n '';\n });\n#@@ ENDIF NEED_ISOLATED_BUILD_SUPPRORT\n\n # Main project derivation.\n project = stdenv.mkDerivation (drvCommon // {\n inherit src;\n name = @@PROJECT_NAME@@;\n\n configurePhase = ''\n ${buildVars}\n\n # Copy over the Yarn cache.\n rm -fr '${cacheFolder}'\n mkdir -p '${cacheFolder}'\n#@@ IF COMBINED_DRV\n cp --reflink=auto --recursive ${cacheDrv}/* '${cacheFolder}/'\n#@@ ENDIF COMBINED_DRV\n#@@ IF INDIVIDUAL_DRVS\n pushd '${cacheFolder}' > /dev/null\n source ${mkCacheBuilderForDrvs (lib.attrValues cacheDrvs)}\n popd > /dev/null\n#@@ ENDIF INDIVIDUAL_DRVS\n\n # Yarn may need a writable home directory.\n export yarn_global_folder=\"$TMP\"\n\n # Ensure global cache is disabled. Cache must be part of our output.\n touch .yarnrc.yml\n sed -i -e '/^enableGlobalCache/d' .yarnrc.yml\n echo 'enableGlobalCache: false' >> .yarnrc.yml\n\n # Some node-gyp calls may call out to npm, which could fail due to an\n # read-only home dir.\n export HOME=\"$TMP\"\n\n # running preConfigure after the cache is populated allows for\n # preConfigure to contain substituteInPlace for dependencies as well as the\n # main project. This is necessary for native bindings that maybe have\n # hardcoded values.\n runHook preConfigure\n\n@@ISOLATED_INTEGRATION@@\n\n # Run normal Yarn install to complete dependency installation.\n yarn install --immutable --immutable-cache\n\n runHook postConfigure\n '';\n\n buildPhase = ''\n runHook preBuild\n runHook postBuild\n '';\n\n installPhase = ''\n runHook preInstall\n\n # Move the package contents to the output directory.\n if grep -q '\"workspaces\"' package.json; then\n # We can't use `yarn pack` in a workspace setup, because it only\n # packages the outer workspace.\n mkdir -p \"$out/libexec\"\n mv $PWD \"$out/libexec/$name\"\n else\n # - If the package.json has a `files` field, only files matching those patterns are copied\n # - Otherwise all files are copied.\n yarn pack --out package.tgz\n mkdir -p \"$out/libexec/$name\"\n tar xzf package.tgz --directory \"$out/libexec/$name\" --strip-components=1\n\n cp --reflink=auto .yarnrc* \"$out/libexec/$name\"\n cp --reflink=auto ${lockfile} \"$out/libexec/$name/yarn.lock\"\n cp --reflink=auto --recursive .yarn \"$out/libexec/$name\"\n\n # Copy the Yarn linker output into the package.\n#@@ IF USES_PNP_LINKER\n cp --reflink=auto .pnp.* \"$out/libexec/$name\"\n#@@ ENDIF USES_PNP_LINKER\n#@@ IF USES_NM_LINKER\n cp --reflink=auto --recursive node_modules \"$out/libexec/$name\"\n#@@ ENDIF USES_NM_LINKER\n fi\n\n cd \"$out/libexec/$name\"\n\n # Invoke a plugin internal command to setup binaries.\n mkdir -p \"$out/bin\"\n yarn nixify install-bin $out/bin\n\n#@@ IF USES_NM_LINKER\n # A package with node_modules doesn't need the cache\n yarn cache clean\n#@@ ENDIF USES_NM_LINKER\n\n runHook postInstall\n '';\n\n passthru = {\n inherit nodejs;\n yarn-freestanding = yarn;\n yarn = writeShellScriptBin \"yarn\" ''\n exec '${yarn}/bin/yarn' --cwd '${overriddenProject}/libexec/${overriddenProject.name}' \"$@\"\n '';\n };\n });\n\n overriddenProject = optionalOverride overrideAttrs project;\n\n@@CACHE_ENTRIES@@\n@@ISOLATED@@\nin overriddenProject\n",{PROJECT_NAME:p(o),YARN_BIN:g,LOCKFILE:D,INDIVIDUAL_DRVS:A,COMBINED_DRV:!A,COMBINED_HASH:T,CACHE_FOLDER:I,CACHE_ENTRIES:j,ISOLATED:B.join("\n"),ISOLATED_INTEGRATION:h(" ",F.join("\n")),NEED_ISOLATED_BUILD_SUPPRORT:F.length>0,USES_PNP_LINKER:"pnp"===n.get("nodeLinker"),USES_NM_LINKER:"node-modules"===n.get("nodeLinker")}).replace(/\n\n\n+/g,"\n\n");if(await s.xfs.mkdirpPromise(s.ppath.dirname(d)),await s.xfs.writeFilePromise(d,c),n.get("generateDefaultNix")){const e=s.ppath.join(r,"default.nix"),t=s.ppath.join(r,"flake.nix");s.xfs.existsSync(e)||s.xfs.existsSync(t)||(await s.xfs.writeFilePromise(e,"# This is a minimal `default.nix` by yarn-plugin-nixify. You can customize it\n# as needed, it will not be overwritten by the plugin.\n\n{ pkgs ? import { } }:\n\npkgs.callPackage ./yarn-project.nix { } { src = ./.; }\n"),i.reportInfo(0,"A minimal default.nix was created. You may want to customize it."))}}n.get("enableNixPreload")&&s.xfs.existsSync(s.npath.toPortablePath("/nix/store"))&&await s.xfs.mktempPromise((async t=>{const n=["--add-fixed","sha512"],r=[];if(A)for(const[e,{cachePath:n,hash:a}]of O.entries()){const o=v(e),i=x(o,a);if(!s.xfs.existsSync(i)){const e=s.ppath.join(t,k(a).slice(0,7));await s.xfs.mkdirPromise(e);const i=s.ppath.join(e,o);await s.xfs.copyFilePromise(n,i),r.push(i)}}else{n.unshift("--recursive");const e=x("yarn-cache",T,{recursive:!0});if(!s.xfs.existsSync(e)){const e=s.ppath.join(t,"yarn-cache");await s.xfs.mkdirPromise(e);for(const[t,{cachePath:n}]of S.entries()){const r=s.ppath.join(e,t);await s.xfs.copyFilePromise(n,r)}r.push(e)}}try{const t=r.length;for(;0!==r.length;){const t=r.splice(0,100);await a.execUtils.execvp("nix-store",[...n,...t],{cwd:e.cwd,strict:!0})}0!==t&&i.reportInfo(0,A?`Preloaded ${t} packages into the Nix store`:"Preloaded cache into the Nix store")}catch(e){if("ENOENT"!==e.code)throw e}}))})(e,t)}},configuration:{enableNixify:{description:"If false, disables the Nixify plugin hook that generates Nix expressions",type:a.SettingsType.BOOLEAN,default:!0},nixExprPath:{description:"Path of the file where the project Nix expression will be written to",type:a.SettingsType.ABSOLUTE_PATH,default:"./yarn-project.nix"},generateDefaultNix:{description:"If true, a default.nix will be generated if it does not exist",type:a.SettingsType.BOOLEAN,default:!0},enableNixPreload:{description:"If true, cached packages will be preloaded into the Nix store",type:a.SettingsType.BOOLEAN,default:!0},individualNixPackaging:{description:"If true, generate one Nix derivation per package. If false, use a single derivation for the entire cache folder.",type:a.SettingsType.BOOLEAN,default:!1},isolatedNixBuilds:{description:"Dependencies with a build step that can be built in an isolated derivation",type:a.SettingsType.STRING,default:[],isArray:!0},installNixBinariesForDependencies:{description:"If true, the Nix output 'bin' directory will also contain executables for binaries defined by dependencies",type:a.SettingsType.BOOLEAN,default:!1}}};t=r})(),t}}; \ No newline at end of file diff --git a/build-artifacts.mjs b/build-artifacts.mjs index 7565a81..8bf757f 100644 --- a/build-artifacts.mjs +++ b/build-artifacts.mjs @@ -1,3 +1,4 @@ +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. */ @@ -69,14 +70,18 @@ for (const { target, mode, settings } of builds) { 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" })); + 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" diff --git a/build-components.mjs b/build-components.mjs index c74636b..ba34594 100644 --- a/build-components.mjs +++ b/build-components.mjs @@ -1,3 +1,4 @@ +import { installDependencies } from "./install-dependencies.mjs"; import fs from "node:fs/promises"; import path from "node:path"; import { createHash } from "node:crypto"; @@ -66,17 +67,43 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec 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 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)};` + + (name === "@quixos/camino-react" ? "" : `export {default} from ${JSON.stringify(url)};`), + ); + sharedAliases.push("--alias:" + name + "=" + shim, "--external:" + url); } - 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(); @@ -187,7 +214,7 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec '--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]), + ...sharedAliases, ...["svg", "png", "jpg", "jpeg", "gif", "webp", "avif", "woff", "woff2", "ttf", "ico"].map( (extension) => "--loader:." + extension + "=dataurl", ), diff --git a/build-react-contracts.mjs b/build-react-contracts.mjs index 185d858..0081175 100644 --- a/build-react-contracts.mjs +++ b/build-react-contracts.mjs @@ -23,7 +23,12 @@ const writable = async (directory) => { } }; await writable(work); -await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" })); +const packageFile = path.join(work, "package.json"); +const metadata = await fs.readFile(packageFile, "utf8").then(JSON.parse, (error) => { + if (error.code === "ENOENT") return {}; + throw error; +}); +await fs.writeFile(packageFile, JSON.stringify({ ...metadata, type: "module" })); if (config.dependencies) await fs.symlink(config.dependencies, path.join(work, "node_modules")); const members = Object.create(null); let index = 0; diff --git a/install-dependencies.mjs b/install-dependencies.mjs new file mode 100644 index 0000000..3481799 --- /dev/null +++ b/install-dependencies.mjs @@ -0,0 +1,53 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { createHash } from "node:crypto"; + +async function packages(directory) { + const result = new Map(); + for (const entry of await fs.readdir(directory)) { + if (entry.startsWith(".")) continue; + if (entry.startsWith("@")) { + for (const name of await fs.readdir(path.join(directory, entry))) + result.set(`${entry}/${name}`, path.join(directory, entry, name)); + } else result.set(entry, path.join(directory, entry)); + } + return result; +} +async function packageDigest(directory) { + const digest = createHash("sha256"); + async function visit(relative) { + for (const entry of (await fs.readdir(path.join(directory, relative), { withFileTypes: true })).sort((a, b) => + a.name.localeCompare(b.name), + )) { + if (entry.name === "node_modules") continue; + const name = path.join(relative, entry.name), + file = path.join(directory, name); + if (entry.isDirectory()) await visit(name); + else { + const bytes = await fs.readFile(file); + digest.update(JSON.stringify([name, bytes.length])); + digest.update(bytes); + } + } + } + await visit(""); + return digest.digest("hex"); +} +/** Merge at package granularity: a scope such as @types is not a reserved package. */ +export async function installDependencies({ destination, application, defaults = [], selected = {}, reserved = [] }) { + const entries = new Map(); + for (const root of defaults) for (const [name, file] of await packages(root)) entries.set(name, file); + for (const [name, file] of Object.entries(selected)) entries.set(name, file); + if (application) + for (const [name, file] of await packages(application)) { + if (reserved.includes(name)) { + const canonical = entries.get(name); + if (!canonical || (await packageDigest(file)) !== (await packageDigest(canonical))) + throw Error("Application dependencies cannot substitute selected React/client types or checked SDK: " + name); + } else entries.set(name, file); + } + for (const [name, file] of entries) { + await fs.mkdir(path.dirname(path.join(destination, name)), { recursive: true }); + await fs.symlink(file, path.join(destination, name)); + } +} diff --git a/quixos-package-helpers.nix b/quixos-package-helpers.nix index 3fa177e..356a7d4 100644 --- a/quixos-package-helpers.nix +++ b/quixos-package-helpers.nix @@ -848,6 +848,8 @@ in { mkCaminoReactPackage = import ./react-package.nix; mkCaminoReactContracts = import ./react-contracts.nix; + mkCaminoYarnDependencies = import ./yarn-dependencies.nix; + mkCaminoYarnShell = import ./yarn-shell.nix; mkCaminoTypeScriptPackage = import ./typescript-package.nix; mkCaminoTypeScriptService = import ./typescript-service.nix; assembleCaminoPackage = import ./assemble-package.nix; diff --git a/yarn-dependencies.nix b/yarn-dependencies.nix new file mode 100644 index 0000000..b444174 --- /dev/null +++ b/yarn-dependencies.nix @@ -0,0 +1,29 @@ +{ + pkgs, + src, + nodejs ? pkgs.nodejs_24, +}: +let + project = (pkgs.callPackage (src + "/yarn-project.nix") { inherit nodejs; }) { + inherit src; + overrideAttrs = _: { + buildPhase = "runHook preBuild; runHook postBuild"; + installPhase = '' + runHook preInstall + test -d node_modules + mkdir -p "$out" + cp -a node_modules/. "$out/" + runHook postInstall + ''; + }; + }; +in +project +// { + devShell = pkgs.mkShell { + packages = [ + nodejs + project.yarn-freestanding + ]; + }; +} diff --git a/yarn-shell.nix b/yarn-shell.nix new file mode 100644 index 0000000..7f99c11 --- /dev/null +++ b/yarn-shell.nix @@ -0,0 +1,22 @@ +{ pkgs }: +let + yarnCli = pkgs.fetchurl { + url = "https://repo.yarnpkg.com/4.18.0/packages/yarnpkg-cli/bin/yarn.js"; + hash = "sha512-/Lhxb+fNDuzhQf/Bi5IZOp35IEwbqDGJwoiDUiP8C75kr0c7qw1emSen2utcryuwfrJ4fMkzjKBA6hJfKh8vfg=="; + }; + yarn = pkgs.writeShellScriptBin "yarn" '' + exec ${pkgs.nodejs_24}/bin/node ${yarnCli} "$@" + ''; +in +pkgs.mkShell { + packages = [ + pkgs.nodejs_24 + yarn + ]; + shellHook = '' + if [ ! -f .yarn/plugins/yarn-plugin-nixify.cjs ]; then + mkdir -p .yarn/plugins + cp ${./.yarn/plugins/yarn-plugin-nixify.cjs} .yarn/plugins/yarn-plugin-nixify.cjs + fi + ''; +}