diff --git a/build-portable-artifacts.mjs b/build-portable-artifacts.mjs index 4c58c19..80a5c2c 100644 --- a/build-portable-artifacts.mjs +++ b/build-portable-artifacts.mjs @@ -14,9 +14,10 @@ const output = process.env.out; if (!output) throw Error("Missing Nix output"); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); const generatorDigest = digest(await fs.readFile(config.compiler)); -const runtime = config.portable - ? JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8")) - : { files: [] }; +const runtime = + config.portable || config.remote + ? JSON.parse(await fs.readFile(path.join(config.sharedRuntime, "runtime.json"), "utf8")) + : { files: [] }; const relative = (p) => compiler.artifactPath(p); const implementations = []; const generated = new Map(); @@ -42,12 +43,15 @@ const coreURL = coreFile ? compiler.contentArtifactUrl(coreFile) : null; const coreServer = path.join(config.sharedRuntime, "core.mjs"); await fs.mkdir(path.join(output, "share/quixos/generated"), { recursive: true }); const portable = config.portable; -if (portable && !/^[$A-Z_a-z][$\w]*$/.test(portable.registryExport)) - throw Error("Registry export must be a JavaScript identifier"); +for (const settings of [portable, config.remote].filter(Boolean)) + if (!/^[$A-Z_a-z][$\w]*$/.test(settings.registryExport)) + throw Error("Registry export must be a JavaScript identifier"); const targets = portable ? [...new Set([...portable.targets, "server"])] : []; -for (const target of targets) { +const builds = targets.map((target) => ({ target, mode: target, settings: portable })); +if (config.remote) builds.push({ target: "server", mode: "remote", settings: config.remote }); +for (const { target, mode, settings } of builds) { if (!["browser", "server"].includes(target)) throw Error(`Unsupported JS build target ${target}`); - const work = path.resolve(`work-${target}`); + const work = path.resolve(`work-${mode}`); await fs.cp(config.source, work, { recursive: true, dereference: false }); const writable = async (directory) => { await fs.chmod(directory, 0o755); @@ -69,13 +73,16 @@ for (const target of targets) { } await fs.symlink(config.core, path.join(modules, "@quixos/camino-replica-core")); await fs.writeFile(path.join(work, "package.json"), JSON.stringify({ type: "module" })); - const bindingPath = relative(portable.bindingOutput); - const source = compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target }); + const bindingPath = relative(settings.bindingOutput); + const source = + mode === "remote" + ? compiler.generateRemoteTypeScriptBindings(plan.definition.bindingSchema, pkg.revision) + : compiler.generatePortableTypeScriptBindings(plan.definition.bindingSchema, pkg.revision, { target }); await fs.mkdir(path.dirname(path.join(work, bindingPath)), { recursive: true }); await fs.writeFile(path.join(work, bindingPath), source); - generated.set(target, { file: path.join(work, bindingPath), source }); - await fs.writeFile(path.join(output, "share/quixos/generated", `${target}.ts`), source); - const entry = relative(portable.entry); + generated.set(mode, { file: path.join(work, bindingPath), source }); + await fs.writeFile(path.join(output, "share/quixos/generated", `${mode}.ts`), source); + const entry = relative(settings.entry); execFileSync( config.node, [ @@ -96,7 +103,8 @@ for (const target of targets) { ], { cwd: work, stdio: "inherit" }, ); - const bundlePath = `share/quixos/portable/${target}/module.mjs`; + const bundlePath = + mode === "remote" ? "share/quixos/remote/server/module.mjs" : `share/quixos/portable/${target}/module.mjs`; await fs.mkdir(path.dirname(path.join(output, bundlePath)), { recursive: true }); const coreImport = target === "browser" ? coreURL : coreServer; const metadata = path.join(work, "bundle-meta.json"); @@ -117,7 +125,7 @@ for (const target of targets) { ); const meta = JSON.parse(await fs.readFile(metadata, "utf8")); const built = Object.values(meta.outputs).find((o) => o.entryPoint); - if (!built?.exports.includes(portable.registryExport)) throw Error("Bundle lacks the declared registry export"); + if (!built?.exports.includes(settings.registryExport)) throw Error("Bundle lacks the declared registry export"); // Portable modules must contain their ordinary source dependencies and share only the checked SDK. if ( Object.values(meta.outputs).some((o) => @@ -131,15 +139,19 @@ for (const target of targets) { ) throw Error("Unexpected external import in portable module"); const exports = pkg.exports - .filter((e) => e.execution.kind !== "remote" && e.execution.targets.includes(target)) + .filter((e) => + mode === "remote" + ? e.execution.kind === "remote" + : e.execution.kind !== "remote" && e.execution.targets.includes(target), + ) .map((e) => e.id); const body = { - id: `${target}-module`, + id: `${mode}-module`, target, compatibility: "quixos-js-module-v1", path: bundlePath, ports: "quixos-transaction-ports-v1", - registryExport: portable.registryExport, + registryExport: settings.registryExport, exports, files: [await recordFile(bundlePath, "text/javascript"), ...sharedFiles], }; @@ -153,13 +165,32 @@ if (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 { - if (!portable) throw Error("Generated service requires a portable registry"); - if (pkg.exports.some((e) => e.execution.kind === "remote" || !e.execution.targets.includes("server"))) + 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, - `import { ${portable.registryExport} as registry } from ${JSON.stringify(path.join(output, "share/quixos/portable/server/module.mjs"))};\nimport {servePortableRegistry} from ${JSON.stringify(config.serviceAdapter)};\nservePortableRegistry(registry);\n`, + 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"; diff --git a/portable-artifacts.nix b/portable-artifacts.nix index 9d050e4..2fbe763 100644 --- a/portable-artifacts.nix +++ b/portable-artifacts.nix @@ -9,6 +9,7 @@ typescript ? core.dependencies, packageRevisionId, portable ? null, + remote ? null, service ? { }, nodeModules ? null, }: @@ -40,6 +41,15 @@ let "server" ]; }; + remote = + if remote == null then + null + else + { + inherit (remote) entry; + registryExport = remote.registryExport or "registry"; + bindingOutput = remote.bindingOutput or "src/gen/remote.ts"; + }; node = "${pkgs.nodejs_24}/bin/node"; tsc = "${typescript}/node_modules/typescript/bin/tsc"; esbuild = "${pkgs.esbuild}/bin/esbuild";