Build checked component artifacts and shared React session SDK (WI-09)
This commit is contained in:
+20
-6
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
''
|
||||
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -846,7 +846,7 @@ let
|
||||
);
|
||||
in
|
||||
{
|
||||
mkCaminoPortableArtifacts = import ./portable-artifacts.nix;
|
||||
mkCaminoArtifacts = import ./artifacts.nix;
|
||||
inherit
|
||||
mkQuixosPackageFlake
|
||||
mkTsPackageServer
|
||||
|
||||
Reference in New Issue
Block a user