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,
|
bindings, typechecks the entry's dependency closure, bundles declared targets,
|
||||||
generates a process service when requested, and emits content manifests and
|
generates a process service when requested, and emits content manifests and
|
||||||
receipts. It has no migration step or package-defined shell hooks.
|
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:
|
# Inside a per-system flake output:
|
||||||
quixosPackages.checkedArtifacts =
|
quixosPackages.checkedArtifacts =
|
||||||
{ plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
{ plan, generator, core, sharedRuntime, typescript, packageRevisionId }:
|
||||||
helpers.mkCaminoPortableArtifacts {
|
helpers.mkCaminoArtifacts {
|
||||||
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
inherit pkgs plan core sharedRuntime typescript packageRevisionId;
|
||||||
protocol = generator;
|
protocol = generator;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
@@ -51,6 +51,20 @@ must use the checked loader.
|
|||||||
|
|
||||||
The root `camino-artifact-builds` check exercises actual reader/service outputs,
|
The root `camino-artifact-builds` check exercises actual reader/service outputs,
|
||||||
module and process execution, browser shared imports, rejection recovery, and
|
module and process execution, browser shared imports, rejection recovery, and
|
||||||
altered receipt rejection. No running workspace uses these outputs yet. Installing
|
altered receipt rejection. The managed workspace host verifies these receipts
|
||||||
the new world into the coordinator/replica host is LF-08/LF-11 integration work;
|
before activation. No adapter pretends this ABI works in the old server dispatcher.
|
||||||
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,
|
remote ? null,
|
||||||
service ? { },
|
service ? { },
|
||||||
nodeModules ? null,
|
nodeModules ? null,
|
||||||
|
components ? null,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
|
compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
|
||||||
@@ -26,6 +27,15 @@ let
|
|||||||
nodeModules
|
nodeModules
|
||||||
service
|
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;
|
source = src;
|
||||||
portable =
|
portable =
|
||||||
if portable == null then
|
if portable == null then
|
||||||
@@ -59,6 +69,6 @@ let
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
in
|
in
|
||||||
pkgs.runCommand "camino-checked-artifacts" { } ''
|
pkgs.runCommand "camino-checked-artifacts" { passthru.buildConfig = config; } ''
|
||||||
${pkgs.nodejs_24}/bin/node ${./build-portable-artifacts.mjs} ${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. */
|
/** Trusted Nix build driver. No package-defined build hooks run between generation and witnessing. */
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -160,14 +161,16 @@ for (const { target, mode, settings } of builds) {
|
|||||||
};
|
};
|
||||||
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
|
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
|
||||||
}
|
}
|
||||||
await fs.mkdir(path.join(output, "bin"), { recursive: true });
|
const components = await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated });
|
||||||
await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true });
|
if (pkg.exports.length || config.service.entry) {
|
||||||
let serviceEntry;
|
await fs.mkdir(path.join(output, "bin"), { recursive: true });
|
||||||
if (config.service.entry) {
|
await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true });
|
||||||
|
let serviceEntry;
|
||||||
|
if (config.service.entry) {
|
||||||
if (!/\.m?js$/.test(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");
|
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));
|
serviceEntry = path.join(config.source, relative(config.service.entry));
|
||||||
} else {
|
} else {
|
||||||
const modules = implementations.filter((implementation) => implementation.target === "server");
|
const modules = implementations.filter((implementation) => implementation.target === "server");
|
||||||
if (pkg.exports.some((entry) => !modules.some((module) => module.exports.includes(entry.id))))
|
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");
|
throw Error("Generated service does not cover every package export; provide a custom service");
|
||||||
@@ -195,9 +198,9 @@ servePortableRegistry({exports: [...owners.keys()], invoke(request, channel, con
|
|||||||
return registry.invoke(request, channel, context);
|
return registry.invoke(request, channel, context);
|
||||||
}});\n`,
|
}});\n`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const serviceModule = "share/quixos/service/main.mjs";
|
const serviceModule = "share/quixos/service/main.mjs";
|
||||||
execFileSync(
|
execFileSync(
|
||||||
config.esbuild,
|
config.esbuild,
|
||||||
[
|
[
|
||||||
serviceEntry,
|
serviceEntry,
|
||||||
@@ -210,13 +213,13 @@ execFileSync(
|
|||||||
`--outfile=${path.join(output, serviceModule)}`,
|
`--outfile=${path.join(output, serviceModule)}`,
|
||||||
],
|
],
|
||||||
{ stdio: "inherit" },
|
{ stdio: "inherit" },
|
||||||
);
|
);
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
path.join(output, "bin/service"),
|
path.join(output, "bin/service"),
|
||||||
`#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${serviceModule}" "$@"\n`,
|
`#!${config.shell}\nservice_root=\${0%/*}/..\nexec ${config.node} "$service_root/${serviceModule}" "$@"\n`,
|
||||||
{ mode: 0o755 },
|
{ mode: 0o755 },
|
||||||
);
|
);
|
||||||
const serviceBody = {
|
const serviceBody = {
|
||||||
id: "service",
|
id: "service",
|
||||||
target: "server",
|
target: "server",
|
||||||
compatibility: "quixos-service-v1",
|
compatibility: "quixos-service-v1",
|
||||||
@@ -228,8 +231,9 @@ const serviceBody = {
|
|||||||
await recordFile(serviceModule, "text/javascript"),
|
await recordFile(serviceModule, "text/javascript"),
|
||||||
...sharedFiles,
|
...sharedFiles,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
implementations.unshift({ ...serviceBody, contentDigest: compiler.implementationDigest(serviceBody) });
|
implementations.unshift({ ...serviceBody, contentDigest: compiler.implementationDigest(serviceBody) });
|
||||||
|
}
|
||||||
const manifest = compiler.parsePackageArtifacts({
|
const manifest = compiler.parsePackageArtifacts({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
packageRevisionId: pkg.revision,
|
packageRevisionId: pkg.revision,
|
||||||
@@ -237,6 +241,7 @@ const manifest = compiler.parsePackageArtifacts({
|
|||||||
sourceDigest: pkg.sourceDigest,
|
sourceDigest: pkg.sourceDigest,
|
||||||
versions: plan.definition.versions,
|
versions: plan.definition.versions,
|
||||||
implementations,
|
implementations,
|
||||||
|
...(components.length ? { components } : {}),
|
||||||
});
|
});
|
||||||
for (const { file, source } of generated.values())
|
for (const { file, source } of generated.values())
|
||||||
if ((await fs.readFile(file, "utf8")) !== source) throw Error("Generated bindings changed during build");
|
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
|
in
|
||||||
{
|
{
|
||||||
mkCaminoPortableArtifacts = import ./portable-artifacts.nix;
|
mkCaminoArtifacts = import ./artifacts.nix;
|
||||||
inherit
|
inherit
|
||||||
mkQuixosPackageFlake
|
mkQuixosPackageFlake
|
||||||
mkTsPackageServer
|
mkTsPackageServer
|
||||||
|
|||||||
Reference in New Issue
Block a user