diff --git a/runtime/src/shim/translator.ts b/runtime/src/shim/translator.ts index 9105f7d..6499d1b 100644 --- a/runtime/src/shim/translator.ts +++ b/runtime/src/shim/translator.ts @@ -43,8 +43,8 @@ export class Translator { */ readonly buildHash: string | null; - private constructor(instance: WebAssembly.Instance, buildHash: string | null) { - this.#exports = instance.exports as unknown as ShimExports; + private constructor(exports: object, buildHash: string | null) { + this.#exports = exports as unknown as ShimExports; this.buildHash = buildHash; for (const name of ["memory", "ts_alloc", "ts_dealloc", "ts_translate"]) { if (!(name in this.#exports)) { @@ -69,7 +69,33 @@ export class Translator { ).join(""); } const instance = await WebAssembly.instantiate(module, {}); - return new Translator(instance, buildHash); + return new Translator(instance.exports, buildHash); + } + + /** + * Wrap an ALREADY-INSTANTIATED shim — the ESM wasm-module import path + * (issue #16 delivery design): `import * as shim from ".../translator_shim.wasm"` + * hands back an instantiated namespace (the shim imports nothing, so the + * ESM integration instantiates it trivially), and this wraps it with no + * further compile or copy. + * + * Sharing note: ESM gives ONE instance per realm, so every `fromExports` + * wrapper over the same namespace shares linear memory. That is safe by + * construction — `translate` is synchronous end-to-end (alloc → call → + * copy out → dealloc within one JS frame), so calls can never interleave — + * but treat the wrappers as equivalent, not independent. + * + * `buildHash` (hex sha-256 of the shim wasm bytes) cannot be recovered + * from an instance; pass it when known — a published package can ship the + * hash of the exact asset it carries — or leave it absent and the + * artifact cache politely refuses to key on translator identity + * (cache/core.ts). + */ + static fromExports( + exports: object, + opts: { buildHash?: string } = {}, + ): Translator { + return new Translator(exports, opts.buildHash ?? null); } /** Translate a component binary into plan v0 + adapter artifacts. */ diff --git a/runtime/tests/translator_from_exports_test.ts b/runtime/tests/translator_from_exports_test.ts new file mode 100644 index 0000000..b4d91a1 --- /dev/null +++ b/runtime/tests/translator_from_exports_test.ts @@ -0,0 +1,90 @@ +// Translator.fromExports — the ESM wasm-module import path (issue #16 +// delivery design note): Deno's native wasm imports hand back an +// instantiated namespace; `fromExports` wraps it with no compile and no +// copy. Pinned here: the wrapped namespace translates identically to a +// bytes-constructed Translator (envelope equality), the missing-export +// refusal, and the buildHash contract (null unless supplied). + +import { assertEq } from "./support/asserts.ts"; +import { Translator } from "../src/shim/mod.ts"; + +const shimUrl = new URL( + "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", + import.meta.url, +); +const trivialUrl = new URL( + "../../crates/translator-shim/testdata/trivial.wasm", + import.meta.url, +); + +async function maybeRead(url: URL): Promise { + try { + return await Deno.readFile(url); + } catch { + return null; + } +} + +const shimBytes = await maybeRead(shimUrl); +const trivial = await maybeRead(trivialUrl); +const ready = shimBytes !== null && trivial !== null; + +Deno.test({ + name: "fromExports: a native wasm-module import translates identically to create(bytes)", + ignore: !ready, + fn: async () => { + // Deno's ESM wasm integration: dynamic import instantiates the + // zero-import shim and returns its exports as the module namespace. + const ns = await import(shimUrl.href); + const viaImport = Translator.fromExports(ns); + const viaBytes = await Translator.create(shimBytes!); + // Envelope equality is the strongest cheap equivalence: same plan JSON, + // same adapters, byte for byte. + assertEq( + viaImport.translateRaw(trivial!), + viaBytes.translateRaw(trivial!), + "envelopes must match", + ); + }, +}); + +Deno.test({ + name: "fromExports: repeated translations on the shared instance stay stable", + ignore: !ready, + fn: async () => { + const ns = await import(shimUrl.href); + const t = Translator.fromExports(ns); + const first = t.translateRaw(trivial!); + for (let i = 0; i < 3; i++) { + assertEq(t.translateRaw(trivial!), first, `translation ${i} drifted`); + } + }, +}); + +Deno.test("fromExports: a namespace missing shim exports is refused by name", () => { + let raised: unknown; + try { + Translator.fromExports({ memory: new WebAssembly.Memory({ initial: 1 }) }); + } catch (e) { + raised = e; + } + assertEq( + String(raised).includes("ts_alloc"), + true, + `should name the missing export, got: ${raised}`, + ); +}); + +Deno.test({ + name: "fromExports: buildHash is null unless supplied", + ignore: !ready, + fn: async () => { + const ns = await import(shimUrl.href); + assertEq(Translator.fromExports(ns).buildHash, null, "default null"); + assertEq( + Translator.fromExports(ns, { buildHash: "abc123" }).buildHash, + "abc123", + "supplied hash carried", + ); + }, +});