I love it when developers in our Python to Rust cohort go beyond the curriculum. WebAssembly isn't part of it, but one of them figured it out on his own and shared the result in our cohort Slack channel: his JSON parser, compiled to wasm and live on GitHub Pages.

Seeing his version work made me curious about the machinery underneath, so I followed his lead with my own parser.

The result is a JSON playground: type JSON on the left, and the Rust parser formats it on the right as you type, entirely in the browser.

The Rust wrapper is a 9-line file. The interesting part is the JavaScript that wasm-bindgen generates for you.

A second wrapper with wasm-bindgen

The parser already ships to Python behind a python feature, which I covered in How Libraries Run Rust Inside Python. The browser gets the same treatment: an optional wasm feature, so normal Rust users of the crate don't pull in the dependency.

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
wasm-bindgen = { version = "0.2", optional = true }

[features]
wasm = ["wasm-bindgen"]

The wrapper lives in its own module, compiled only when that feature is on:

// lib.rs
#[cfg(feature = "wasm")]
mod wasm_bindings;

// wasm_bindings.rs
#[wasm_bindgen]
pub fn parse_json_wasm(input: &str) -> Result<String, JsValue> {
    parse(input)
        .map(|value| value.pretty_print(2))
        .map_err(|e| JsValue::from_str(&e.to_string()))
}

If you've seen the PyO3 version, this will look familiar. #[pyfunction] becomes #[wasm_bindgen], and an Err turns into a thrown JavaScript exception instead of a Python ValueError. The wrapper changes; the parser's core doesn't change at all. You can see the full change in the PR.

Rust in the browser: write a Rust module, mark it with #[wasm_bindgen], build it with wasm-pack, then call it from JavaScript through the generated glue

The surprise: strings need a bridge

That &str parameter can't cross the WebAssembly boundary as a JavaScript string. WebAssembly has no string type: its core value types are numbers (integers, floats, SIMD vectors) and opaque references.

So wasm-pack generates a JavaScript file, rust_json_parser.js, that does the translating. Here is its parse_json_wasm, trimmed:

const ptr0 = passStringToWasm0(input, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const ret = wasm.parse_json_wasm(ptr0, WASM_VECTOR_LEN);
if (ret[3]) throw takeFromExternrefTable0(ret[2]);
return getStringFromWasm0(ret[0], ret[1]);   // then __wbindgen_free

Four steps happen on every call:

  1. Encode the JavaScript string as UTF-8 and copy it into the module's memory.
  2. Call the Rust function with a pointer and a length, two plain numbers.
  3. Decode the returned string, or turn the returned error into a JavaScript exception.
  4. Free the memory Rust allocated.

PyO3 does this same kind of work for CPython, but it's compiled into the extension, so you never see it. Here it's a JavaScript file you can open and read. Just don't edit it, because the next build overwrites it.

Build and ship to GitHub Pages

wasm-pack build --target web --out-dir pkg -- --features wasm
cp pkg/rust_json_parser.js pkg/rust_json_parser_bg.wasm docs/wasm/

The page loads it as an ES module, calls await init() to fetch and instantiate the .wasm module, and from then on parse_json_wasm behaves like any other JavaScript function. The .wasm is about 100 KB, around 51 KB gzipped.

The tradeoff

Returning a JSON string and calling JSON.parse on it is the simplest approach. To return native JavaScript objects I'd add serde and serde-wasm-bindgen, but avoiding serde is the point: I want my own parser working in more places, the same reason I swapped serde for it in ccfind.

There's one deployment issue left: the committed .wasm can drift out of sync with the Rust source. A GitHub Action that rebuilds it on every push would make the deploy reproducible.

What this means for your code

If your Rust core is a pure function, text in and a value out, you're one small wrapper away from each new host. One wrapper gets it into Python, and another gets it into the browser. The bindings tool writes the tricky boundary code, and with wasm-bindgen you can read exactly what it wrote.

A developer's side quest, shared in Slack, became my experiment too. That's the part of a cohort I can't put in a curriculum.

If you want to build the parser itself, start with What building a JSON tokenizer taught me about Rust.