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