Build composable Camino packages with explicit type and source dependencies
This commit is contained in:
@@ -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 =
|
||||
|
||||
@@ -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";
|
||||
/** 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,
|
||||
|
||||
+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");
|
||||
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 = <Component subject={subject}/>;\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",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
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
|
||||
|
||||
@@ -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