Build composable Camino packages with explicit type and source dependencies
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
service ? { },
|
service ? { },
|
||||||
nodeModules ? null,
|
nodeModules ? null,
|
||||||
components ? null,
|
components ? null,
|
||||||
|
group ? false,
|
||||||
}:
|
}:
|
||||||
let
|
let
|
||||||
compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
|
compiler = "${protocol}/libexec/quixos-protocol/execution-world.mjs";
|
||||||
@@ -26,6 +27,7 @@ let
|
|||||||
sharedRuntime
|
sharedRuntime
|
||||||
nodeModules
|
nodeModules
|
||||||
service
|
service
|
||||||
|
group
|
||||||
;
|
;
|
||||||
components =
|
components =
|
||||||
if components == null then
|
if components == null then
|
||||||
@@ -35,6 +37,7 @@ let
|
|||||||
inherit (components) sdk runtime entries;
|
inherit (components) sdk runtime entries;
|
||||||
dependencies = components.sdk.dependencies;
|
dependencies = components.sdk.dependencies;
|
||||||
bindingOutput = components.bindingOutput or "src/gen/client.ts";
|
bindingOutput = components.bindingOutput or "src/gen/client.ts";
|
||||||
|
contracts = components.contracts or [ ];
|
||||||
};
|
};
|
||||||
source = src;
|
source = src;
|
||||||
portable =
|
portable =
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
);
|
||||||
@@ -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}
|
||||||
|
''
|
||||||
+8
-73
@@ -1,3 +1,4 @@
|
|||||||
|
import { buildService } from "./build-service.mjs";
|
||||||
import { buildComponents } from "./build-components.mjs";
|
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";
|
||||||
@@ -161,79 +162,13 @@ for (const { target, mode, settings } of builds) {
|
|||||||
};
|
};
|
||||||
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
|
implementations.push({ ...body, contentDigest: compiler.implementationDigest(body) });
|
||||||
}
|
}
|
||||||
const components = await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated });
|
const components =
|
||||||
if (pkg.exports.length || config.service.entry) {
|
config.group && !config.components
|
||||||
await fs.mkdir(path.join(output, "bin"), { recursive: true });
|
? []
|
||||||
await fs.mkdir(path.join(output, "share/quixos/service"), { recursive: true });
|
: await buildComponents({ config, compiler, plan, pkg, output, recordFile, generated });
|
||||||
let serviceEntry;
|
if (!config.group || config.service.entry)
|
||||||
if (config.service.entry) {
|
await buildService({ config, compiler, pkg, output, coreServer, sharedFiles, implementations, recordFile });
|
||||||
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 manifest = compiler.parsePackageArtifacts({
|
const manifest = compiler.parsePackageArtifacts({
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
packageRevisionId: pkg.revision,
|
packageRevisionId: pkg.revision,
|
||||||
|
|||||||
+56
-4
@@ -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");
|
if (pkg.components?.length) throw Error("Declared components require checked component build settings");
|
||||||
return [];
|
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);
|
const entries = Object.entries(settings.entries);
|
||||||
if (
|
if (
|
||||||
entries.length !== declarations.length ||
|
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);
|
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-client", { file: path.join(work, bindingPath), source: bindings });
|
||||||
generated.set("component-assets", { file: path.join(work, assetPath), source: assets });
|
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 = [];
|
const components = [];
|
||||||
for (const [id, entry] of entries) {
|
for (const [id, entry] of entries) {
|
||||||
const declaration = declarations.find((component) => component.id === id),
|
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");
|
if (!/^[$A-Z_a-z][$\w]*$/.test(entry.export)) throw Error("Invalid component export name");
|
||||||
const key = digest(Buffer.from(id)).slice(0, 24),
|
const key = digest(Buffer.from(id)).slice(0, 24),
|
||||||
directory = "share/quixos/components/" + key;
|
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(
|
await fs.writeFile(
|
||||||
witness,
|
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 = <Component subject={subject}/>;\n`
|
||||||
|
: ""),
|
||||||
);
|
);
|
||||||
execFileSync(
|
execFileSync(
|
||||||
config.node,
|
config.node,
|
||||||
@@ -118,15 +164,21 @@ export async function buildComponents({ config, compiler, plan, pkg, output, rec
|
|||||||
"react-jsx",
|
"react-jsx",
|
||||||
path.basename(witness),
|
path.basename(witness),
|
||||||
assetPath,
|
assetPath,
|
||||||
|
propsPath,
|
||||||
],
|
],
|
||||||
{ cwd: work, stdio: "inherit" },
|
{ cwd: work, stdio: "inherit" },
|
||||||
);
|
);
|
||||||
const metadata = path.join(work, "component-meta-" + key + ".json");
|
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 });
|
await fs.mkdir(path.join(output, directory), { recursive: true });
|
||||||
execFileSync(
|
execFileSync(
|
||||||
config.esbuild,
|
config.esbuild,
|
||||||
[
|
[
|
||||||
source,
|
facade,
|
||||||
"--bundle",
|
"--bundle",
|
||||||
"--platform=browser",
|
"--platform=browser",
|
||||||
"--format=esm",
|
"--format=esm",
|
||||||
|
|||||||
@@ -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 extends true> = T;
|
||||||
|
type ObjectProps = Assert<Props extends object ? true : false>;
|
||||||
|
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),
|
||||||
|
);
|
||||||
@@ -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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -846,7 +846,11 @@ let
|
|||||||
);
|
);
|
||||||
in
|
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
|
inherit
|
||||||
mkQuixosPackageFlake
|
mkQuixosPackageFlake
|
||||||
mkTsPackageServer
|
mkTsPackageServer
|
||||||
|
|||||||
@@ -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}
|
||||||
|
''
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
Reference in New Issue
Block a user