diff --git a/.github/justfile b/.github/justfile index 4437b33..121c56d 100644 --- a/.github/justfile +++ b/.github/justfile @@ -56,6 +56,7 @@ core: @just gha::_step test-ct-runner @just gha::_step test-bundle @just gha::_step examples + @just gha::_step test-translate @just gha::_step conformance @just gha::_step sched-seeds @just gha::_step test-ports diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 6a8ec71..f5b610b 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -8,7 +8,9 @@ host-resource methods/statics (class-prototype authority), adds the stage-3 decorator form, and makes interface members receive their containing object as `this`; amendment A3 (2026-08-11) lets `instantiate` accept untranslated artifacts (`{ componentBytes, translator }`) and run -the translation internally.** This document supersedes `descriptor-ir.md`'s interim +the translation internally; amendment A4 (2026-08-11) blesses the +translation envelope as the build-time artifact +(`artifactsFromEnvelope`).** This document supersedes `descriptor-ir.md`'s interim "host value mapping" table as the destination for host-facing value shapes. The runtime's *raw* boundary (`instance.exports`, `HostImports`) keeps the `definitions.py` interpreter shapes as an **internal** surface; the @@ -377,6 +379,14 @@ const instance = await instantiate(artifacts, { worth sharing; warm translation is sub-millisecond). `requiredImports` still takes a plan: translate explicitly to inspect the import surface before instantiating. +- **Build-time translation** (A4): the translation ENVELOPE (the + single-file JSON from `Translator.translateRaw` / the `tools/translate` + CLI, carrying plan + FACT adapters) is the blessed deploy artifact — + production ships `component.wasm` + envelope + runtime, no translator. + `artifactsFromEnvelope(envelopeJson, componentBytes)` reconstitutes + `ComponentArtifacts`; the envelope's embedded component sha-256 is + verified at instantiation, so a mismatched deploy pair fails loudly. + Fetch-agnostic by design: the embedder acquires the two blobs. - **Per-interface module authoring** (the consumers' file layout) is a helper over the same record: a module's named export, camelCase of the interface short-name, provides that interface diff --git a/justfile b/justfile index eb5ef59..1621a59 100644 --- a/justfile +++ b/justfile @@ -16,7 +16,7 @@ ci: (gha::core) (gha::browser) # Includes the consumer smokes and exams CI cannot run (they need the # polymorph checkouts and iroh-relay; docs/consumers.md). # The full pre-commit pass (AGENTS.md "Gates"): everything. -gates: build test-rust test-runtime test-wasi-shims test-ct-runner test-bundle examples conformance sched-seeds test-ports test-webrtc shells browsers websocket-conformance smoke-tls smoke-c0 iroh-exam +gates: build test-rust test-runtime test-wasi-shims test-ct-runner test-bundle examples test-translate conformance sched-seeds test-ports test-webrtc shells browsers websocket-conformance smoke-tls smoke-c0 iroh-exam # Fast sanity: builds + native tests + type-checks, no suites. check: build test-rust @@ -46,6 +46,12 @@ examples: shim ./examples/hello-world/run.sh ./examples/kitchen-sink/run.sh +# Build-time translation CLI (tools/translate, embedder-api A4): translate +# to an envelope, reconstitute artifacts without a translator, verify the +# mismatched-pair refusal. +test-translate: shim + deno test --allow-read --allow-write=/tmp --allow-run tools/translate/translate_test.ts + # Rehearsal finding: 20 runtime e2e tests self-skip when it is absent — # generation must precede the runtime suite (318/0/3 with; 298/0/23 without). # The conformance corpus (harness/generated/). diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index c54a208..38add8e 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -12,7 +12,7 @@ import type { WirePlan, WireExport } from "../plan/format.ts"; import type { LoadedPlan } from "../plan/loader.ts"; -import { loadPlan, PlanError } from "../plan/loader.ts"; +import { loadEnvelope, loadPlan, PlanError } from "../plan/loader.ts"; import type { FuncType, ResourceTypeInfo, ValType } from "../cabi/types.ts"; import type { ComponentValue } from "../cabi/types.ts"; import { Trap } from "../cabi/trap.ts"; @@ -89,6 +89,26 @@ export interface UntranslatedArtifacts { /** Either artifacts shape accepted by `instantiate`. */ export type InstantiateSource = ComponentArtifacts | UntranslatedArtifacts; +/** + * Reconstitute `ComponentArtifacts` from a translation ENVELOPE — the + * single-file JSON emitted by build-time translation (`tools/translate`, + * or `Translator.translateRaw`), carrying the plan and the FACT adapter + * modules. The production deploy set is `component.wasm` + its envelope + + * the runtime: no translator ships (embedder-api.md amendment A4). + * + * Pure and fetch-agnostic: acquire the two blobs however the platform + * likes (HTTP, fs, bundler asset) and hand them over. The envelope embeds + * the component's sha-256, which `instantiate` verifies — a mismatched + * pair fails loudly at instantiation, never subtly at runtime. + */ +export function artifactsFromEnvelope( + envelopeJson: string, + componentBytes: Uint8Array, +): ComponentArtifacts { + const { wire, adapters } = loadEnvelope(envelopeJson); + return { plan: wire, componentBytes, adapters }; +} + async function resolveArtifacts( src: InstantiateSource, ): Promise { diff --git a/runtime/src/embedder/mod.ts b/runtime/src/embedder/mod.ts index 3f33de4..db9a64c 100644 --- a/runtime/src/embedder/mod.ts +++ b/runtime/src/embedder/mod.ts @@ -7,6 +7,7 @@ // types that cast this facade; no generated code participates. export { + artifactsFromEnvelope, type ComponentArtifacts, type EmbedderInstance, type EmbedderOptions, diff --git a/tools/translate/README.md b/tools/translate/README.md new file mode 100644 index 0000000..f2a1f06 --- /dev/null +++ b/tools/translate/README.md @@ -0,0 +1,50 @@ +# tools/translate — build-time translation + +Translate a component **once, at build/deploy time**, so production never +ships the translator (~0.5 MB gzip of wasm). The deploy set becomes: + +``` +component.wasm # unchanged +component.plan.json # the translation envelope: plan + FACT adapters +your host + @deltic/runtime +``` + +## Translate + +```sh +deno run --allow-read --allow-write tools/translate/main.ts \ + app.component.wasm # writes app.component.plan.json +``` + +(`-o out.plan.json` to choose the destination, `--shim path` to point at a +translator build other than the repo's.) + +## Deploy host + +```ts +import { artifactsFromEnvelope, instantiate } from "@deltic/runtime/embedder"; + +const [envelope, componentBytes] = await Promise.all([ + fetch("/app.component.plan.json").then((r) => r.text()), + fetch("/app.component.wasm").then((r) => r.arrayBuffer()), +]); +const component = await instantiate( + artifactsFromEnvelope(envelope, new Uint8Array(componentBytes)), + imports, +); +``` + +Acquisition is deliberately yours (HTTP above; `Deno.readFile`/`node:fs` +work the same) — `artifactsFromEnvelope` is pure. The envelope embeds the +component's sha-256 and length, which `instantiate` verifies: a mismatched +deploy pair (stale envelope, wrong component) **fails loudly at +instantiation**, pinned by `translate_test.ts`. + +## When to prefer runtime translation instead + +Components that arrive dynamically (plugin systems) can't pre-translate: +use `instantiate({ componentBytes, translator }, …)` (embedder-api A3) +with the translator asset, and let the runtime's artifact cache +(`@deltic/runtime/cache`) amortize repeat visits. The full delivery +decision tree is in the design note on +[#16](https://github.com/lann/deltic/issues/16). diff --git a/tools/translate/main.ts b/tools/translate/main.ts new file mode 100644 index 0000000..5aefa7b --- /dev/null +++ b/tools/translate/main.ts @@ -0,0 +1,69 @@ +// Build-time translation CLI (issue #16, delivery design note item 2). +// +// Translates a component ONCE, at build/deploy time, so production never +// ships the ~0.5 MB (gzip) translator wasm — the deploy set becomes: +// +// component.wasm (unchanged) +// component.plan.json (this tool's output: the translation envelope +// — plan + FACT adapter modules, base64) +// your host code + @deltic/runtime +// +// and the host reconstitutes artifacts with `artifactsFromEnvelope` (see +// README.md). The envelope embeds the component's sha-256; `instantiate` +// verifies the pair, so a mismatched deploy fails loudly, not subtly. +// +// Usage: +// deno run --allow-read --allow-write tools/translate/main.ts \ +// [-o ] [--shim ] +// +// Defaults: -o .plan.json next to the input; --shim resolves to +// the repo's built translator (consumers of the published package will get +// a default translator from @deltic/translator once #16 packaging lands). + +import { Translator } from "@deltic/runtime/shim"; + +function usage(): never { + console.error( + "usage: translate [-o ] [--shim ]", + ); + Deno.exit(2); +} + +let input: string | undefined; +let output: string | undefined; +let shimPath = new URL( + "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", + import.meta.url, +).pathname; + +const args = [...Deno.args]; +while (args.length) { + const a = args.shift()!; + if (a === "-o") output = args.shift() ?? usage(); + else if (a === "--shim") shimPath = args.shift() ?? usage(); + else if (a.startsWith("-")) usage(); + else if (input === undefined) input = a; + else usage(); +} +if (input === undefined) usage(); +output ??= input.replace(/\.wasm$/, "") + ".plan.json"; + +const componentBytes = await Deno.readFile(input); +const translator = await Translator.create(await Deno.readFile(shimPath)); + +const t0 = performance.now(); +// `translateRaw` IS the artifact: the envelope JSON carries the plan and +// the FACT adapters (base64) in one deterministic file — the same format +// the runtime's artifact cache stores (runtime/src/cache). +const envelope = translator.translateRaw(componentBytes); +const ms = (performance.now() - t0).toFixed(1); + +// Fail here, not at deploy time, if the translator rejected the component. +const { loadEnvelope } = await import("@deltic/runtime/plan"); +const { wire, adapters } = loadEnvelope(envelope); + +await Deno.writeTextFile(output, envelope); +console.log( + `${output}: ${envelope.length} bytes (${adapters.size} adapters, ` + + `${wire.imports.length} imports) in ${ms}ms`, +); diff --git a/tools/translate/translate_test.ts b/tools/translate/translate_test.ts new file mode 100644 index 0000000..6508b93 --- /dev/null +++ b/tools/translate/translate_test.ts @@ -0,0 +1,113 @@ +// End-to-end pin for the build-time translation path (embedder-api.md A4): +// the CLI translates a fixture component to an envelope file, and a "deploy +// host" that never sees the translator reconstitutes artifacts from the +// envelope and runs the component. Also pins the loud-failure pairing check +// (envelope of component A + bytes of component B must refuse). + +import { assertEq } from "../../runtime/tests/support/asserts.ts"; +import { + artifactsFromEnvelope, + instantiate, +} from "../../runtime/src/embedder/mod.ts"; + +const here = new URL(".", import.meta.url); +const repo = new URL("../../", import.meta.url); + +async function exists(url: URL): Promise { + try { + await Deno.stat(url); + return true; + } catch { + return false; + } +} + +const shim = new URL( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", + repo, +); +const imports = new URL("crates/translator-shim/testdata/imports.wasm", repo); +const hello = new URL( + "crates/translator-shim/testdata/trivial.wasm", + repo, +); +const ready = (await exists(shim)) && (await exists(imports)); + +async function runCli( + args: string[], +): Promise<{ code: number; stdout: string; stderr: string }> { + const cmd = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-read", + "--allow-write=/tmp", + new URL("main.ts", here).pathname, + ...args, + ], + stdout: "piped", + stderr: "piped", + }); + const out = await cmd.output(); + const dec = new TextDecoder(); + return { + code: out.code, + stdout: dec.decode(out.stdout), + stderr: dec.decode(out.stderr), + }; +} + +Deno.test({ + name: "translate CLI: envelope out, deploy host instantiates without a translator", + ignore: !ready, + fn: async () => { + const out = `/tmp/deltic-translate-test-${crypto.randomUUID()}.plan.json`; + try { + const res = await runCli([imports.pathname, "-o", out]); + assertEq(res.code, 0, `cli failed: ${res.stderr}`); + + // The deploy host: envelope + component bytes only. + const envelope = await Deno.readTextFile(out); + const componentBytes = await Deno.readFile(imports); + const logged: number[] = []; + const c = await instantiate( + artifactsFromEnvelope(envelope, componentBytes), + { + log: (x: number) => void logged.push(x), + "host:api/math": { + add: (a: number, b: number) => a + b, + greet: (who: string) => `hello ${who}`, + }, + }, + ); + assertEq(await c.exports.run(2, 40), 42); + assertEq(logged, [42]); + } finally { + await Deno.remove(out).catch(() => {}); + } + }, +}); + +Deno.test({ + name: "translate CLI: a mismatched envelope/component pair refuses at instantiation", + ignore: !ready || !(await exists(hello)), + fn: async () => { + const out = `/tmp/deltic-translate-test-${crypto.randomUUID()}.plan.json`; + try { + const res = await runCli([imports.pathname, "-o", out]); + assertEq(res.code, 0, `cli failed: ${res.stderr}`); + const envelope = await Deno.readTextFile(out); + // Wrong component bytes for this envelope: the embedded sha-256 (and + // length) must refuse the pair loudly. + const wrong = await Deno.readFile(hello); + let raised: unknown; + try { + await instantiate(artifactsFromEnvelope(envelope, wrong), {}); + } catch (e) { + raised = e; + } + assertEq(raised !== undefined, true, "expected a pairing failure"); + } finally { + await Deno.remove(out).catch(() => {}); + } + }, +});