From 8786f7bbebe6129ef5aeac4b3f989e08f95b5d4e Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 10 Aug 2026 18:30:01 -0400 Subject: [PATCH] @deltic/translator: packaged asset + defaultTranslator() per-platform loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item 3 of the #16 delivery design note (lands after fromExports, which the Deno path consumes). A separate package, deliberately: the translator is a versioned peer of the runtime, but build-time-translating consumers (tools/translate, A4) deploy no translator at all — keeping the 1.85 MB asset out of @deltic/runtime keeps their production graphs clean. defaultTranslator(): lazy, realm-cached. Platform arms: - Deno: the wasm rides the STATIC module graph — mod.ts reaches the Deno-only asset module through a string-literal dynamic import, which Deno statically analyzes (permission-free) while keeping evaluation lazy and non-Deno platforms away from the wasm import. Empirical correction folded in: a COMPUTED import(url) is permission-gated in Deno — only the literal form rides the graph. Wrapped via Translator.fromExports (no compile, no copy). - Node: node:fs readFile of the packaged asset (wasm-module imports are still experimental there). - Browser/workers: fetch(new URL(..., import.meta.url)) — the bundler-standard asset pattern. The asset is copied from the cargo build by `just shim` (gitignored); publish tooling pins the exact asset + digest when #16 packaging lands. A missing asset fails at defaultTranslator() call time with a run-`just shim` hint. Examples dogfood it: both hosts drop the shim readFile and the ../../target permission — `deno run --allow-read=build host.ts` is the full command line, with the component read as the only permission left. Tests (translator/tests, wired into the test-translate recipe): defaultTranslator translates envelope-identically to a bytes-built Translator; singleton identity per realm. Gates: test-translate (incl. the new package checks), examples, test-runtime, conformance (1254/0, 0 unexpected, 0 stale), sched-seeds. --- .gitignore | 4 ++ README.md | 1 + deno.json | 3 +- examples/hello-world/host.ts | 26 +++++------- examples/hello-world/run.sh | 2 +- examples/kitchen-sink/host.ts | 17 ++++---- examples/kitchen-sink/run.sh | 2 +- justfile | 2 + translator/deno.json | 9 +++++ translator/mod.ts | 74 +++++++++++++++++++++++++++++++++++ translator/shim_asset_deno.ts | 12 ++++++ translator/tests/mod_test.ts | 52 ++++++++++++++++++++++++ 12 files changed, 174 insertions(+), 30 deletions(-) create mode 100644 translator/deno.json create mode 100644 translator/mod.ts create mode 100644 translator/shim_asset_deno.ts create mode 100644 translator/tests/mod_test.ts diff --git a/.gitignore b/.gitignore index 5b78c22..16d4e27 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /target/ +# @deltic/translator packaged asset (copied by `just shim`) +translator/translator_shim.wasm harness/generated/ examples/guests/build/ node_modules/ @@ -17,3 +19,5 @@ bench/boundary/deltic-embedder.local.mjs bench/boundary/generated/ bench/boundary/node_modules/ bench/boundary/guest/target/ +# @deltic/translator packaged asset (copied by `just shim`) +translator/translator_shim.wasm diff --git a/README.md b/README.md index a330ee7..48ba263 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Pre-1.0, but densely gated: | `runtime/` | TS core: plan executor, canonical ABI, 0.3 task scheduler, JSPI bridge, embedder API (`runtime/src/embedder`) | | `crates/bindgen` | WIT → TypeScript types for the embedder conventions | | `examples/` | **start here to embed**: hello-world + kitchen-sink (WIT + Rust guest + TS host, self-checking), plus the guest fixture corpus | +| `translator/` | `@deltic/translator`: the packaged translator asset + `defaultTranslator()` per-platform loader (build-time alternative: `tools/translate`) | | `wasi-shims/` | minimal WASI providers (p2 baseline + p3 clocks), one per semver track | | `ct-runner/` | conformance-suite runner for the polymorph-test L1 contract | | `harness/` + `tools/browser` | official-suite harness; Deno lane + Chromium/Firefox/WebKit lanes | diff --git a/deno.json b/deno.json index 53f1b08..71b4e61 100644 --- a/deno.json +++ b/deno.json @@ -4,6 +4,7 @@ "./harness", "./wasi-shims", "./ct-runner", - "./examples" + "./examples", + "./translator" ] } diff --git a/examples/hello-world/host.ts b/examples/hello-world/host.ts index c9eb5e5..51fe3f6 100644 --- a/examples/hello-world/host.ts +++ b/examples/hello-world/host.ts @@ -2,28 +2,20 @@ // call: give `instantiate` the component bytes and the translator, get // typed-shaped exports back. // -// The two wasm files are read with `Deno.readFile`, so this script runs -// with a scoped read permission (run.sh passes it): +// The translator comes from @deltic/translator — on Deno it arrives via a +// native wasm-module import (permission-free); the only permission this +// script needs is reading the component it runs: // -// deno run --allow-read=..,../../target host.ts +// deno run --allow-read=build host.ts // -// (Deno's `import ... with { type: "bytes" }` will make this flag-free -// once it stabilizes — it is behind --unstable-raw-imports as of Deno -// 2.9, and `type: "text"` is not an option for binaries: lossy UTF-8 -// decoding corrupts them.) -// -// Inside this repository `@deltic/runtime` resolves through the Deno -// workspace; a published consumer uses the same specifier via JSR/npm or -// the `deltic-embedder.mjs` release bundle (deltic#16 tracks packaging). +// Inside this repository `@deltic/runtime` and `@deltic/translator` +// resolve through the Deno workspace; a published consumer uses the same +// specifiers via JSR/npm (deltic#16 tracks packaging). import { instantiate } from "@deltic/runtime/embedder"; +import { defaultTranslator } from "@deltic/translator"; -const translator = await Deno.readFile( - new URL( - "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", - import.meta.url, - ), -); +const translator = await defaultTranslator(); const componentBytes = await Deno.readFile( new URL("build/hello.component.wasm", import.meta.url), ); diff --git a/examples/hello-world/run.sh b/examples/hello-world/run.sh index 409b871..f5793ce 100755 --- a/examples/hello-world/run.sh +++ b/examples/hello-world/run.sh @@ -18,4 +18,4 @@ wasm-tools component new \ wasm-tools validate --features component-model build/hello.component.wasm deno check host.ts -deno run --allow-read=..,../../target host.ts +deno run --allow-read=build host.ts diff --git a/examples/kitchen-sink/host.ts b/examples/kitchen-sink/host.ts index 302e45f..af28663 100644 --- a/examples/kitchen-sink/host.ts +++ b/examples/kitchen-sink/host.ts @@ -22,6 +22,7 @@ import { suspending, WitError, } from "@deltic/runtime/embedder"; +import { defaultTranslator } from "@deltic/translator"; // Tiny self-checks so the example fails loudly if the API drifts. // (`undefined` is meaningful in the conventions — the outermost-option @@ -117,20 +118,16 @@ const imports = { // --- §1: translate + instantiate -------------------------------------------- -const translator = await Deno.readFile( - new URL( - "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", - import.meta.url, - ), -); +const translator = await defaultTranslator(); const componentBytes = await Deno.readFile( new URL("build/kitchen-sink.component.wasm", import.meta.url), ); -// `{ componentBytes, translator }` translates internally (A3). When -// instantiating several components, create one `Translator` explicitly -// (`Translator.create(bytes)` from @deltic/runtime/shim) and pass it here -// instead — the wasm compile is the cost worth sharing. +// `{ componentBytes, translator }` translates internally (A3); +// `defaultTranslator()` is @deltic/translator's packaged, per-realm-cached +// loader (on Deno: a native wasm-module import — no permissions). Apps +// that know their components at build time can skip the translator +// entirely: see tools/translate (embedder-api A4). // // A marked import is auto-detection evidence: this instantiation selects // JSPI mode by itself. (`jspi: false` would force plain mode, where a diff --git a/examples/kitchen-sink/run.sh b/examples/kitchen-sink/run.sh index a9fdfbd..e1db5d9 100755 --- a/examples/kitchen-sink/run.sh +++ b/examples/kitchen-sink/run.sh @@ -18,4 +18,4 @@ wasm-tools component new \ wasm-tools validate --features component-model,cm-async build/kitchen-sink.component.wasm deno check host.ts -deno run --allow-read=..,../../target host.ts +deno run --allow-read=build host.ts diff --git a/justfile b/justfile index c694e8b..f36fc12 100644 --- a/justfile +++ b/justfile @@ -43,6 +43,7 @@ shim: CARGO_PROFILE_RELEASE_PANIC=abort \ CARGO_PROFILE_RELEASE_STRIP=symbols \ cargo build -p translator-shim --target wasm32-unknown-unknown --release + cp target/wasm32-unknown-unknown/release/translator_shim.wasm translator/translator_shim.wasm # wasmtime CLI is optional in build.sh (smoke run only when present). # Guest fixture components (examples/guests/build/, gitignored): the @@ -62,6 +63,7 @@ examples: shim # mismatched-pair refusal. test-translate: shim deno test --allow-read --allow-write=/tmp --allow-run tools/translate/translate_test.ts + cd translator && deno task check && deno task test # 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). diff --git a/translator/deno.json b/translator/deno.json new file mode 100644 index 0000000..f2609ab --- /dev/null +++ b/translator/deno.json @@ -0,0 +1,9 @@ +{ + "name": "@deltic/translator", + "version": "0.1.0", + "exports": { ".": "./mod.ts" }, + "tasks": { + "test": "deno test --allow-read tests/", + "check": "deno check mod.ts tests/" + } +} diff --git a/translator/mod.ts b/translator/mod.ts new file mode 100644 index 0000000..61137bd --- /dev/null +++ b/translator/mod.ts @@ -0,0 +1,74 @@ +// @deltic/translator — the packaged translator wasm plus its per-platform +// loader (issue #16 delivery design note, item 3). +// +// Why a separate package: the translator is a versioned peer of the +// runtime (plan-format coupling), so it ships inside the same release — +// but embedders that translate at BUILD time (tools/translate, +// embedder-api A4) deploy no translator at all, and keeping the ~1.85 MB +// asset out of @deltic/runtime keeps their production graphs clean. +// +// The asset (`translator_shim.wasm`, sibling to this module) is copied +// from the cargo build by `just shim` and is gitignored — run `just shim` +// once in a fresh checkout. Publish tooling will pin the exact asset (and +// its digest) into the released package when #16's packaging lands. + +import { Translator } from "@deltic/runtime/shim"; + +let singleton: Promise | undefined; + +/** + * The packaged translator, loaded lazily and cached for the realm. + * + * Platform paths, in order of preference: + * + * * **Deno** — native wasm-module import: stable, permission-free, and + * delivery/caching ride the module cache. The shim imports nothing, + * so the ESM integration instantiates it trivially and + * `Translator.fromExports` wraps the namespace with no compile and no + * copy. (`buildHash` is unrecoverable from an instance, so the + * artifact cache keys without translator identity on this path — + * see Translator.buildHash.) + * * **Node** — `node:fs` read of the packaged asset (Node's wasm-module + * imports are still experimental; don't build on them). + * * **Browser / workers** — `fetch` of the packaged asset URL (bundlers + * understand the `new URL(…, import.meta.url)` pattern and carry the + * asset). + * + * Pass the result to `instantiate({ componentBytes, translator })` + * (embedder-api A3), or call `.translate()` directly. + */ +export function defaultTranslator(): Promise { + return singleton ??= load(); +} + +async function load(): Promise { + const url = new URL("./translator_shim.wasm", import.meta.url); + try { + if (typeof Deno !== "undefined") { + // String-literal dynamic import: statically analyzable, so the wasm + // rides the module graph permission-free; still lazy, and non-Deno + // platforms never evaluate the Deno-only module (see its header). + const { ns } = await import("./shim_asset_deno.ts"); + return Translator.fromExports(ns); + } + const proc = (globalThis as { process?: { versions?: { node?: string } } }) + .process; + if (proc?.versions?.node) { + const { readFile } = await import("node:fs/promises"); + return await Translator.create(new Uint8Array(await readFile(url))); + } + const res = await fetch(url); + if (!res.ok) { + throw new Error(`fetching the translator asset failed: ${res.status}`); + } + return await Translator.create(new Uint8Array(await res.arrayBuffer())); + } catch (e) { + // The overwhelmingly likely in-repo cause is the missing gitignored + // asset; say so instead of leaking a bare module-resolution error. + throw new Error( + `@deltic/translator: could not load ${url}: ${e}\n` + + `(in a repo checkout, run \`just shim\` to build and place the asset)`, + { cause: e }, + ); + } +} diff --git a/translator/shim_asset_deno.ts b/translator/shim_asset_deno.ts new file mode 100644 index 0000000..552927b --- /dev/null +++ b/translator/shim_asset_deno.ts @@ -0,0 +1,12 @@ +// The Deno arm of @deltic/translator, reached from mod.ts via a +// STRING-LITERAL dynamic import: the literal specifier puts this module — +// and the wasm it statically imports — into the statically-analyzable +// module graph, so no read permission is needed (unlike a computed +// `import(url)`, which Deno gates); the dynamic edge keeps it lazy, and +// non-Deno platforms never evaluate it. The static wasm import instantiates +// the zero-import shim under Deno's ESM integration when this module first +// evaluates. + +import * as ns from "./translator_shim.wasm"; + +export { ns }; diff --git a/translator/tests/mod_test.ts b/translator/tests/mod_test.ts new file mode 100644 index 0000000..bcdf4ff --- /dev/null +++ b/translator/tests/mod_test.ts @@ -0,0 +1,52 @@ +// defaultTranslator: the packaged loader works (Deno path = native wasm +// import via Translator.fromExports), caches per realm, and translates +// identically to a bytes-built Translator. + +import { defaultTranslator } from "../mod.ts"; +import { Translator } from "@deltic/runtime/shim"; + +function assertEq(got: unknown, want: unknown, what: string) { + if (got !== want) throw new Error(`${what}: expected ${want}, got ${got}`); +} + +async function maybeRead(url: URL): Promise { + try { + return await Deno.readFile(url); + } catch { + return null; + } +} + +const trivial = await maybeRead( + new URL("../../crates/translator-shim/testdata/trivial.wasm", import.meta.url), +); +const asset = await maybeRead( + new URL("../translator_shim.wasm", import.meta.url), +); +const ready = trivial !== null && asset !== null; + +Deno.test({ + name: "defaultTranslator: loads, translates, and matches a bytes-built Translator", + ignore: !ready, + fn: async () => { + const t = await defaultTranslator(); + const reference = await Translator.create(asset!); + assertEq( + t.translateRaw(trivial!), + reference.translateRaw(trivial!), + "envelope equality", + ); + }, +}); + +Deno.test({ + name: "defaultTranslator: one instance per realm", + ignore: !ready, + fn: async () => { + assertEq( + await defaultTranslator() === await defaultTranslator(), + true, + "singleton identity", + ); + }, +});