From fb71828a4b29957fa56983b6ce0d7d04cde18a1e Mon Sep 17 00:00:00 2001 From: Lann Martin Date: Mon, 10 Aug 2026 18:05:38 -0400 Subject: [PATCH] examples: streams/futures in kitchen-sink; instantiate accepts untranslated artifacts (A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related changes from the examples DX review. A3 (contracts/embedder-api.md v0.2): `instantiate` also accepts `{ componentBytes, translator }` where translator is the shim wasm bytes or a shared Translator instance, translating internally — bytes in, instance out. requiredImports still takes a plan (translate explicitly to inspect the surface first). Pinned by tests/embedder/untranslated_artifacts_test.ts (both translator spellings). This collapses hello-world's host to a single instantiate call and removes the last boilerplate a first-time embedder has to understand before seeing output. Byte-import decision, recorded after empirical probes: `import ... with { type: "bytes" }` would make the hosts permission-flag-free, but it is Deno-unstable as of 2.9 (--unstable-raw-imports) and the unstable opt-in only takes effect at the WORKSPACE ROOT — a copied-out example directory would silently lose it, breaking the examples' copy-out contract. The flagless-but-stable alternative, `type: "text"`, corrupts binaries irreversibly (lossy UTF-8: the hello component decodes with 2206 U+FFFD replacements, re-encoding 20505 -> 24898 bytes). Examples therefore stay on Deno.readFile + scoped --allow-read, with the bytes-import future noted in a comment; run.sh now also type-checks (deno check) before running. kitchen-sink gains §8 streams and §9 futures, WIT -> guest -> host: - tally: async func(stream) -> u64 — the host passes natural producers (a finite array; a ReadableStream) where the guest expects a stream; the runtime owns pumping and close-on-end. - countdown: async func(u32) -> stream — a guest-produced stream arrives as a Stream handle; for-await yields CHUNKS (number[] batches), asserted flattened. - promised-double: async func(future) -> u32 — a plain Promise works where a future is expected. - deferred-answer: async func() -> future — the future-typed result is the one exception to Promise-shaped exports: an EAGER Future handle, returned synchronously (a Promise wrapper would adopt the thenable and make drop/cancel unreachable); the host holds it, checks .drop exists, then awaits it for the value. Guest side: no `async:` macro option — the WIT's own `async func` markers drive per-function codegen (the test-suite fixture's pattern), which is load-bearing here: the sync imports stay sync-LOWERED so the suspending-import demonstration remains honest. Producer halves (countdown, deferred-answer) follow the fixture rendezvous pattern: writes complete only when the peer receives, so they run in spawn_local tasks (wit-bindgen async-spawn feature) while the reader half returns. Component validation gains the cm-async feature in run.sh. READMEs updated (kitchen-sink table + notice items; index row); the "deliberately absent" list shrinks to async-typed imports and error-context. Gates: examples, test-runtime (338/0 incl. the new A3 pins), wasi-shims/ct-runner/bundle, conformance (1254/0, 0 unexpected, 0 stale), sched-seeds, shells — all green. --- contracts/embedder-api.md | 12 ++- examples/README.md | 2 +- examples/hello-world/host.ts | 40 ++++------ examples/hello-world/run.sh | 1 + examples/kitchen-sink/README.md | 20 ++++- examples/kitchen-sink/guest/Cargo.lock | 74 +++++++++++++++++++ examples/kitchen-sink/guest/Cargo.toml | 5 +- examples/kitchen-sink/guest/src/lib.rs | 48 ++++++++++++ examples/kitchen-sink/host.ts | 56 ++++++++++++-- examples/kitchen-sink/run.sh | 3 +- examples/kitchen-sink/wit/world.wit | 21 ++++++ runtime/src/embedder/instantiate.ts | 37 +++++++++- runtime/src/embedder/mod.ts | 2 + .../embedder/untranslated_artifacts_test.ts | 46 ++++++++++++ 14 files changed, 329 insertions(+), 38 deletions(-) create mode 100644 runtime/tests/embedder/untranslated_artifacts_test.ts diff --git a/contracts/embedder-api.md b/contracts/embedder-api.md index 197e3e6..6a8ec71 100644 --- a/contracts/embedder-api.md +++ b/contracts/embedder-api.md @@ -6,7 +6,9 @@ declared, per-function capability (`suspending()`), replacing v0.1's undeclared "permitted cast"; amendment A2 (2026-08-10) extends A1 to host-resource methods/statics (class-prototype authority), adds the stage-3 decorator form, and makes interface members receive their -containing object as `this`.** This document supersedes `descriptor-ir.md`'s interim +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 "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 @@ -367,6 +369,14 @@ const instance = await instantiate(artifacts, { - Bindgen emits the world's `Imports` type (this record, fully typed) and `Exports` type; `instantiate` verifies the world digest (`contracts/digest.md`) before trusting either. +- **Untranslated artifacts** (A3): `instantiate` also accepts + `{ componentBytes, translator }` where `translator` is the + translator-shim wasm bytes or a shared `Translator` instance, and + translates internally — bytes in, instance out. Prefer the shared + instance across several instantiations (the wasm compile is the cost + worth sharing; warm translation is sub-millisecond). + `requiredImports` still takes a plan: translate explicitly to inspect + the import surface before instantiating. - **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/examples/README.md b/examples/README.md index 5d3826a..cda2043 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,7 +12,7 @@ runs both, and CI does too — these cannot silently rot). | example | what it teaches | |---|---| | [`hello-world/`](hello-world/) | the smallest complete embedding: translate → instantiate → call one export; no imports | -| [`kitchen-sink/`](kitchen-sink/) | a representative tour: imports (sync / fallible / **suspending**), resources both directions, and the non-obvious value spellings (enum, variant, flags, outermost vs nested option/result, the option-boxing rule) | +| [`kitchen-sink/`](kitchen-sink/) | a representative tour: imports (sync / fallible / **suspending**), resources both directions, **streams and futures** (natural producers in, handles out), and the non-obvious value spellings (enum, variant, flags, outermost vs nested option/result, the option-boxing rule) | The normative reference behind both is [`contracts/embedder-api.md`](../contracts/embedder-api.md). diff --git a/examples/hello-world/host.ts b/examples/hello-world/host.ts index 6baeb9a..c9eb5e5 100644 --- a/examples/hello-world/host.ts +++ b/examples/hello-world/host.ts @@ -1,46 +1,38 @@ -// The host half of the hello-world example. +// The host half of the hello-world example — the whole pipeline is one +// call: give `instantiate` the component bytes and the translator, get +// typed-shaped exports back. // -// Pipeline, in full: -// 1. translate — the translator shim (a wasm module itself) turns the -// component binary into an execution plan + FACT adapter modules; -// 2. instantiate — the embedder API links host imports (none here) and -// returns typed-shaped exports; -// 3. call — exports are uniformly Promise-shaped (a sync guest resolves -// immediately); values cross per contracts/embedder-api.md. +// The two wasm files are read with `Deno.readFile`, so this script runs +// with a scoped read permission (run.sh passes it): // -// Run with: ./run.sh (or: deno run --allow-read host.ts, after building -// the guest and the translator shim — run.sh does both). +// deno run --allow-read=..,../../target 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). -import { Translator } from "@deltic/runtime/shim"; import { instantiate } from "@deltic/runtime/embedder"; -// --- 1. translate ---------------------------------------------------------- - -const shimWasm = await Deno.readFile( +const translator = await Deno.readFile( new URL( "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", import.meta.url, ), ); -const translator = await Translator.create(shimWasm); - const componentBytes = await Deno.readFile( new URL("build/hello.component.wasm", import.meta.url), ); -const { plan, adapters } = translator.translate(componentBytes); - -// --- 2. instantiate -------------------------------------------------------- - -// The second argument is the imports record. This world imports nothing, -// so it is empty — see ../kitchen-sink for the full shape. -const component = await instantiate({ plan, componentBytes, adapters }, {}); -// --- 3. call --------------------------------------------------------------- +const component = await instantiate({ componentBytes, translator }, { + // ... imports would go here; this world has none. See ../kitchen-sink. +}); +// Exports are uniformly Promise-shaped (a sync guest resolves immediately). const greeting = await component.exports.greet("component model"); console.log(greeting); diff --git a/examples/hello-world/run.sh b/examples/hello-world/run.sh index ff5ec9a..409b871 100755 --- a/examples/hello-world/run.sh +++ b/examples/hello-world/run.sh @@ -17,4 +17,5 @@ wasm-tools component new \ -o build/hello.component.wasm wasm-tools validate --features component-model build/hello.component.wasm +deno check host.ts deno run --allow-read=..,../../target host.ts diff --git a/examples/kitchen-sink/README.md b/examples/kitchen-sink/README.md index c9fd9ae..b30dcd3 100644 --- a/examples/kitchen-sink/README.md +++ b/examples/kitchen-sink/README.md @@ -11,6 +11,8 @@ One world exercising the surfaces an embedder actually touches: | host-implemented imports: sync, fallible, **suspending** | `notify` interface | `run-batch` | §2 | | host-implemented resource (ctor / method / static / dispose) | `notify.channel` | `run-batch` | §3 | | guest-implemented resource (`using`) | `api.counter` | `Counter` | §6 | +| streams: producers in, `Stream` handle out | `tally`, `countdown` | §8 | §8 | +| futures: Promise in, EAGER `Future` handle out | `promised-double`, `deferred-answer` | §9 | §9 | Run it: @@ -39,10 +41,20 @@ What to notice: handed over as-is (the runtime calls `[Symbol.dispose]` when the guest drops its handle); the guest's `counter` comes back as a constructible class the host can `using`-scope. - -Deliberately absent (to stay approachable): streams/futures and async-typed -functions — see `contracts/embedder-api.md` §"Streams and futures" until an -example covers them. +- **Streams lower from natural producers and lift as handles.** Pass an + array / ReadableStream / AsyncIterable where the guest expects a + `stream`; a guest-produced stream arrives as a `Stream` handle + whose `for await` yields *chunks* (`number[]` batches; `Uint8Array` for + `u8`). Guest-side, every stream/future write is a rendezvous — the + producer halves run in `spawn_local` tasks (see the guest doc comments). +- **A future-typed result is the one exception to Promise-shaped + exports**: `deferredAnswer()` returns an eager `Future` handle + synchronously (a Promise wrapper would adopt the thenable handle and + make `drop`/`cancel` unreachable). Awaiting the handle yields the value. + +Deliberately absent (to stay approachable): async-typed *imports* and +`error-context` — see `contracts/embedder-api.md` until an example covers +them. The authoritative reference is [`contracts/embedder-api.md`](../../contracts/embedder-api.md); if this diff --git a/examples/kitchen-sink/guest/Cargo.lock b/examples/kitchen-sink/guest/Cargo.lock index 21dcab0..a756894 100644 --- a/examples/kitchen-sink/guest/Cargo.lock +++ b/examples/kitchen-sink/guest/Cargo.lock @@ -33,6 +33,67 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -101,6 +162,12 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "prettyplease" version = "0.2.37" @@ -177,6 +244,12 @@ dependencies = [ "zmij", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "syn" version = "2.0.119" @@ -246,6 +319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a301904d6657d6364c758d869e5389d05d393b16d5b65db60b4f03cbe71bb80d" dependencies = [ "bitflags", + "futures", "wit-bindgen-rust-macro", ] diff --git a/examples/kitchen-sink/guest/Cargo.toml b/examples/kitchen-sink/guest/Cargo.toml index f9f2438..5c8a864 100644 --- a/examples/kitchen-sink/guest/Cargo.toml +++ b/examples/kitchen-sink/guest/Cargo.toml @@ -8,7 +8,10 @@ publish = false crate-type = ["cdylib"] [dependencies] -wit-bindgen = "=0.60.0" +# `async-spawn` backs the stream/future producer halves (countdown, +# deferred-answer): a write is a rendezvous, so it completes in a spawned +# background task while the reader half returns immediately. +wit-bindgen = { version = "=0.60.0", features = ["async-spawn"] } [profile.release] opt-level = "s" diff --git a/examples/kitchen-sink/guest/src/lib.rs b/examples/kitchen-sink/guest/src/lib.rs index b14a01c..543054d 100644 --- a/examples/kitchen-sink/guest/src/lib.rs +++ b/examples/kitchen-sink/guest/src/lib.rs @@ -14,12 +14,18 @@ use std::cell::Cell; wit_bindgen::generate!({ path: "../wit", world: "kitchen-sink", + // No `async:` option: the WIT's own `async func` markers drive + // per-function codegen — the sync exports/imports above stay sync + // (keeping the suspending-import demonstration honest: those imports + // are sync-LOWERED), while §8/§9's stream/future functions get async + // bodies. }); use exports::deltic::kitchen_sink::api::{ Guest, GuestCounter, Level, Perms, Point, Shape, }; use deltic::kitchen_sink::notify; +use wit_bindgen::rt::async_support::{FutureReader, StreamReader, StreamResult}; struct Component; @@ -137,6 +143,48 @@ impl Guest for Component { notify::log(notify::Level::Info, "batch: done"); Ok(reading) } + + /// §8 — consume a host-supplied stream to exhaustion. + async fn tally(mut numbers: StreamReader) -> u64 { + let mut sum = 0u64; + while let Some(v) = numbers.next().await { + sum += u64::from(v); + } + sum + } + + /// §8 — produce a stream. The writer pumps in a spawned task (each + /// write is a rendezvous with the host-side reader); the reader half + /// returns immediately. Dropping the writer closes the stream. + async fn countdown(start: u32) -> StreamReader { + let (mut writer, reader) = wit_stream::new(); + wit_bindgen::rt::async_support::spawn_local(async move { + for v in (1..=start).rev() { + let (result, _buf) = writer.write(vec![v]).await; + if !matches!(result, StreamResult::Complete(_)) { + break; + } + } + }); + reader + } + + /// §9 — await a host-supplied future (the host passed a Promise). + async fn promised_double(f: FutureReader) -> u32 { + f.await * 2 + } + + /// §9 — produce a future. Same rendezvous rule as streams: the write + /// completes only when the host receives the value, so it runs in a + /// spawned task while the reader half is handed back. + async fn deferred_answer() -> FutureReader { + let (writer, reader) = wit_future::new(|| 0u32); + wit_bindgen::rt::async_support::spawn_local(async move { + wit_bindgen::yield_async().await; + let _ = writer.write(42).await; + }); + reader + } } export!(Component); diff --git a/examples/kitchen-sink/host.ts b/examples/kitchen-sink/host.ts index 2eb6950..302e45f 100644 --- a/examples/kitchen-sink/host.ts +++ b/examples/kitchen-sink/host.ts @@ -11,10 +11,12 @@ // §6 a guest-implemented resource driven with `using` // §7 run-batch: the guest drives every import, parking twice on JSPI // without knowing it +// §8 streams: natural producers in (array / ReadableStream), a +// Stream handle out (for-await in chunks) +// §9 futures: a Promise in, an EAGER Future handle out // // Run with: ./run.sh -import { Translator } from "@deltic/runtime/shim"; import { instantiate, suspending, @@ -115,22 +117,25 @@ const imports = { // --- §1: translate + instantiate -------------------------------------------- -const shimWasm = await Deno.readFile( +const translator = await Deno.readFile( new URL( "../../target/wasm32-unknown-unknown/release/translator_shim.wasm", import.meta.url, ), ); -const translator = await Translator.create(shimWasm); const componentBytes = await Deno.readFile( new URL("build/kitchen-sink.component.wasm", import.meta.url), ); -const { plan, adapters } = translator.translate(componentBytes); +// `{ 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. +// // A marked import is auto-detection evidence: this instantiation selects // JSPI mode by itself. (`jspi: false` would force plain mode, where a // Promise from a sync-typed import is refused instead of parked.) -const component = await instantiate({ plan, componentBytes, adapters }, imports); +const component = await instantiate({ componentBytes, translator }, imports); // Interface exports are keyed like interface imports: verbatim WIT id. const api = component.exports["deltic:kitchen-sink/api"]; @@ -234,4 +239,45 @@ assertEq( ); assertEq(logs.includes("info: batch: done"), true, "guest logged completion"); +// --- §8: streams --------------------------------------------------------------- + +// Where the guest expects a stream, pass a natural producer — a +// finite array is the simplest (auto-closed at the end)... +assertEq(await api.tally([1, 2, 3, 4]), 10n, "tally an array-as-stream"); + +// ...or anything ReadableStream/AsyncIterable-shaped. +assertEq( + await api.tally(ReadableStream.from([5, 6, 7])), + 18n, + "tally a ReadableStream", +); + +// A guest-PRODUCED stream arrives as a Stream handle. `for await` +// yields CHUNKS — number[] batches (Uint8Array for stream), never +// single values — sized by whatever the guest wrote per rendezvous. +const stream = await api.countdown(3); +const received: number[] = []; +for await (const chunk of stream) received.push(...chunk); +assertEq(received, [3, 2, 1], "countdown chunks, flattened"); + +// --- §9: futures --------------------------------------------------------------- + +// Where the guest expects a future, a plain Promise works. +assertEq( + await api.promisedDouble( + new Promise((r) => setTimeout(() => r(21), 0)), + ), + 42, + "promise-as-future", +); + +// A future-typed RESULT is the one deliberate exception to Promise-shaped +// exports: the call returns an EAGER Future handle, synchronously. +// (Wrapping it in a Promise would let JS promise resolution adopt the +// thenable handle — drop()/cancel() would become unreachable.) Hold it, +// inspect it, then await it: the handle is thenable and yields the value. +const fut = api.deferredAnswer(); +assertEq(typeof fut.drop, "function", "deferred-answer returns a handle"); +assertEq(await fut, 42, "awaiting the handle yields the value"); + console.log(`kitchen-sink example: OK (${logs.length} log lines)`); diff --git a/examples/kitchen-sink/run.sh b/examples/kitchen-sink/run.sh index 46f007c..a9fdfbd 100755 --- a/examples/kitchen-sink/run.sh +++ b/examples/kitchen-sink/run.sh @@ -15,6 +15,7 @@ mkdir -p build wasm-tools component new \ "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/example_kitchen_sink.wasm" \ -o build/kitchen-sink.component.wasm -wasm-tools validate --features component-model build/kitchen-sink.component.wasm +wasm-tools validate --features component-model,cm-async build/kitchen-sink.component.wasm +deno check host.ts deno run --allow-read=..,../../target host.ts diff --git a/examples/kitchen-sink/wit/world.wit b/examples/kitchen-sink/wit/world.wit index 70a3d9f..71ef948 100644 --- a/examples/kitchen-sink/wit/world.wit +++ b/examples/kitchen-sink/wit/world.wit @@ -107,6 +107,27 @@ interface api { /// reads the (suspending) sensor, and pushes messages through a /// channel resource. Returns the sensor reading it observed. run-batch: func(messages: u32) -> result; + + /// §8 — the guest CONSUMES a stream the host supplies. On the host a + /// `stream` parameter accepts the natural producers — a finite + /// array, a ReadableStream, an AsyncIterable, or a Stream handle — and + /// the runtime owns the pumping and closes on end. + tally: async func(numbers: stream) -> u64; + + /// §8 — the guest PRODUCES a stream. The host receives a `Stream` + /// handle: `for await` yields CHUNKS (`number[]` batches, `Uint8Array` + /// for u8), or `.readable()` gives a web ReadableStream. + countdown: async func(start: u32) -> stream; + + /// §9 — the guest awaits a future the host supplies; a plain Promise + /// works where `future` is expected. + promised-double: async func(f: future) -> u32; + + /// §9 — a future-typed RESULT arrives as an EAGER `Future` handle, + /// not `Promise>` (JS promise resolution would adopt the + /// thenable handle and make drop/cancel unreachable). Call without + /// awaiting to hold the handle; awaiting the handle yields the value. + deferred-answer: async func() -> future; } world kitchen-sink { diff --git a/runtime/src/embedder/instantiate.ts b/runtime/src/embedder/instantiate.ts index 1dffbe1..c54a208 100644 --- a/runtime/src/embedder/instantiate.ts +++ b/runtime/src/embedder/instantiate.ts @@ -25,6 +25,7 @@ import { } from "../exec/mod.ts"; import { camelCase, parseLeafName, pascalCase } from "./casing.ts"; import { isSuspending, suspending } from "../jspi/suspending.ts"; +import { Translator } from "../shim/mod.ts"; import { NameCollisionError, WitError } from "./errors.ts"; import { type ImportLeaf, requiredImports } from "./imports.ts"; import { @@ -66,6 +67,39 @@ export interface ComponentArtifacts { adapters?: Map; } +/** + * Untranslated alternative to `ComponentArtifacts` (embedder-api.md + * amendment A3): hand `instantiate` the raw component plus the translator + * and it runs the translation internally — the pipeline collapses to + * bytes-in, instance-out. + * + * `translator` accepts the translator-shim wasm bytes (simplest; compiles + * the shim per call) or an already-created `Translator` (preferred when + * instantiating more than one component, or the same component more than + * once — create it once and reuse; translation itself is sub-millisecond + * warm, the wasm compile is the cost being shared). `requiredImports` + * still needs a plan: translate explicitly when you want to inspect the + * import surface before instantiating. + */ +export interface UntranslatedArtifacts { + componentBytes: Uint8Array; + translator: Uint8Array | Translator; +} + +/** Either artifacts shape accepted by `instantiate`. */ +export type InstantiateSource = ComponentArtifacts | UntranslatedArtifacts; + +async function resolveArtifacts( + src: InstantiateSource, +): Promise { + if ("plan" in src) return src; + const translator = src.translator instanceof Translator + ? src.translator + : await Translator.create(src.translator); + const { plan, adapters } = translator.translate(src.componentBytes); + return { plan, componentBytes: src.componentBytes, adapters }; +} + export interface EmbedderOptions { /** Opt in to JSPI-backed suspension (see `InstantiateInput.jspi`). */ jspi?: boolean; @@ -104,10 +138,11 @@ type Binding = * resolution (see `version.ts`). */ export async function instantiate( - artifacts: ComponentArtifacts, + source: InstantiateSource, imports: Record = {}, opts: EmbedderOptions = {}, ): Promise { + const artifacts = await resolveArtifacts(source); const facade = new Facade(artifacts, imports); const handle = await instantiateComponent({ plan: artifacts.plan, diff --git a/runtime/src/embedder/mod.ts b/runtime/src/embedder/mod.ts index 7f51309..3f33de4 100644 --- a/runtime/src/embedder/mod.ts +++ b/runtime/src/embedder/mod.ts @@ -10,6 +10,8 @@ export { type ComponentArtifacts, type EmbedderInstance, type EmbedderOptions, + type InstantiateSource, + type UntranslatedArtifacts, instantiate, instantiateEmbedder, } from "./instantiate.ts"; diff --git a/runtime/tests/embedder/untranslated_artifacts_test.ts b/runtime/tests/embedder/untranslated_artifacts_test.ts new file mode 100644 index 0000000..866f3a6 --- /dev/null +++ b/runtime/tests/embedder/untranslated_artifacts_test.ts @@ -0,0 +1,46 @@ +// The untranslated artifacts shape (contracts/embedder-api.md amendment +// A3): `instantiate` accepts `{ componentBytes, translator }` and runs the +// translation internally — bytes in, instance out. Both translator +// spellings are pinned: raw shim wasm bytes (compiles per call) and a +// shared `Translator` instance (the multi-component pattern). + +import { assertEq } from "../support/asserts.ts"; +import { haveFixture, readArtifact, testdata } from "./support.ts"; +import { instantiate } from "../../src/embedder/mod.ts"; +import { Translator } from "../../src/shim/mod.ts"; + +const shimWasm = await readArtifact( + "target/wasm32-unknown-unknown/release/translator_shim.wasm", +); +const ready = shimWasm !== null && (await haveFixture(testdata("imports"))); + +const IMPORTS = { + log: (_x: number) => {}, + "host:api/math": { + add: (a: number, b: number) => a + b, + greet: (who: string) => `hello ${who}`, + }, +}; + +Deno.test({ + name: "A3: instantiate({ componentBytes, translator: bytes }) translates internally", + ignore: !ready, + fn: async () => { + const componentBytes = (await readArtifact(testdata("imports")))!; + const c = await instantiate({ componentBytes, translator: shimWasm! }, IMPORTS); + assertEq(await c.exports.run(2, 40), 42); + }, +}); + +Deno.test({ + name: "A3: a shared Translator instance serves several instantiations", + ignore: !ready, + fn: async () => { + const translator = await Translator.create(shimWasm!); + const componentBytes = (await readArtifact(testdata("imports")))!; + const a = await instantiate({ componentBytes, translator }, IMPORTS); + const b = await instantiate({ componentBytes, translator }, IMPORTS); + assertEq(await a.exports.run(1, 2), 3); + assertEq(await b.exports.run(3, 4), 7); + }, +});