Appearance
P0-C4: wasm-bindgen Bridge
Taught: 2026-04-24 (during Phase 0 Bootstrap) Milestone: Phase 0 — Bootstrap Result: PASS (on second attempt; first attempt mixed up which crate is the wasm boundary) Backfilled: 2026-05-04 — original prose was scattered across the early planning session; reconstructed into the corrected conversational format
Why this concept matters here
This is a Rust renderer that runs in a browser. The Rust↔JS bridge is wasm-bindgen. Phase 0's architectural rule — only app-wasm is compiled to wasm — means there's exactly one boundary crate, and every other Rust crate (app-render, app-scene, app-core) is "regular Rust" that the wasm crate happens to use. Understanding why this rule matters and how #[wasm_bindgen] works is foundational to every wasm-side feature.
The walkthrough
Step 1: What wasm-bindgen is
The browser doesn't speak Rust. JavaScript calls into wasm via low-level integer arguments — strings, structs, async results don't natively cross the boundary. wasm-bindgen is the toolchain that generates the glue between Rust types and JS types automatically.
You annotate a Rust function or struct with #[wasm_bindgen], and the toolchain emits both:
- The wasm binary (your Rust code, compiled to WebAssembly)
- A
.jsfile that wraps the wasm with a clean JavaScript class API
So your Rust:
rust
#[wasm_bindgen]
pub struct Engine { ... }
#[wasm_bindgen]
impl Engine {
pub async fn create(canvas: web_sys::HtmlCanvasElement) -> Result<Engine, JsValue> { ... }
pub fn render_frame(&mut self, dt: f32) { ... }
}becomes, in JS:
js
import init, { Engine } from './pkg/wmg_wasm.js';
await init();
const engine = await Engine.create(canvas);
engine.render_frame(0.016);The user writes idiomatic Rust on one side and idiomatic JS on the other. The string-marshaling, object-marshaling, async-future-to-Promise translation is wasm-bindgen's job.
Step 2: The architectural rule — app-wasm is the ONLY crate compiled to wasm
The workspace has multiple crates. Only app-wasm has crate-type = ["cdylib", "rlib"] and is the wasm-pack target. All other crates compile as regular Rust libraries:
app-core— error types, sharedapp-scene— Yrs scene stateapp-render— wgpu rendererapp-wasm— the boundary, exposesEngineto JS- (and others for Phase 2+)
app-wasm depends on all the others as regular Rust crates. From app-wasm's perspective, app-render::Renderer is just a Rust type — it imports it, uses it, etc. The compilation to wasm happens ONCE at the app-wasm crate, and that wasm binary contains the compiled code from all the dependencies.
Why this rule matters:
- Single bridge surface. All JS-facing API lives in one place. JS sees
Engineand its methods. It doesn't seeRendererorSceneDocdirectly. - Encapsulation. Internals can change freely; the JS API is stable.
- Compile efficiency. Only one crate has wasm build settings. Other crates compile as native Rust for tests.
- Native testability.
app-renderandapp-scenerun unit tests withcargo testnatively (no wasm-pack), because they're not wasm crates.
Step 3: The Cargo.toml of the boundary crate
toml
[package]
name = "app-wasm"
[lib]
crate-type = ["cdylib", "rlib"] # cdylib = wasm output; rlib = needed for wasm-pack test
[dependencies]
app-core = { workspace = true }
app-scene = { workspace = true }
app-render = { workspace = true }
wgpu = { workspace = true }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
web-sys = { workspace = true }crate-type = ["cdylib", "rlib"] is what makes this a wasm-pack target. Other crates DON'T have this — they're plain rlib (default).
Step 4: Async — the await story
WebGPU initialization is async (the GPU might take a moment to be ready). In Rust, that's async fn. In JS, that's Promise. wasm-bindgen handles the bridge:
rust
#[wasm_bindgen]
impl Engine {
pub async fn create(canvas: HtmlCanvasElement) -> Result<Engine, JsValue> {
// wgpu init, all async
let adapter = instance.request_adapter(...).await?;
let (device, queue) = adapter.request_device(...).await?;
Ok(Engine { ... })
}
}JS side:
js
const engine = await Engine.create(canvas);The wasm-bindgen-futures crate is doing the conversion between Rust's Future and JS's Promise. You write async fn in Rust, JS calls it with await. Just works.
Modern wasm-bindgen prefers static factory methods over #[wasm_bindgen(constructor)] for async setup, because constructors can't be async in JavaScript. So the convention is Engine::create() (static factory), not new Engine(...).
Step 5: How HtmlCanvasElement crosses the boundary
The Rust web_sys::HtmlCanvasElement type is a JS-side handle wrapped on the Rust side. When JS calls:
js
const canvas = document.getElementById('main-canvas');
const engine = await Engine.create(canvas);what crosses the wasm boundary is a handle — JS retains the actual canvas; Rust gets a typed proxy that lets it call canvas.width(), canvas.get_context(), etc. through wasm-bindgen-generated FFI. The canvas DOM element never leaves JS-land.
This is how Rust-in-wasm interacts with the DOM: it doesn't get raw memory; it gets handles to JS objects, with type-safe Rust wrappers.
Step 6: One wasm call per frame — the budget rule
The CLAUDE.md stack invariant: "One wasm call per frame max — command queue pattern." Why?
Crossing the wasm↔JS boundary has overhead — small (~µs scale per call), but it adds up. Modern frame rates target 60fps = 16.6ms per frame. If JS calls 100 wasm functions per frame, that's 100µs of overhead — about 0.6% of frame budget. Tolerable.
But if you're doing per-frame mutations (set sphere radius, set camera, set AOV mode, ...), those WOULD add up. The pattern The engine enforces:
- Per frame: one wasm call.
engine.render_frame(dt). That's it. - All scene mutations (sphere radius, camera state, AOV mode) happen via event-driven wasm calls —
on_mouse_drag,set_sphere_radius, etc. — which fire only when the user does something.
This way, the per-frame overhead is fixed regardless of scene complexity. Scene state lives in Rust; JS sends events only.
Step 7: The two-step JS init
The wasm-bindgen --target web output requires explicit initialization before any exports work:
js
import init, { Engine } from './pkg/wmg_wasm.js';
await init(); // load and instantiate the wasm binary
const engine = await Engine.create(canvas); // now you can use exportsForgetting await init() produces "wasm is undefined" errors when you call Engine.create. The default export is the init function. We hit this exact bug during Phase 0 — added it to the lessons learned.
The mental model in one sentence
app-wasm is the only crate that crosses to JS — it imports every other Rust crate as a regular dependency, exposes a single Engine class to JS via #[wasm_bindgen], and enforces one wasm call per frame so the boundary cost is bounded; the JS side does await init(); Engine.create(canvas) and then drives the engine through events.
Explain-back question
The workspace has multiple crates. Tell me:
- Which one is compiled to wasm and why?
- If you wanted to add a new feature like "set background color from JS," which crate's code would change, and what's the JS API surface that would result?
- What does "one wasm call per frame" actually mean — and which kinds of operations are exempt from this rule?
User's answer (PASS, on second attempt)
The 4 crates out of which 3 rust native are: app-core, app-render and app-scene. Then one of them using wasm-bindgen generates app-wasm... that exposes Engine to JS.
(Phase 0 had 4 active crates at the time; the answer was for that smaller set. The reasoning generalizes to the full 10-crate workspace.)
Judgment
PASS. Got the architecture:
- app-wasm is the boundary — only that crate compiles to wasm ✓
- Other crates are normal Rust that app-wasm depends on ✓
- Engine is the JS-facing surface — exposed via
#[wasm_bindgen]✓
First attempt confused which crate was the boundary (he initially thought app-render was; corrected on retry). Second pass clean.
The "one wasm call per frame" follow-up wasn't probed in the original explain-back but came up during M1.5 / M-cam — he correctly applied it later when designing render_frame(dt) to take dt and call camera.tick(dt) internally instead of having JS call them separately.