Viscosity
Review
Part IX · Rust for TypeScript developers · Day 36

143. Rust and WebAssembly

A WebAssembly function only understands numbers, and Viscosity’s compiler wants a string and returns two. wasm-bindgen writes the code in between. Here is that code, generated for the real engine, and what it costs to cross.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Build a Rust crate for wasm32-unknown-unknown and run wasm-bindgen on it.
  • Explain how a &str argument and a struct result cross the boundary.
  • Say what a Result's Err becomes in JavaScript, and why free() exists.
  • Read the engine/rust wasm crate, and what its build did and didn't prove.

Numbers only

Chapter 117 called a WebAssembly function from JavaScript: add(40, 2). Its parameters were i32s, because that is all a WebAssembly function can take or return: numbers. Now look at the function the Rust port of Viscosity wants to offer the browser:

fn compile(source: &str)
    -> Result<Output, JsError>

A string goes in. A struct of two strings, or an error, comes out. None of that is a number, so something has to translate on both sides of the call, and write it by hand for every function and you’d soon get it wrong.

Intuition

WebAssembly is a hatch in a wall, just big enough for a few numbers at a time. wasm-bindgen is a pair of clerks, one on each side. Yours puts your letter into a locker on the far side and passes the locker number through the hatch. The other clerk opens the locker and reads it.

The real crate

engine/rust/crates/wasm/src/lib.rs is the whole wrapper, about fifty lines. Its core, simplified:

use wasm_bindgen::prelude::*;

#[wasm_bindgen(getter_with_clone)]
pub struct Output {
    pub code: String,
    pub explain: String,
}

#[wasm_bindgen]
pub fn compile(source: &str)
    -> Result<Output, JsError> {
    let r = compile_js(source, &JsPlugin,
        CompileOptions::default())
        .map_err(|e| {
            JsError::new(&e.to_string())
        })?;
    Ok(Output {
        code: r.code,
        explain: r.explain,
    })
}

#[wasm_bindgen] marks what JavaScript should see. getter_with_clone lets JavaScript read the String fields: a getter normally needs a Copy field, and this one clones instead[1]. A second export, compileWith, takes the mode, bounds and minimum size as well; you’ll write its option parsing in the exercise. The crate’s Cargo.toml asks for crate-type = ["cdylib", "rlib"]: a cdylib is what becomes the .wasm file.

Building it takes two tools. Cargo compiles the crate for the wasm32-unknown-unknown target, WebAssembly with no operating system. The wasm-bindgen CLI then reads that .wasm and writes the JavaScript glue next to it[1]:

cargo build --release -p viscosity-wasm \
  --target wasm32-unknown-unknown
W=target/wasm32-unknown-unknown/release
wasm-bindgen --target nodejs \
  --out-dir pkg $W/viscosity_wasm.wasm

I ran exactly this with cargo 1.97 and wasm-bindgen 0.2.106, the version the workspace pins. The build took 41 seconds from scratch; the .wasm was about 1.25 MB, and about 1.19 MB after wasm-bindgen moved its glue out (the exact byte counts move by a few hundred as the engine changes). The TypeScript types it wrote, trimmed:

export class Output {
  free(): void;
  code: string;
  explain: string;
}
export function compile(
  source: string): Output;
Definition

wasm-bindgen generates the bindings between Rust compiled to WebAssembly and JavaScript. The #[wasm_bindgen] attribute marks exported functions and structs; the wasm-bindgen CLI then writes the JavaScript glue and .d.ts types that convert strings, structs and Results to and from the numbers WebAssembly actually passes[1].

Crossing the boundary

Here is the generated compile, trimmed and re-indented:

function compile(source) {
  const ptr0 = passStringToWasm0(source,
    wasm.__wbindgen_malloc,
    wasm.__wbindgen_realloc);
  const len0 = WASM_VECTOR_LEN;
  const ret = wasm.compile(ptr0, len0);
  if (ret[2]) {
    throw takeFromExternrefTable0(ret[1]);
  }
  return Output.__wrap(ret[0]);
}

Step through one call:

Step throughA string across the boundary
const r = compile(source);
console.log(r.code);
r.free();
The JavaScript heap and WebAssembly linear memory during one compile callJavaScript heaplinear memorysource: "const…" UTF-16
source is a JavaScript string, UTF-16, in the JS heap. A WebAssembly function can take only numbers, so it can’t be passed as it is.
1/7

The string is copied, and re-encoded: JavaScript strings are UTF-16, Rust’s are UTF-8. A JavaScript string may hold an unpaired surrogate, which isn’t valid Unicode; TextEncoder replaces it with U+FFFD, so Rust gets a slightly different string[1]. The run below models the copy with a Vec<u8> standing in for linear memory. Notice that the lengths differ: 23 UTF-8 bytes, 22 UTF-16 units.

Try itA string as two numbers
// A model of what wasm-bindgen's
// glue does with a string argument.
// mem stands in for linear memory.

// JS side: copy UTF-8 bytes in, then
// pass where they start, and how many.
fn pass_str(mem: &mut Vec<u8>, s: &str)
    -> (usize, usize) {
    // a toy malloc: the end of mem
    let ptr = mem.len();
    mem.extend_from_slice(s.as_bytes());
    (ptr, s.len())
}

// Rust side: rebuild a &str from them.
fn take_str(mem: &[u8], ptr: usize,
    len: usize) -> &str {
    let bytes = &mem[ptr..ptr + len];
    std::str::from_utf8(bytes).unwrap()
}

fn main() {
    let mut mem = vec![0u8; 16];
    let src = "ps[i].x += 1; // naïve";
    let (ptr, len) =
        pass_str(&mut mem, src);
    println!("ptr {ptr}, len {len}");
    let js = src.encode_utf16().count();
    println!("JS length {js}");
    let back = take_str(&mem, ptr, len);
    println!("{back}");
    println!("same? {}", back == src);
}
Open in Rust Playground ↗

An Err takes the other branch. wasm-bindgen turns an exported function’s Err into a JavaScript exception[1]. From Node, compile("let = ;") threw an Error whose message was Unexpected token (1:6): the CompileError from oxc, through to_string(), through JsError, into a try/catch you could write in chapter 11.

Under the hood

The README says the WebAssembly build reproduces every parity fixture. I checked: a short Node script loaded the nodejs build and compiled all 61 test programs with both bounds modes, opt-in mode where the header asks for it. All 122 outputs matched the TypeScript engine’s code and explain, byte for byte. A rough timing on the shared VM put particles.js at a minimum of 96 to 114 µs a compile, near the native build’s 74 to 95 µs, but that is one quick look, not a benchmark. One line of the glue connects back to chapter 117. Before each copy it checks its Uint8Array view of the memory, and if the view’s byteLength is 0 it makes a new one. A malloc inside Rust may have grown the memory, which detaches the old buffer[2]. The book’s playground starts on the TypeScript engine; chapter 189 compiles the Rust one for the browser.

Your turn

ExercisecompileWith’s options

The wasm crate’s compileWith(source, mode, bounds, minSize) gets its options from JavaScript as strings. Write parse_mode ("auto" or "opt-in"), parse_bounds ("check" or "assume"), and options, which parses both, mode first, and passes the first error up with ?. Use the crate’s exact messages, given as BAD_MODE and BAD_BND.

const BAD_MODE: &str =
    "mode must be auto or opt-in";
const BAD_BND: &str =
    "bounds must be check or assume";

#[derive(Debug, PartialEq)]
enum Mode {
    Auto,
    OptIn,
}

#[derive(Debug, PartialEq)]
enum Bounds {
    Check,
    Assume,
}

fn parse_mode(s: &str)
    -> Result<Mode, String> {
    todo!()
}

fn parse_bounds(s: &str)
    -> Result<Bounds, String> {
    todo!()
}

fn options(mode: &str, bounds: &str)
    -> Result<(Mode, Bounds), String> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • WebAssembly exports exchange numbers only. Strings and structs cross as pointers and lengths into linear memory.
  • wasm-bindgen is an attribute plus a CLI. Together they write the glue and the TypeScript types.
  • A &str argument is copied in as UTF-8; a returned struct stays in Rust’s memory, behind a JS object holding its address.
  • Getters copy on every read; free() drops the Rust value.
  • An Err(JsError) becomes a thrown JavaScript Error.
  • The engine’s wasm build reproduced all 122 fixtures from Node.

Check your understanding

0/6
  1. 1.

    What does #[wasm_bindgen] on pub fn compile(source: &str) -> Result<Output, JsError> give JavaScript?

  2. 2.

    Each call to compile copies the source into linear memory. Why doesn’t the glue just hand Rust the JS string?

  3. 3.

    You read r.code ten times in a loop. What happens each time?

  4. 4.

    What does JavaScript receive when Rust’s compile returns Err(JsError::new("Unexpected token (1:6)"))?

  5. 5.

    Why is calling r.free() the caller’s job?

  6. 6.

    What does the WebAssembly build of Viscosity produce?

References

  1. [1] The wasm-bindgen contributors. The wasm-bindgen Guide. wasm-bindgen documentation (github.com/wasm-bindgen/wasm-bindgen, formerly rustwasm/wasm-bindgen; the README links the rendered guide at this URL). wasm-bindgen.github.io/wasm-bindgen/ (checked 2026-10-31)
  2. [2] W3C WebAssembly Community Group (editor: Ms2ger). WebAssembly JavaScript Interface. WebAssembly/spec repository on GitHub (rendered at webassembly.github.io/spec/js-api). webassembly.github.io/spec/js-api/ (checked 2026-10-31)