Skip to content
Select theme

Setting up wasm-bindgen and wasm-pack

Step 2 of 8, and it writes no Rust to speak of. Step 1 picked the target: a fuzzy search over 100,000 catalogue items that costs around 40 ms per keystroke in JavaScript. Before any of that work can move into WebAssembly, the toolchain has to exist — the wasm32-unknown-unknown compilation target, and wasm-pack, which turns a Rust crate into a directory a browser can import. By the end of this step both are installed, and a first pkg/ directory is sitting on disk. It exports nothing from your crate yet; that gap is exactly what step 3 closes.

Nothing from step 1 carries over: that article committed no code, so this one starts the project from an empty directory. Versions used here: Rust 1.99.0 and Node v22.22.2 on an arm64 Mac. If your wasm-pack or wasm-bindgen version is newer than the ones printed below, that is fine — just expect those numbers in your own output to differ.

rustc is a cross-compiler. The Rust you have compiles for your own machine; a rustup target is the standard library for another one, and installing it is one command:

Terminal window
rustup target add wasm32-unknown-unknown
info: downloading component 'rust-std' for 'wasm32-unknown-unknown'
info: installing component 'rust-std' for 'wasm32-unknown-unknown'

If you have done this before, rustup prints info: component 'rust-std' for target 'wasm32-unknown-unknown' is up to date instead.

Read the triple left to right: wasm32 is the architecture — 32-bit WebAssembly — and unknown-unknown means there is no operating system underneath, and none assumed. There is no kernel on the other end of a syscall, so the parts of the standard library that ask an OS for help have nothing to ask. Code that only computes — scoring strings, in this track’s case — does not care, and that is most of what this project needs.

wasm-pack is the driver for everything in the rest of this step: it runs cargo with the wasm target, then processes the output with wasm-bindgen to generate JavaScript glue, then runs wasm-opt to shrink it. Install it with cargo:

Terminal window
cargo install wasm-pack

Cargo compiles it from source, which takes a few minutes:

Compiling wasm-pack v0.15.0
Finished `release` profile [optimized] target(s) in 6m 22s
Installing /Users/rango/.cargo/bin/wasm-pack
Installed package `wasm-pack v0.15.0` (executable `wasm-pack`)

It lands in ~/.cargo/bin, which rustup already put on your PATH:

Terminal window
wasm-pack --version
wasm-pack 0.15.0

Clone the examples repository and start the project inside react-wasm/:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/react-wasm
cargo init --lib --name fuzzy-search
Creating library package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

--lib because there is no main here — this crate is a library that JavaScript will load, not a program that runs on its own. fuzzy-search is this track’s crate name; if you name yours something else, every generated filename below carries your name instead, with the hyphen turned into an underscore (fuzzy_search).

Two edits turn the fresh Cargo.toml into the one the rest of the track assumes:

[package]
name = "fuzzy-search"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wasm-bindgen = "0.2"

crate-type = ["cdylib"] is the line wasm-pack cannot work without — the trap section at the bottom shows the error you get if it is missing. A cdylib is a library with a C-compatible surface, exported symbols rather than Rust-internal ones; for the wasm target it is what makes cargo produce a .wasm file with anything callable in it. wasm-bindgen is the boundary library itself; step 3 puts its attribute on real functions. "0.2" is a caret requirement — cargo resolves it to the newest 0.2.x, which was 0.2.129 on this machine.

The library itself, for now, is one function no one can call:

src/lib.rs
// Plain Rust for now — nothing here crosses into JavaScript yet. Step 3 of the
// track puts #[wasm_bindgen] on real functions and this file grows for real.
pub fn ready() -> bool {
true
}
Terminal window
wasm-pack build --target web

The first run does three jobs and takes a while — 11 minutes here, most of it cargo fetching and compiling the wasm-bindgen CLI that wasm-pack installs once and reuses after. The interesting lines, with cargo’s own compile noise trimmed:

[INFO]: 🎯 Checking for the Wasm target...
[INFO]: 🌀 Compiling to Wasm...
Finished `release` profile [optimized] target(s) in 8.87s
[INFO]: ⬇️ Installing wasm-bindgen...
Installed package `wasm-bindgen-cli v0.2.129` (executables `wasm-bindgen`, `wasm-bindgen-test-runner`, `wasm2es6js`)
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: ✨ Done in 11m 35s
[INFO]: 📦 Your wasm pkg is ready to publish at /Users/rango/www/hackingwithrust/code/react-wasm/pkg.

The path on the last line is this machine’s checkout; yours will show your own. One detail worth noticing: the CLI wasm-pack installed is v0.2.129 — the same version as the wasm-bindgen dependency it just resolved. The generated glue and the runtime in your crate have to agree on the wire format, and wasm-pack keeps the two in step for you.

Every build after that first one is fast, because the CLI is cached:

[INFO]: 🎯 Checking for the Wasm target...
[INFO]: 🌀 Compiling to Wasm...
Finished `release` profile [optimized] target(s) in 0.03s
[INFO]: ⬇️ Installing wasm-bindgen...
[INFO]: Optimizing wasm binaries with `wasm-opt`...
[INFO]: Optional fields missing from Cargo.toml: 'description', 'repository', and 'license'. These are not necessary, but recommended
[INFO]: ✨ Done in 0.53s
[INFO]: 📦 Your wasm pkg is ready to publish at /Users/rango/www/hackingwithrust/code/react-wasm/pkg.

The Optional fields line is a suggestion, not a warning — the build works without them, and this track leaves them out.

--target web picks the flavour of glue. web emits a plain ES module for a browser, no bundler assumed; bundler targets webpack-style tooling, nodejs targets Node’s require, no-modules is the lowest-common-denominator script form. Step 7 loads the module from Vite with --target web, so that is the one this track builds.

Terminal window
ls -1 pkg/
fuzzy_search_bg.wasm
fuzzy_search_bg.wasm.d.ts
fuzzy_search.d.ts
fuzzy_search.js
package.json
README.md

Six files, all generated. fuzzy_search_bg.wasm is the compiled Rust — 362 bytes here, because there is nothing in it anyone can reach. fuzzy_search.js is the glue that loads it and exports an init function; the two .d.ts files are TypeScript types for the glue and for the raw wasm exports. package.json makes the directory an npm package:

{
"name": "fuzzy-search",
"type": "module",
"version": "0.1.0",
"files": [
"fuzzy_search_bg.wasm",
"fuzzy_search.js",
"fuzzy_search.d.ts"
],
"main": "fuzzy_search.js",
"types": "fuzzy_search.d.ts",
"sideEffects": [
"./snippets/*"
]
}

README.md is a copy of the crate’s, and there is a hidden pkg/.gitignore containing a single * — wasm-pack’s way of saying the directory is generated output, not something to commit. The crate’s own .gitignore already ignores pkg/ outright.

The TypeScript definitions are the honest summary of what step 2 accomplished, so it is worth reading the whole thing:

/* tslint:disable */
/* eslint-disable */
export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
export interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_start: () => void;
}
export type SyncInitInput = BufferSource | WebAssembly.Module;
/**
* Instantiates the given `module`, which can either be bytes or
* a precompiled `WebAssembly.Module`.
*
* @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
*
* @returns {InitOutput}
*/
export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;

ready() is in src/lib.rs, it compiled, and it appears nowhere. Rust functions are private to a wasm module unless something exports them, and nothing has been told to. Import the glue in Node and the module’s full surface is init machinery:

Terminal window
node --input-type=module -e "const m = await import('./pkg/fuzzy_search.js'); console.log(Object.keys(m));"
[ 'default', 'initSync' ]

Calling default() with no argument asks Node to fetch the .wasm from a file: URL, which Node refuses (TypeError: fetch failed, caused by Error: not implemented... yet...) — in a browser that fetch is exactly how the glue loads. Hand the bytes over yourself and the module instantiates; what comes back matches InitOutput from the definitions above, symbol for symbol:

Terminal window
node --input-type=module -e "import { readFile } from 'node:fs/promises'; const m = await import('./pkg/fuzzy_search.js'); const i = m.initSync({ module: await readFile('./pkg/fuzzy_search_bg.wasm') }); console.log(Object.keys(i));"
[ 'memory', '__wbindgen_externrefs', '__wbindgen_start' ]

Three exports, all boilerplate — linear memory, a table wasm-bindgen manages, and a start hook. No ready. That is the whole gap step 3 exists to close.

Leave [lib] crate-type = ["cdylib"] out of Cargo.toml and the same build dies before cargo does anything:

Error: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]
Caused by: crate-type must be cdylib to compile to WebAssembly. Add the following to your Cargo.toml file:
[lib]
crate-type = ["cdylib", "rlib"]

The default library type, rlib, is a Rust-only format — no exported symbols for the outside world, so wasm-pack refuses to build it. The error text suggests ["cdylib", "rlib"], which also works: the extra rlib lets other Rust crates link the library too. This track only needs the wasm side, so plain ["cdylib"] it is.

This step’s end state is tagged wasm-ch02 in the examples repository. If your build misbehaves, checking out that tag in your clone puts your tree exactly where this article left it:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/react-wasm
git checkout wasm-ch02
wasm-pack build --target web

The toolchain works and the boundary is proven to exist — proven by its emptiness. Step 3 puts #[wasm_bindgen] on the first real functions, ports the scoring code from step 1, and works out what can cross from Rust to JavaScript and back: strings, numbers, and the copies in between. The track overview keeps the full list current as articles land.