diff --git a/.github/justfile b/.github/justfile index 2219057..4437b33 100644 --- a/.github/justfile +++ b/.github/justfile @@ -55,6 +55,7 @@ core: @just gha::_step test-wasi-shims @just gha::_step test-ct-runner @just gha::_step test-bundle + @just gha::_step examples @just gha::_step conformance @just gha::_step sched-seeds @just gha::_step test-ports diff --git a/README.md b/README.md index 4d31ef2..a330ee7 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Pre-1.0, but densely gated: | `crates/translator-shim` | wasmtime-environ + FACT → versioned plan format (wasm32, runs everywhere) | | `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 | | `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 | @@ -61,6 +62,7 @@ is the command surface (`just --list`; recipe bodies are the exact commands). | Where | What | |---|---| +| [`examples/`](examples/) | runnable embedder examples: [hello-world](examples/hello-world/) (smallest complete embedding) and [kitchen-sink](examples/kitchen-sink/) (imports incl. suspending, resources both directions, value-shape tour) | | [`docs/architecture.md`](docs/architecture.md) | the system design and decisions, with rationale (§-numbered; cited from code comments) | | [`docs/milestones.md`](docs/milestones.md) | the verified milestone record (S0 → C3) | | [`docs/consumers.md`](docs/consumers.md) | the polymorph adoption track: jco blocker mapping, cutover evidence, in-repo ports | diff --git a/deno.json b/deno.json index bc4c330..53f1b08 100644 --- a/deno.json +++ b/deno.json @@ -1,3 +1,9 @@ { - "workspace": ["./runtime", "./harness", "./wasi-shims", "./ct-runner"] + "workspace": [ + "./runtime", + "./harness", + "./wasi-shims", + "./ct-runner", + "./examples" + ] } diff --git a/examples/.gitignore b/examples/.gitignore index 8f4e4a4..f003cc5 100644 --- a/examples/.gitignore +++ b/examples/.gitignore @@ -1,4 +1,9 @@ -# Component outputs and cargo build artifacts (rebuild with ./build.sh) +# Component outputs and cargo build artifacts (rebuild with ./build.sh +# or the per-example run.sh). guests/build/ guests/target/ guests/*/target/ +hello-world/build/ +hello-world/guest/target/ +kitchen-sink/build/ +kitchen-sink/guest/target/ diff --git a/examples/README.md b/examples/README.md index f0faa7c..5d3826a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,7 +1,26 @@ -# examples/ — Rust guest fixture corpus +# examples/ + +Two things live here, for two audiences: + +## Embedder examples (start here if you're using deltic) + +Complete, self-contained, self-checking WIT + Rust-guest + TS-host pairs. +Each directory can be copied out of the repo and built as-is; each `run.sh` +builds the guest component and runs the host under Deno (`just examples` +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) | + +The normative reference behind both is +[`contracts/embedder-api.md`](../contracts/embedder-api.md). + +## Rust guest fixture corpus (`guests/`) Guest components built with **wit-bindgen** (the compatibility target of this -project, docs/architecture.md §1/§11). The future TS host runs these as its executable +project, docs/architecture.md §1/§11). The TS host runs these as its executable wit-bindgen-compat claim. Each guest is a pure computational reactor — **no WASI imports** — so componentization needs no wasip1 adapter. diff --git a/examples/deno.json b/examples/deno.json new file mode 100644 index 0000000..9989481 --- /dev/null +++ b/examples/deno.json @@ -0,0 +1,10 @@ +{ + "name": "@deltic/examples", + "version": "0.0.0", + "exports": {}, + "tasks": { + "hello-world": "./hello-world/run.sh", + "kitchen-sink": "./kitchen-sink/run.sh", + "check": "deno check hello-world/host.ts kitchen-sink/host.ts" + } +} diff --git a/examples/hello-world/README.md b/examples/hello-world/README.md new file mode 100644 index 0000000..b689e97 --- /dev/null +++ b/examples/hello-world/README.md @@ -0,0 +1,30 @@ +# hello-world — the smallest complete embedding + +One exported function, no imports. Three files matter: + +| file | role | +|---|---| +| [`wit/world.wit`](wit/world.wit) | the contract: `greet: func(name: string) -> string` | +| [`guest/src/lib.rs`](guest/src/lib.rs) | the Rust guest (wit-bindgen) implementing it | +| [`host.ts`](host.ts) | the host: translate → instantiate → call | + +Run it: + +```sh +just shim # once, from the repo root: builds the translator +./run.sh # builds the guest component, runs the host +``` + +What to notice: + +- **Exports are Promise-shaped** — `await component.exports.greet(...)` + even though this guest is synchronous. One calling convention for sync + and async guests (contracts/embedder-api.md §"Functions and async"). +- **Strings just work** — the guest returns a heap-allocated string; the + canonical ABI's realloc dance is the runtime's problem, not yours. +- **The imports record is empty** — this world imports nothing. For the + full imports story (interfaces, resources, error model, suspending + imports) continue to [`../kitchen-sink`](../kitchen-sink). + +The authoritative reference for everything the host sees is +[`contracts/embedder-api.md`](../../contracts/embedder-api.md). diff --git a/examples/hello-world/guest/Cargo.lock b/examples/hello-world/guest/Cargo.lock new file mode 100644 index 0000000..0ce9bf2 --- /dev/null +++ b/examples/hello-world/guest/Cargo.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "example-hello" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-encoder" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01df5f3b4ca7881e843f3bc0fb8a3905d79c68692250dcb8e33e698705ccdb6" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a301904d6657d6364c758d869e5389d05d393b16d5b65db60b4f03cbe71bb80d" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48521bb96e56cbb9e031ad306c80dc20e89e69e49ada02781ee06f60fbcb545f" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df3fe9b9a0066f82fd0c2f07f79d0ffc0f85c53fa5f998516d8722712479042" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d689c4bc9d6af067c651cfba444766343c9a4bdef15fad1e2efab95c0c277af0" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e65bb94c369b3c4741ce3d1d2704b1fec93db7c540df0e521a097e7ceeb5be" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1655131e4f7d3f0cb141f6eca71315ca40eff0f3d4de7cff0a82bacedd8c89b4" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/hello-world/guest/Cargo.toml b/examples/hello-world/guest/Cargo.toml new file mode 100644 index 0000000..3b41696 --- /dev/null +++ b/examples/hello-world/guest/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "example-hello" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.60.0" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" +strip = "debuginfo" + +# Standalone: opt out of the repository's root cargo workspace so this +# directory can be copied out of the repo and built as-is. +[workspace] diff --git a/examples/hello-world/guest/src/lib.rs b/examples/hello-world/guest/src/lib.rs new file mode 100644 index 0000000..c8736a7 --- /dev/null +++ b/examples/hello-world/guest/src/lib.rs @@ -0,0 +1,19 @@ +//! The guest half of the hello-world example: implement the `hello` +//! world's one export. `wit_bindgen::generate!` reads ../wit/world.wit and +//! emits the `Guest` trait; `export!` wires the implementation into the +//! component's export table. + +wit_bindgen::generate!({ + path: "../wit", + world: "hello", +}); + +struct Component; + +impl Guest for Component { + fn greet(name: String) -> String { + format!("Hello, {name}!") + } +} + +export!(Component); diff --git a/examples/hello-world/host.ts b/examples/hello-world/host.ts new file mode 100644 index 0000000..6baeb9a --- /dev/null +++ b/examples/hello-world/host.ts @@ -0,0 +1,50 @@ +// The host half of the hello-world example. +// +// 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. +// +// Run with: ./run.sh (or: deno run --allow-read host.ts, after building +// the guest and the translator shim — run.sh does both). +// +// 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( + 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 greeting = await component.exports.greet("component model"); +console.log(greeting); + +if (greeting !== "Hello, component model!") { + throw new Error(`unexpected greeting: ${greeting}`); +} +console.log("hello-world example: OK"); diff --git a/examples/hello-world/run.sh b/examples/hello-world/run.sh new file mode 100755 index 0000000..ff5ec9a --- /dev/null +++ b/examples/hello-world/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the guest component and run the host. Same pipeline as +# examples/build.sh, scoped to this directory: cargo core module -> +# `wasm-tools component new` -> validate -> run host.ts under Deno. +# +# Prerequisites: the translator shim (`just shim` from the repo root; a +# published consumer gets a prebuilt Translator instead — deltic#16). +set -euo pipefail +cd "$(dirname "$0")" + +export CARGO_TARGET_DIR="$PWD/guest/target" +(cd guest && cargo build --release --target wasm32-unknown-unknown) + +mkdir -p build +wasm-tools component new \ + "$CARGO_TARGET_DIR/wasm32-unknown-unknown/release/example_hello.wasm" \ + -o build/hello.component.wasm +wasm-tools validate --features component-model build/hello.component.wasm + +deno run --allow-read=..,../../target host.ts diff --git a/examples/hello-world/wit/world.wit b/examples/hello-world/wit/world.wit new file mode 100644 index 0000000..7257c5a --- /dev/null +++ b/examples/hello-world/wit/world.wit @@ -0,0 +1,9 @@ +package deltic:hello-example; + +/// The smallest useful world: one exported function, no imports. +/// `greet` takes and returns a string, which already exercises the +/// canonical ABI's realloc path — the host must give the guest somewhere +/// to write the returned string. +world hello { + export greet: func(name: string) -> string; +} diff --git a/examples/kitchen-sink/README.md b/examples/kitchen-sink/README.md new file mode 100644 index 0000000..c9fd9ae --- /dev/null +++ b/examples/kitchen-sink/README.md @@ -0,0 +1,50 @@ +# kitchen-sink — a representative tour of the embedder API + +One world exercising the surfaces an embedder actually touches: + +| surface | WIT | guest | host | +|---|---|---|---| +| enum / record / variant / flags | `types` interface | `describe`, `classify`, `scale`, `allowed` | §4 | +| outermost `option` → `undefined \| T` | `find` | | §5 | +| return-place `result` → resolve / throw `WitError` | `lookup` | | §5 | +| nested option/result as plain data + the boxing rule | `survey`, `maybe-maybe` | | §5 | +| 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 | + +Run it: + +```sh +just shim # once, from the repo root: builds the translator +./run.sh # builds the guest component, runs the host +``` + +What to notice: + +- **The guest cannot tell which imports suspend.** `read-sensor` and + `channel.send` are sync WIT functions; the host implements them with + Promises and marks them — `suspending(fn)` (call form) and + `@suspending` (decorator on the class method). The guest's Rust is + oblivious; its wasm frame parks on JSPI and resumes. Marking has costs + (a continuation hop per call, illegal from `start` functions) — see + the §2c comment in [`host.ts`](host.ts). +- **Match errors on the brand, never the message.** `lookup`'s err side + arrives as a thrown `WitError` with `.payload`; any *unbranded* throw + from a host import is a host bug and traps the component. +- **The option rule is per-chain.** An option inside a `list` is still + the outermost of its own chain (`undefined | T`); boxing to + `{ tag: "some" | "none" }` happens only for option directly inside + another option — `maybe-maybe` pins all three depths. +- **Resources are classes on both sides.** The host's `Channel` class is + 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. + +The authoritative reference is +[`contracts/embedder-api.md`](../../contracts/embedder-api.md); if this +example and the contract disagree, the contract wins (and the example's +self-checks should have caught it — run them). diff --git a/examples/kitchen-sink/guest/Cargo.lock b/examples/kitchen-sink/guest/Cargo.lock new file mode 100644 index 0000000..21dcab0 --- /dev/null +++ b/examples/kitchen-sink/guest/Cargo.lock @@ -0,0 +1,337 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "example-kitchen-sink" +version = "0.1.0" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", + "serde", + "serde_core", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "macro-string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a9dbbfc75d2688ed057456ce8a3ee3f48d12eec09229f560f3643b9f275653" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasm-encoder" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09480d646178e5fdd12bb06e812d0af9a3a191dbc9cd697fdc86687beade7393" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b01df5f3b4ca7881e843f3bc0fb8a3905d79c68692250dcb8e33e698705ccdb6" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5769a29f799fbab136aaf65b4fe5384cd7d93fe6fc9ba0dcb6c8382a1f16e27" +dependencies = [ + "bitflags", + "hashbrown", + "indexmap", + "semver", +] + +[[package]] +name = "wit-bindgen" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a301904d6657d6364c758d869e5389d05d393b16d5b65db60b4f03cbe71bb80d" +dependencies = [ + "bitflags", + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48521bb96e56cbb9e031ad306c80dc20e89e69e49ada02781ee06f60fbcb545f" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df3fe9b9a0066f82fd0c2f07f79d0ffc0f85c53fa5f998516d8722712479042" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d689c4bc9d6af067c651cfba444766343c9a4bdef15fad1e2efab95c0c277af0" +dependencies = [ + "anyhow", + "macro-string", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e65bb94c369b3c4741ce3d1d2704b1fec93db7c540df0e521a097e7ceeb5be" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.254.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1655131e4f7d3f0cb141f6eca71315ca40eff0f3d4de7cff0a82bacedd8c89b4" +dependencies = [ + "anyhow", + "hashbrown", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-ident", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/kitchen-sink/guest/Cargo.toml b/examples/kitchen-sink/guest/Cargo.toml new file mode 100644 index 0000000..f9f2438 --- /dev/null +++ b/examples/kitchen-sink/guest/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "example-kitchen-sink" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wit-bindgen = "=0.60.0" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +panic = "abort" +strip = "debuginfo" + +# Standalone: opt out of the repository's root cargo workspace so this +# directory can be copied out of the repo and built as-is. +[workspace] diff --git a/examples/kitchen-sink/guest/src/lib.rs b/examples/kitchen-sink/guest/src/lib.rs new file mode 100644 index 0000000..b14a01c --- /dev/null +++ b/examples/kitchen-sink/guest/src/lib.rs @@ -0,0 +1,142 @@ +//! The guest half of the kitchen-sink example. +//! +//! Everything host-facing is in ../wit/world.wit; this file implements the +//! `api` interface (including the guest-implemented `counter` resource) and +//! drives the host-implemented `notify` imports from `run_batch`. +//! +//! Note what is ABSENT: nothing here knows that the host implements +//! `read-sensor` and `channel.send` with Promises that park this +//! component's stack (JSPI). Blocking host behavior is invisible to a +//! sync guest — that is the point of the `suspending()` embedder marker. + +use std::cell::Cell; + +wit_bindgen::generate!({ + path: "../wit", + world: "kitchen-sink", +}); + +use exports::deltic::kitchen_sink::api::{ + Guest, GuestCounter, Level, Perms, Point, Shape, +}; +use deltic::kitchen_sink::notify; + +struct Component; + +/// Guest-implemented resource: plain Rust state behind a handle. The host +/// sees a `Counter` class; the runtime owns the identity mapping. +struct Counter { + value: Cell, +} + +impl GuestCounter for Counter { + fn new(start: u32) -> Self { + Counter { value: Cell::new(start) } + } + + fn increment(&self) -> u32 { + self.value.set(self.value.get() + 1); + self.value.get() + } + + fn current(&self) -> u32 { + self.value.get() + } +} + +impl Guest for Component { + type Counter = Counter; + + fn describe(s: Shape) -> String { + match s { + Shape::Dot => "a dot".into(), + Shape::Circle(r) => format!("a circle of radius {r}"), + Shape::Rect(p) => format!("a rectangle to ({}, {})", p.x, p.y), + } + } + + fn classify(size: u32) -> Level { + match size { + 0..=9 => Level::Debug, + 10..=99 => Level::Info, + 100..=999 => Level::Warn, + _ => Level::Error, + } + } + + fn scale(p: Point, by: i32) -> Point { + Point { x: p.x * by, y: p.y * by } + } + + fn allowed(p: Perms) -> bool { + // Writing requires reading; execution alone is never enough. + p.contains(Perms::READ) && !(p == Perms::EXEC) + } + + fn find(name: String) -> Option { + match name.as_str() { + "origin" => Some(Point { x: 0, y: 0 }), + "unit" => Some(Point { x: 1, y: 1 }), + _ => None, + } + } + + fn lookup(name: String) -> Result { + Self::find(name.clone()).ok_or(format!("no point named '{name}'")) + } + + fn survey() -> Vec>> { + vec![ + None, + Some(Ok(Point { x: 2, y: 3 })), + Some(Err("survey hole".into())), + ] + } + + fn maybe_maybe(depth: u32) -> Option> { + match depth { + 0 => None, + 1 => Some(None), + _ => Some(Some(7)), + } + } + + /// Drive every import: log at each level boundary, parse ids (both + /// sides of the result), read the suspending sensor, and push + /// `messages` strings through a channel resource. + fn run_batch(messages: u32) -> Result { + notify::log(notify::Level::Info, "batch: start"); + + // Fallible import, both sides. The err side arrives as a plain + // Result::Err here — the host threw a branded WitError. + let id = notify::parse_id("42").map_err(|e| format!("parse-id(42): {e}"))?; + if notify::parse_id("not a number").is_ok() { + return Err("parse-id accepted garbage".into()); + } + + // Sync-typed call; the host parks this frame on a Promise and this + // guest neither knows nor cares. + let reading = notify::read_sensor(); + + // Host-implemented resource: construct, method calls (send is + // suspending on the host side), drop at end of scope (the runtime + // calls the host class's [Symbol.dispose]). + let chan = notify::Channel::new("batch"); + let mut sent = 0; + for i in 0..messages { + sent = chan.send(&format!("message {i} (id {id})")); + } + notify::log( + notify::Level::Debug, + &format!("batch: sent {sent} via '{}'", chan.label()), + ); + if notify::Channel::open_count() == 0 { + return Err("open-count says no channels while one is live".into()); + } + + notify::log(notify::Level::Info, "batch: done"); + Ok(reading) + } +} + +export!(Component); diff --git a/examples/kitchen-sink/host.ts b/examples/kitchen-sink/host.ts new file mode 100644 index 0000000..2eb6950 --- /dev/null +++ b/examples/kitchen-sink/host.ts @@ -0,0 +1,237 @@ +// The host half of the kitchen-sink example: a representative tour of the +// embedder API (contracts/embedder-api.md — the authority if this file and +// the contract ever disagree). +// +// §1 translate + instantiate, with a real imports record +// §2 host-implemented imports: sync, fallible (WitError), suspending +// §3 a host-implemented resource class (@suspending method, static, +// dispose-on-guest-drop) +// §4 calling exports: enum/variant/record/flags spellings +// §5 outermost option vs return-place result vs NESTED option/result +// §6 a guest-implemented resource driven with `using` +// §7 run-batch: the guest drives every import, parking twice on JSPI +// without knowing it +// +// Run with: ./run.sh + +import { Translator } from "@deltic/runtime/shim"; +import { + instantiate, + suspending, + WitError, +} from "@deltic/runtime/embedder"; + +// Tiny self-checks so the example fails loudly if the API drifts. +// (`undefined` is meaningful in the conventions — the outermost-option +// spelling — so the replacer must not let JSON collapse it into null.) +function assertEq(got: unknown, want: unknown, what: string) { + const replacer = (_k: string, v: unknown) => + v === undefined ? "" : typeof v === "bigint" ? `${v}n` : v; + const g = JSON.stringify(got, replacer); + const w = JSON.stringify(want, replacer); + if (g !== w) throw new Error(`${what}: expected ${w}, got ${g}`); +} + +// --- §2/§3: the host side of the `notify` import interface ------------------ + +const logs: string[] = []; + +/** §3 — a host-implemented resource. The guest's `channel` resource maps to + * this class: constructor from the guest's `Channel::new`, camelCase + * methods, statics as statics, and `[Symbol.dispose]` invoked by the + * runtime when the guest drops its last own handle. `send` is marked + * @suspending: it returns a Promise, so each guest `chan.send(...)` parks + * the guest's wasm frame until the "transmission" settles. */ +class Channel { + static #open = 0; + #name: string; + #sent = 0; + + constructor(name: string) { + Channel.#open += 1; + this.#name = name; + } + + @suspending + send(msg: string): Promise { + logs.push(`channel[${this.#name}] <- ${msg}`); + this.#sent += 1; + // Settles on a macrotask: a genuine park, not a microtask formality. + return new Promise((r) => setTimeout(() => r(this.#sent), 0)); + } + + label(): string { + return this.#name; + } + + static openCount(): number { + return Channel.#open; + } + + [Symbol.dispose]() { + Channel.#open -= 1; + logs.push(`channel[${this.#name}] disposed`); + } +} + +const imports = { + // Interface imports are keyed by their verbatim WIT id; members are + // camelCase. World-level bare imports (none here) would sit at the top + // level of this record. + "deltic:kitchen-sink/notify": { + // §2a — plain sync import. The enum parameter arrives as a string. + log: (lvl: string, msg: string) => { + logs.push(`${lvl}: ${msg}`); + }, + + // §2b — fallible import (result return-place): return the ok value; + // throw `new WitError(payload)` for the err side. Any OTHER throw is a + // host bug and traps the component — the anti-footgun inversion. + parseId: (raw: string): number => { + const n = Number(raw); + if (!Number.isInteger(n) || n < 0) { + throw new WitError(`'${raw}' is not an id`); + } + return n; + }, + + // §2c — a SUSPENDING import: sync-typed in WIT (`func() -> u64`), but + // the implementation returns a Promise. The `suspending()` marker (the + // call form; `@suspending` on Channel.send above is the decorator + // form) declares that intent: the guest's frame parks on JSPI and + // resumes with the value. Costs to know about: every call through a + // marked import pays an engine continuation hop even when it returns + // synchronously, and a marked import must not be reached from a + // component's `start` function. + readSensor: suspending( + (): Promise => + new Promise((r) => setTimeout(() => r(6502n), 0)), + ), + + // §3 — the resource class sits at its WIT-named (PascalCase) position. + Channel, + }, +}; + +// --- §1: translate + instantiate -------------------------------------------- + +const shimWasm = 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); + +// 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); + +// Interface exports are keyed like interface imports: verbatim WIT id. +const api = component.exports["deltic:kitchen-sink/api"]; + +// --- §4: plainly-shaped values ---------------------------------------------- + +// variant → { tag } / { tag, val }; nested records are plain objects. +assertEq(await api.describe({ tag: "dot" }), "a dot", "describe dot"); +assertEq( + await api.describe({ tag: "circle", val: 3 }), + "a circle of radius 3", + "describe circle", +); +assertEq( + await api.describe({ tag: "rect", val: { x: 4, y: 5 } }), + "a rectangle to (4, 5)", + "describe rect", +); + +// enum → string. +assertEq(await api.classify(7), "debug", "classify 7"); +assertEq(await api.classify(512), "warn", "classify 512"); + +// record → object. +assertEq( + await api.scale({ x: 2, y: -3 }, 10), + { x: 20, y: -30 }, + "scale", +); + +// flags → object of booleans (absent = false when lowering). +assertEq(await api.allowed({ read: true, write: true }), true, "allowed rw"); +assertEq(await api.allowed({ exec: true }), false, "allowed exec-only"); + +// --- §5: the three faces of option/result ------------------------------------ + +// OUTERMOST option: `undefined` or the value — no wrapper object. +assertEq(await api.find("origin"), { x: 0, y: 0 }, "find origin"); +assertEq(await api.find("atlantis"), undefined, "find missing"); + +// RETURN-PLACE result: ok resolves; err arrives as a thrown WitError whose +// `.payload` is the WIT err value. Match on the brand, never on message. +assertEq(await api.lookup("unit"), { x: 1, y: 1 }, "lookup ok"); +try { + await api.lookup("atlantis"); + throw new Error("lookup should have thrown"); +} catch (e) { + if (!(e instanceof WitError)) throw e; + assertEq(e.payload, "no point named 'atlantis'", "lookup err payload"); +} + +// NESTED option/result are plain data — but note WHICH rule applies where: +// the result-as-value is { tag: "ok" | "err", val }, while the option +// wrapping it is still the outermost of ITS OWN chain (the list does not +// count), so the none slot is a genuine `undefined`, not a { tag: "none" }. +assertEq( + await api.survey(), + [ + undefined, + { tag: "ok", val: { x: 2, y: 3 } }, + { tag: "err", val: "survey hole" }, + ], + "survey nested shapes", +); + +// Option-inside-option is the ONE place boxing appears, and it boxes +// exactly as deep as needed (the contract's worked example): +assertEq(await api.maybeMaybe(0), undefined, "maybe-maybe none"); +assertEq(await api.maybeMaybe(1), { tag: "none" }, "maybe-maybe some(none)"); +assertEq( + await api.maybeMaybe(2), + { tag: "some", val: 7 }, + "maybe-maybe some(some(7))", +); + +// --- §6: a guest-implemented resource ---------------------------------------- + +// The exports facade gives a real class: construct (synchronously — the +// one exception to Promise-shaped exports), call methods, and let `using` +// drop the handle (guest-side state is freed when scope ends). +{ + using counter = new api.Counter(3); + assertEq(await counter.increment(), 4, "counter.increment"); + assertEq(await counter.increment(), 5, "counter.increment again"); + assertEq(await counter.current(), 5, "counter.current"); +} + +// --- §7: the guest drives the imports ---------------------------------------- + +// run-batch logs, parses (both result sides), reads the suspending sensor, +// and sends through a Channel — parking this component's stack on every +// suspending call, invisibly to the guest code. +const reading = await api.runBatch(3); +assertEq(reading, 6502n, "run-batch sensor reading"); + +assertEq(Channel.openCount(), 0, "channel disposed after guest drop"); +assertEq( + logs.filter((l) => l.startsWith("channel[batch] <-")).length, + 3, + "three sends through the channel", +); +assertEq(logs.includes("info: batch: done"), true, "guest logged completion"); + +console.log(`kitchen-sink example: OK (${logs.length} log lines)`); diff --git a/examples/kitchen-sink/run.sh b/examples/kitchen-sink/run.sh new file mode 100755 index 0000000..46f007c --- /dev/null +++ b/examples/kitchen-sink/run.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Build the guest component and run the host. Same pipeline as +# examples/build.sh, scoped to this directory: cargo core module -> +# `wasm-tools component new` -> validate -> run host.ts under Deno. +# +# Prerequisites: the translator shim (`just shim` from the repo root; a +# published consumer gets a prebuilt Translator instead — deltic#16). +set -euo pipefail +cd "$(dirname "$0")" + +export CARGO_TARGET_DIR="$PWD/guest/target" +(cd guest && cargo build --release --target wasm32-unknown-unknown) + +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 + +deno run --allow-read=..,../../target host.ts diff --git a/examples/kitchen-sink/wit/world.wit b/examples/kitchen-sink/wit/world.wit new file mode 100644 index 0000000..70a3d9f --- /dev/null +++ b/examples/kitchen-sink/wit/world.wit @@ -0,0 +1,115 @@ +package deltic:kitchen-sink; + +/// Shared shapes, chosen to cover the value translations whose JS spelling +/// is not obvious (contracts/embedder-api.md §"Value shapes"): +/// enum, record, variant (with and without payloads), and flags. +interface types { + /// enum → a plain JS string: "debug" | "info" | "warn" | "error". + enum level { debug, info, warn, error } + + /// record → a plain JS object: { x: number, y: number }. + record point { x: s32, y: s32 } + + /// variant → { tag } for payload-less cases, { tag, val } otherwise: + /// { tag: "dot" } | { tag: "circle", val: 3 } | { tag: "rect", val: {x,y} } + variant shape { + dot, + circle(u32), + rect(point), + } + + /// flags → an object of optional booleans: { read: true, exec: true }. + flags perms { read, write, exec } +} + +/// Host-implemented imports: the embedder provides these in the imports +/// record. Covers a plain sync function, a fallible function (the branded +/// WitError throw), a suspending function (sync-typed, but the host parks +/// the guest's frame on a Promise), and a host-implemented resource with a +/// constructor, methods (one suspending), and a static. +interface notify { + use types.{level}; + + /// Sync import; enum parameter arrives as a string. + log: func(lvl: level, msg: string); + + /// Fallible import: the host implementation throws `new WitError(msg)` + /// for the err side — an unbranded throw would be a host bug and traps. + parse-id: func(raw: string) -> result; + + /// Sync-TYPED, but the host marks its implementation `suspending(...)` + /// and returns a Promise: the guest's frame parks on JSPI and resumes + /// with the value. The guest cannot tell — this line of WIT is the whole + /// guest-visible story. + read-sensor: func() -> u64; + + /// Host-implemented resource: the host provides a JS class. The guest + /// constructs instances (constructors are synchronous by contract), + /// calls methods — `send` is marked @suspending on the class — and the + /// runtime calls `[Symbol.dispose]()` when the guest drops its handle. + resource channel { + constructor(name: string); + send: func(msg: string) -> u32; + label: func() -> string; + open-count: static func() -> u32; + } +} + +/// Guest-implemented exports: what the host calls. +interface api { + use types.{level, point, shape, perms}; + + /// Variant in, string out. + describe: func(s: shape) -> string; + + /// Enum out. + classify: func(size: u32) -> level; + + /// Record in and out. + scale: func(p: point, by: s32) -> point; + + /// Flags in. + allowed: func(p: perms) -> bool; + + /// OUTERMOST option → `point | undefined` on the host (no wrapper). + find: func(name: string) -> option; + + /// RETURN-PLACE result → the ok value resolves the Promise; the err + /// value arrives as a thrown `WitError` whose `.payload` is the string. + lookup: func(name: string) -> result; + + /// Option/result NESTED inside other types are plain data (never a + /// throw, never a wrapper-less undefined-vs-value pun): + /// - result-as-value → { tag: "ok" | "err", val } + /// - option is `undefined | T` at the OUTERMOST position of its own + /// chain — even inside this list — and boxes to + /// { tag: "some", val } | { tag: "none" } only when nested DIRECTLY + /// inside another option (see `maybe-maybe`). + survey: func() -> list>>; + + /// The option-boxing rule, exactly (contracts/embedder-api.md §"Value + /// mapping", the worked `option>` example): + /// none → undefined + /// some(none) → { tag: "none" } + /// some(some7) → { tag: "some", val: 7 } + maybe-maybe: func(depth: u32) -> option>; + + /// Guest-implemented resource: the host constructs it (`new Counter(3)` + /// via the exports facade), calls methods, and drops it (`using` / + /// `[Symbol.dispose]()` / `.drop()`). + resource counter { + constructor(start: u32); + increment: func() -> u32; + current: func() -> u32; + } + + /// Drives every import above from inside the guest: logs, parses, + /// reads the (suspending) sensor, and pushes messages through a + /// channel resource. Returns the sensor reading it observed. + run-batch: func(messages: u32) -> result; +} + +world kitchen-sink { + import notify; + export api; +} diff --git a/justfile b/justfile index 552d5d5..a85b152 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 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 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 @@ -39,6 +39,13 @@ shim: fixtures: ./examples/build.sh +# The consumer-facing embedder examples (examples/README.md): build each +# guest component and run its self-checking host. These double as living +# documentation of the embedder API — CI runs them so they cannot rot. +examples: shim + ./examples/hello-world/run.sh + ./examples/kitchen-sink/run.sh + # 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/).