Viscosity
Review
Part XI · Viscosity in Rust · Day 48

189. Compiling the engine to WebAssembly

Chapter 143 got the Rust engine running in Node once, by hand. Shipping it takes a repeatable build: two kinds of glue, a version check, an optimizer, and a file small enough to download. Here is that build, and what each step does to 1.5 MB of WebAssembly.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Say what wasm-pack automates, and why Viscosity does the same steps in its own build.mjs.
  • Follow the .wasm through cargo, wasm-bindgen and wasm-opt, and say what each removes.
  • Tell the nodejs and web targets apart by how they load the module.
  • Write the lockfile check that keeps the wasm-bindgen CLI and crate in step.

Once by hand, then every time

In chapter 143 you ran two commands, cargo build for the wasm32-unknown-unknown target and wasm-bindgen --target nodejs, and the Rust engine compiled JavaScript from inside Node. That was enough to check parity. It isn’t enough to ship. A browser needs different glue from Node. The file should be as small as it can be, because readers download it. And the wasm-bindgen command-line tool must be the exact version the crate was built with (chapter 169), or it refuses the file.

The Rust ecosystem has a standard tool for all of this: wasm-pack. wasm-pack build “creates the files necessary for JavaScript interoperability and for publishing a package to npm”: it compiles the crate, runs wasm-bindgen, and writes a pkg folder holding the .wasm, the JavaScript wrapper, your README and a package.json[1]. For release builds it also runs wasm-opt, an optimizer from the Binaryen project[1][4].

Intuition

A flat-pack wardrobe comes with a kit: screws, a key and a sheet of pictures. Most people use the kit. If your wall is odd, you buy your own screws, but you still do the same steps in the same order.

Definition

wasm-pack builds an npm package from a Rust crate. wasm-pack build compiles to WebAssembly, runs wasm-bindgen for one --target (bundler by default, or nodejs, web, no-modules, deno), optimizes with wasm-opt in release builds, and writes pkg/ with a generated package.json[1].

Why Viscosity builds by hand

Viscosity doesn’t use wasm-pack. Its package is built by engine/rust/packages/wasm/build.mjs, 140 lines of Node. The odd wall is the one-target rule. @viscosity/wasm wants both a nodejs build and a web build in a single package, and lets each environment pick its own (that is chapter 190). The script also writes the licence texts, and with --book copies the web build into this book. Its steps, trimmed:

// 1. cargo
cargo build --release
  --target wasm32-unknown-unknown
  -p viscosity-wasm
// 2. wasm-bindgen, at the locked version
for (const t of ['nodejs', 'web'])
  wasm-bindgen --target t
    --out-dir dist/... viscosity_wasm.wasm
// 3. wasm-opt, if it can be found
wasm-opt -O3 <features> f -o f
// 4. LICENSE, THIRD_PARTY_LICENSES.txt
// 5. --book: copy dist/web into the book

(The real code calls execFileSync for each command.) Step 3 is optional. With no wasm-opt the build carries on and says so, and the result works, just larger. The wasm-opt it finds first is an npm devDependency, so npm install is all a contributor needs.

What each step does to the file

I rebuilt commit c68daec from an empty target directory in /tmp, with cargo 1.97, wasm-bindgen 0.2.106 and wasm-opt 132, running the script’s commands one at a time. Cargo took 38 seconds on the shared VM, and each wasm-opt run about 25. Between steps I walked the section headers of the .wasm to see where the bytes were:

Step throughFrom cargo to a 1.1 MB .wasm (at c68daec)
The size of the Rust engine's .wasm after cargo, wasm-bindgen and wasm-opt, by section, and gzippedcodedatanamesothercargo75 exports1,521,731 Bcargo build --releasewasm-bindgen15 exports1,449,352 Bwasm-bindgen --target webwasm-opt15 exports1,145,498 Bwasm-opt -O3
Cargo compiles the crate, the engine and 67 third-party crates for wasm32-unknown-unknown. It has 75 exports, and 58 of them are __wbindgen_describe_ functions, which JavaScript will never call. A 7 KB custom section, __wasm_bindgen_unstable, is for the CLI too.
1/4

The engine has grown since. At commit 288e497, which adds oxc’s semantic checks (chapter 172), the same three steps give 1,844,560, 1,759,791 and 1,373,390 bytes: about 1.4 MB, 486 KiB gzipped.

A WebAssembly file is a list of numbered sections: types, functions, exports, code, data, and “custom” sections that engines may ignore. The run below walks a 52-byte module the same way, reading each section’s id and its size (a variable-length LEB128 number):

Try itWhat is inside a .wasm file
use std::str;

// Read one LEB128 number (7 bits a
// byte, high bit = more to come).
fn leb(
    b: &[u8],
    i: &mut usize,
) -> usize {
    let (mut r, mut s) = (0, 0);
    loop {
        let x = b[*i];
        *i += 1;
        r |= ((x & 0x7f) as usize) << s;
        s += 7;
        if x & 0x80 == 0 {
            return r;
        }
    }
}

fn kind(id: u8) -> &'static str {
    match id {
        0 => "custom",
        1 => "type",
        3 => "function",
        7 => "export",
        10 => "code",
        11 => "data",
        _ => "other",
    }
}

fn main() {
    // f() returns 42; a "name" section
    // calls it "answer".
    let m: &[u8] = &[
        0, 97, 115, 109, 1, 0, 0, 0,
        1, 5, 1, 0x60, 0, 1, 0x7f,
        3, 2, 1, 0,
        7, 5, 1, 1, 102, 0, 0,
        10, 6, 1, 4, 0, 0x41, 42, 11,
        0, 16, 4, 110, 97, 109, 101,
        1, 9, 1, 0, 6,
        97, 110, 115, 119, 101, 114,
    ];
    assert_eq!(&m[..4], b"\0asm");
    let mut i = 8;
    while i < m.len() {
        let id = m[i];
        i += 1;
        let n = leb(m, &mut i);
        let end = i + n;
        let mut w =
            String::from(kind(id));
        if id == 0 {
            let l = leb(m, &mut i);
            let b = &m[i..i + l];
            let s = str::from_utf8(b);
            let s = s.unwrap();
            w += &format!(" {s:?}");
        }
        println!("{w}: {n} bytes");
        i = end;
    }
}
Open in Rust Playground ↗

The second step explains a mystery from chapter 143: how does a command-line tool know that compile takes a string and returns an Output? The #[wasm_bindgen] macro can’t write that down as data, because it runs before the compiler has resolved the types. So it emits code instead: one __wbindgen_describe_ function per export, which reports the types when run. The CLI contains “a tiny and incomplete Wasm interpreter” for exactly this. It runs every describe function, then deletes them all[3].

Two kinds of glue

The two wasm-bindgen runs produce the same .wasm (64 bytes apart) and different JavaScript. The nodejs target writes CommonJS that can be required like any Node module[2]. Its last lines, trimmed:

const wasmBytes = require('fs')
  .readFileSync(wasmPath);
const wasmModule =
  new WebAssembly.Module(wasmBytes);
const wasm = new WebAssembly.Instance(
  wasmModule, imports).exports;

That is synchronous: compile works the moment require returns. A browser can’t read files, so the web target writes an ES module with an init() that you await. It fetches the .wasm (by default, from next to the glue) and instantiates it, streaming if it can[2]:

import init, { compile }
  from '@viscosity/wasm/web';
await init();
const { code, explain } = compile(src);

Checking the tools

Step 2 begins with the check you’ll write below. It reads the wasm-bindgen version from Cargo.lock, asks the CLI for its own, and stops with the exact cargo install command if they differ. One more check is worth having for a committed binary. The web .wasm from my rebuild was byte-identical to the one book/public/wasm held then, and a rebuild at 288e497 matches today’s file, so the file this book serves really is the engine at that commit.

Under the hood

The final module has 15 exports: compile, compileWith, compileLanguage, the getters, setters and free of Output, the memory, and wasm-bindgen’s allocator, table and start functions. It imports just three functions from JavaScript: one throws an error, one makes an Error object, and one sets up the table of JavaScript values. Everything else, oxc’s parser included, is Rust compiled into the code section: 1,040,082 of the 1,145,498 bytes at c68daec, and 1,248,736 of 1,373,390 at 288e497. build.mjs also passes wasm-opt the WebAssembly features rustc enables by default for this target (bulk memory, sign extension, reference types and three more)[5]. Out of curiosity I ran wasm-opt without them: the same bytes came out. The module lists its features in a 157-byte custom section, target_features, which survives every step.

Your turn

ExerciseThe version check

Before running wasm-bindgen, build.mjs reads Cargo.lock and insists on exactly that version of the CLI. Write locked: in a lockfile, find the line name = "<name>" (the whole line) and return the version from the version = "…" line right after it, or None. Then write check: out is the CLI’s --version output, like "wasm-bindgen 0.2.106\n"; its version is the last word. Return Ok(()) if it matches the locked wasm-bindgen, else Err with exactly wasm-bindgen-cli X is required (found Y), or wasm-bindgen is not in Cargo.lock.

const V: &str = "version = \"";

// The version Cargo.lock pins for the
// crate called `name`, if any.
fn locked<'a>(
    lock: &'a str,
    name: &str,
) -> Option<&'a str> {
    todo!()
}

// `out`: what the CLI's --version
// printed. Ok if it matches the lock.
fn check(
    lock: &str,
    out: &str,
) -> Result<(), String> {
    todo!()
}
Open in Rust Playground ↗

What you learned

  • wasm-pack compiles, binds, optimizes and packages a crate for npm, for one target at a time. build.mjs does the same steps by hand, for two targets.
  • cargo’s file carries describe functions and a custom section for the CLI. wasm-bindgen runs them, writes the glue and removes them.
  • wasm-opt -O3 shrank the code by about 8% and dropped about 200 KB of names. Gzipped, the whole saving was under 5%.
  • The nodejs glue loads synchronously with readFileSync. The web glue needs await init().
  • The CLI must match Cargo.lock exactly, and the committed .wasm rebuilds byte for byte.

Check your understanding

0/6
  1. 1.

    Which of these does build.mjs do that a single wasm-pack build does not?

  2. 2.

    Cargo’s .wasm had 75 exports and the final one 15. What were most of the others?

  3. 3.

    wasm-opt cut the file from 1,449,352 to 1,145,498 bytes. Gzipped, the saving was about 20 KB. Why so much less?

  4. 4.

    What does the nodejs target’s glue do when you require it?

  5. 5.

    You install wasm-bindgen-cli 0.2.130, but Cargo.lock pins the crate at 0.2.106. What happens with build.mjs?

  6. 6.

    The .wasm in book/public/wasm is committed. How do you know it is the engine at that commit?

References

  1. [1] The wasm-pack contributors. The wasm-pack book: wasm-pack build, and Cargo.toml configuration. wasm-pack documentation; source docs/src in github.com/wasm-bindgen/wasm-pack (moved from rustwasm/wasm-pack). wasm-bindgen.github.io/wasm-pack/book/commands/build.html (checked 2026-10-31)
  2. [2] 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)
  3. [3] The wasm-bindgen Developers. wasm-bindgen-cli-support 0.2.106: descriptor functions and the descriptor interpreter (src/descriptors.rs, src/interpreter/mod.rs). Source of the wasm-bindgen-cli-support crate, version 0.2.106, on crates.io (github.com/wasm-bindgen/wasm-bindgen). crates.io/crates/wasm-bindgen-cli-support/0.2.106 (checked 2026-10-31)
  4. [4] The Binaryen contributors (WebAssembly Community Group). Binaryen: README (wasm-opt). github.com/WebAssembly/binaryen. github.com/WebAssembly/binaryen (checked 2026-10-31)
  5. [5] The Rust Project Developers. The rustc book: wasm32-unknown-unknown. The rustc book, Platform Support; source src/doc/rustc/src/platform-support/wasm32-unknown-unknown.md in github.com/rust-lang/rust. doc.rust-lang.org/rustc/platform-support/wasm32-unknown-unknown.html (checked 2026-10-31)