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.
Add the wasm32 target
Section titled “Add the wasm32 target”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:
rustup target add wasm32-unknown-unknowninfo: 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.
Install wasm-pack
Section titled “Install wasm-pack”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:
cargo install wasm-packCargo 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:
wasm-pack --versionwasm-pack 0.15.0Create the crate
Section titled “Create the crate”Clone the examples repository and start the project inside react-wasm/:
git clone https://github.com/hackingwithrust/examplescd examples/react-wasmcargo init --lib --name fuzzy-search Creating library packagenote: 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:
// 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}First build
Section titled “First build”wasm-pack build --target webThe 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.
What is in pkg
Section titled “What is in pkg”ls -1 pkg/fuzzy_search_bg.wasmfuzzy_search_bg.wasm.d.tsfuzzy_search.d.tsfuzzy_search.jspackage.jsonREADME.mdSix 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:
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:
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.
The trap: a lib without crate-type
Section titled “The trap: a lib without crate-type”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.
Where the code stands
Section titled “Where the code stands”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:
git clone https://github.com/hackingwithrust/examplescd examples/react-wasmgit checkout wasm-ch02wasm-pack build --target webThe 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.