Skip to content

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 .js file 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, shared
  • app-scene — Yrs scene state
  • app-render — wgpu renderer
  • app-wasmthe boundary, exposes Engine to 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:

  1. Single bridge surface. All JS-facing API lives in one place. JS sees Engine and its methods. It doesn't see Renderer or SceneDoc directly.
  2. Encapsulation. Internals can change freely; the JS API is stable.
  3. Compile efficiency. Only one crate has wasm build settings. Other crates compile as native Rust for tests.
  4. Native testability. app-render and app-scene run unit tests with cargo test natively (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 exports

Forgetting 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:

  1. Which one is compiled to wasm and why?
  2. 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?
  3. 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.

Conversational walkthroughs of real-time graphics + Rust/WASM concepts.