3 unstable releases
Uses new Rust 2024
| new 0.2.1 | Mar 5, 2026 |
|---|---|
| 0.2.0 | Mar 5, 2026 |
| 0.1.0 | Jan 28, 2026 |
#86 in WebAssembly
70KB
993 lines
wasm-bodge
A CLI tool for taking wasm-bindgen based Wasm libraries and building an NPM package that works everywhere (well, lots of places).
Note: The problem this tool is solving is tricky, and the things we do to solve it are complicated and somewhat fragile. It's best to have a full understanding of what exactly is being done in order to be able to debug things effectively. Please read the full README.
TL;DR
What it does: Takes a Rust crate using wasm-bindgen and produces a universal NPM package.
Basically you do this inside your rust crate:
wasm-bodge build
And now you have a ready to publish NPM package.
Supported environments:
- Node.js (ESM and CommonJS)
- Browsers (with bundlers like Webpack, Vite, Rollup)
- Browsers (without bundlers, via base64-embedded wasm)
- Cloudflare Workers (workerd)
- Script tags (IIFE)
Key exports:
The package produced by wasm-bodge provides the following subpath exports which help with handling WebAssembly initialization in different environments:
| Export | Description |
|---|---|
. |
Auto-detected environment entry point |
./slim |
Manual initialization (for library authors) |
./wasm |
Raw .wasm file |
./wasm-base64 |
Base64-encoded wasm |
./iife |
IIFE bundle for <script> tags |
Table of Contents
- Quickstart
- CLI Reference
- The Problem
- How wasm-bodge Solves It
- Build Output
- Technical Details
- Troubleshooting
Quickstart
# Prerequisites: Rust with wasm32-unknown-unknown target, wasm-bindgen-cli, wasm-opt, esbuild
# Build your wasm crate
wasm-bodge build
# Publish from the directory containing package.json
npm publish
Your users can then import it anywhere:
import { myFunction } from "my-wasm-lib"
CLI Reference
wasm-bodge build [OPTIONS]
| Option | Default | Description |
|---|---|---|
--crate-path <PATH> |
. (current dir) |
Path to the Rust crate directory |
--package-json <PATH> |
./package.json |
Path to template package.json |
--out-dir <PATH> |
./dist |
Output directory for generated files |
--profile <PROFILE> |
release |
Cargo build profile |
--wasm-bindgen-tar <PATH> |
(none) | Use prebuilt wasm-bindgen output from tarball |
--no-wasm-opt |
false |
Skip wasm-opt optimization |
Prerequisites:
- Rust with
wasm32-unknown-unknowntarget (rustup target add wasm32-unknown-unknown) wasm-bindgen-cli(cargo install wasm-bindgen-cli)wasm-opt(cargo install wasm-opt) — disable with--no-wasm-optesbuild(npm install -g esbuildor local install)
The Problem
The output of wasm-bindgen is not a JavaScript package that can be loaded in any environment.
Writing WebAssembly libraries in Rust is generally achieved using the wasm-bindgen toolchain. Using wasm-bindgen involves three steps:
- Write code in Rust annotated with the
wasm-bindgenmacros - Compile the Rust code to WebAssembly using
cargo build --target wasm32-unknown-unknown - Use the
wasm-bindgenCLI tool to generate JavaScript and WebAssembly files from the compiled Wasm
Here's an example. Say we have a Rust crate called my-rust-crate with this code:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn add(left: u32, right: u32) -> u32 {
left + right
}
To build this for Node.js we first compile it for the wasm32-unknown-unknown target:
cargo build --target wasm32-unknown-unknown --release
Then, we use wasm-bindgen to generate the JS and Wasm files:
wasm-bindgen --target nodejs --out-dir ./out ./target/wasm32-unknown-unknown/release/my_rust_crate.wasm
This generates output in ./out:
out/
my_rust_crate.d.ts
my_rust_crate.js
my_rust_crate_bg.wasm
my_rust_crate_bg.wasm.d.ts
The .d.ts files are TypeScript declarations. The .js file contains JavaScript glue code that provides a nice interface to the WebAssembly module. We can import it like so:
import { add } from "./out/my_rust_crate.js";
add(1, 2);
To make this into a package, we create a package.json:
{
"name": "my-wasm-lib",
"version": "1.0.0",
"main": "out/my_rust_crate.js",
"exports": {
".": "./out/my_rust_crate.js"
}
}
This works in Node.js:
import { add } from "my-wasm-lib";
add(1, 2);
But it fails in Deno:
> deno
Deno 2.6.5
> let { add } = await import("my-wasm-lib")
Uncaught ReferenceError: exports is not defined
at file:///tmp/wat/my-rust-crate/dist/my_rust_crate.js:12:1
The problem is that --target nodejs produces CommonJS modules, but Deno only supports ES Modules. We could use --target deno, but then Node.js wouldn't work.
This is the core problem wasm-bodge solves.
How wasm-bodge Solves It
The solution is to:
- Bundle all the different wasm-bindgen strategies into the package
- Use subpath exports to choose the right strategy for the current environment
- Provide an escape hatch for environments where detection doesn't work
Subpath Exports
Subpath exports are a package.json feature that allows different entry points for different environments:
{
"exports": {
"import": "./esm/index.js",
"require": "./cjs/index.js"
}
}
This tells the module resolver to use ./esm/index.js for ES Module import statements, and ./cjs/index.js for CommonJS require calls.
We can be more specific with "conditional exports":
{
"exports": {
"node": {
"import": "./esm/node.js",
"require": "./cjs/node.js"
},
"browser": "./esm/browser.js"
}
}
The module resolver picks the most specific match for the current environment. Which conditions are supported depends on the environment and is generally not well standardized or documented - that's why this approach is fragile and why we need an escape hatch.
Shared Web Target Architecture
A key design decision in wasm-bodge is that every entry point ultimately re-exports
from the same wasm-bindgen --target web output module. The web target generates a
module-level let wasm; variable that all binding functions reference, and exports an
initSync(bytes) function that populates it. Because ES modules are singletons, any
code that imports from wasm_bindgen/web/<lib name>.js shares this wasm variable.
This means that if the root export (.) initializes the wasm module, the slim export
(./slim) automatically becomes functional too — they're both backed by the same
underlying module. This is the property that makes it safe for library authors to
import from /slim while their consumers import from the root.
Each environment just differs in how it obtains the wasm bytes and calls initSync:
| Environment | How wasm is loaded | Init mechanism |
|---|---|---|
| Node.js | fs.readFileSync from disk |
initSync(bytes) |
| Browser (no bundler) | Base64-encoded in JS | initSync(bytes) |
| Bundler (Webpack, Vite) | Bundler's native .wasm import |
__wbg_set_wasm(exports) shim |
| Cloudflare Workers | Synchronous wasm module import | initSync({ module }) |
| Slim | User-provided | User calls initSync |
For CJS, a shared cjs/web-bindings.cjs bundle (built by esbuild from the web target)
serves the same role. Both cjs/node.cjs and cjs/slim.cjs require() this bundle,
and Node's require cache ensures they share the same module instance.
Environment-Specific Strategies
wasm-bodge creates entrypoint scripts for each supported environment. Each entrypoint
initializes wasm using whatever mechanism is available in that environment, then
re-exports from the shared web target module.
Node.js
The Node.js entrypoint reads the wasm binary from disk using node:fs and calls
initSync on the web target.
ES Module Entrypoint (./dist/esm/node.js):
import { initSync } from '../wasm_bindgen/web/<lib name>.js';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
initSync(readFileSync(join(__dirname, '../wasm_bindgen/web/<lib name>_bg.wasm')));
export * from '../wasm_bindgen/web/<lib name>.js';
CommonJS Entrypoint (./dist/cjs/node.cjs):
const bindings = require('./web-bindings.cjs');
const fs = require('fs');
const path = require('path');
bindings.initSync(fs.readFileSync(path.join(__dirname, '../wasm_bindgen/web/<lib name>_bg.wasm')));
module.exports = bindings;
Browsers (without bundler)
Browsers don't support importing .wasm directly, and we don't know what URL the wasm
will be served from, so we embed the wasm as base64 in the JS file.
We use --target web and add a build step that base64-encodes the .wasm file into
wasm-base64.js.
ES Module Entrypoint (./dist/esm/web.js):
import { initSync } from '../wasm_bindgen/web/<lib name>.js';
import { wasmBase64 } from './wasm-base64.js';
const bytes = Uint8Array.from(atob(wasmBase64), c => c.charCodeAt(0));
initSync(bytes);
export * from '../wasm_bindgen/web/<lib name>.js';
CommonJS Entrypoint (./dist/cjs/web.cjs):
Bundled from the ESM entrypoint using esbuild --format=cjs.
Bundlers (Webpack, Vite, Rollup, etc.)
Bundlers can handle .wasm imports natively, so we use the bundler target's wasm
loading mechanism. However, we still re-export from the web target so that bundler and
slim share the same wasm state.
The shim imports the raw wasm from the --target bundler output (which the bundler
knows how to resolve), then injects the resulting wasm exports into the web target's
bindings via a post-processed __wbg_set_wasm export.
ES Module Entrypoint (./dist/esm/bundler.js):
import { __wbg_set_wasm as __bundler_set_wasm } from '../wasm_bindgen/bundler/<lib name>_bg.js';
import * as wasmExports from '../wasm_bindgen/bundler/<lib name>_bg.wasm';
import { __wbg_set_wasm } from '../wasm_bindgen/web/<lib name>.js';
__bundler_set_wasm(wasmExports);
wasmExports.__wbindgen_start();
__wbg_set_wasm(wasmExports);
export * from '../wasm_bindgen/web/<lib name>.js';
The bundler target's __wbg_set_wasm must be called first because __wbindgen_start()
invokes wasm imports that reference the bundler target's internal wasm variable.
CommonJS Entrypoint (./dist/cjs/bundler.cjs):
Falls back to the base64 web entrypoint since CommonJS can't import .wasm directly.
Cloudflare Workers (workerd)
Cloudflare Workers allow synchronous .wasm imports but still need JS wrapper
initialization.
ES Module Entrypoint (./dist/esm/workerd.js):
import * as exports from '../wasm_bindgen/web/<lib name>.js';
import { initSync } from '../wasm_bindgen/web/<lib name>.js';
import wasmModule from '../wasm_bindgen/web/<lib name>_bg.wasm';
initSync({ module: wasmModule });
export * from '../wasm_bindgen/web/<lib name>.js';
CommonJS Entrypoint (./dist/cjs/workerd.cjs):
Falls back to the base64 web entrypoint.
IIFE (Script Tags)
For <script> tag usage in browsers, we bundle the web entrypoint as an IIFE:
esbuild ./dist/esm/web.js --bundle --format=iife --global-name=MyWasmLib
Usage:
<script src="path/to/my-wasm-lib/dist/iife/index.js"></script>
<script>
MyWasmLib.myFunction();
</script>
The /slim Escape Hatch
Despite our best efforts, some environments won't work with automatic detection. The
/slim export provides manual initialization:
import { initSync, myFunction } from "my-wasm-lib/slim";
import wasmBytes from "my-wasm-lib/wasm";
const bytes = /* fetch or read wasmBytes as appropriate */;
initSync(bytes);
// Now use the exports
myFunction();
This is important for library authors. If you're writing a library that depends on a
wasm-bodge package, import from /slim. This avoids bundling a wasm initialization
strategy that may not work in your consumer's environment.
Because all entry points share the same underlying web target module, a consuming
application can import from the root export (which auto-initializes) and your library's
/slim import will automatically become functional — no coordination needed.
ES Module Entrypoint (./dist/esm/slim.js):
export * from '../wasm_bindgen/web/<lib name>.js';
export { default } from '../wasm_bindgen/web/<lib name>.js';
CommonJS Entrypoint (./dist/cjs/slim.cjs):
module.exports = require('./web-bindings.cjs');
The web target doesn't auto-initialize and exports initSync for manual initialization.
Build Output
wasm-bodge outputs a ./dist directory with this structure:
dist/
esm/
node.js # Node.js ESM (fs.readFileSync + initSync)
web.js # Browser (base64 embedded + initSync)
bundler.js # Bundler shim (__wbg_set_wasm)
workerd.js # Cloudflare Workers (sync wasm import)
slim.js # Manual initialization (re-export only)
wasm-base64.js # Base64-encoded wasm
cjs/
node.cjs # Node.js CommonJS
web.cjs # Browser CommonJS (bundled from ESM)
slim.cjs # Manual init CommonJS
web-bindings.cjs # Shared web target bundle (used by node.cjs + slim.cjs)
wasm-base64.cjs # Base64 CommonJS
iife/
index.js # IIFE bundle for <script> tags
wasm_bindgen/
nodejs/ # wasm-bindgen --target nodejs (used for .d.ts)
web/ # wasm-bindgen --target web (shared by all entry points)
bundler/ # wasm-bindgen --target bundler (wasm loading only)
index.d.ts # TypeScript declarations
<package-name>.wasm # Raw wasm file
The package.json exports are configured as:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"workerd": {
"import": "./dist/esm/workerd.js",
"require": "./dist/cjs/web.cjs"
},
"node": {
"import": "./dist/esm/node.js",
"require": "./dist/cjs/node.cjs"
},
"browser": {
"import": "./dist/esm/bundler.js",
"require": "./dist/cjs/web.cjs"
},
"import": "./dist/esm/web.js",
"require": "./dist/cjs/web.cjs"
},
"./slim": {
"types": "./dist/index.d.ts",
"import": "./dist/esm/slim.js",
"require": "./dist/cjs/slim.cjs"
},
"./wasm": "./dist/<package-name>.wasm",
"./wasm-base64": {
"import": "./dist/esm/wasm-base64.js",
"require": "./dist/cjs/wasm-base64.cjs"
},
"./iife": "./dist/iife/index.js"
}
}
Technical Details
WebAssembly Initialization
Why does wasm-bindgen have different targets? They all handle WebAssembly initialization
differently.
The standard WebAssembly API is imperative:
const wasmBytes = await fetch("my_module.wasm").then(res => res.arrayBuffer());
const wasmModule = await WebAssembly.instantiate(wasmBytes, importObject);
We want users to import our library like any other JS module, so we need to hide this
initialization. The --target web output from wasm-bindgen generates binding functions
that reference a module-level let wasm; variable, and exports an initSync(bytes)
function that compiles and instantiates the wasm module, populating that variable.
wasm-bodge exploits the fact that ES modules are singletons: if two entry points both
import from the same wasm_bindgen/web/<lib name>.js file, they share the same wasm
variable. Each environment-specific entry point just needs to obtain the wasm bytes
through whatever mechanism is available (filesystem read, base64 decode, bundler import,
etc.) and call initSync. Once any entry point has initialized, all other entry points
that import from the same web target module are also functional.
The bundler environment is a special case. Bundlers handle .wasm imports natively via
the --target bundler output, which uses import * as wasm from './<lib>_bg.wasm'.
Rather than duplicating the wasm instantiation logic, the bundler shim lets the bundler
load the wasm through its normal mechanism, then injects the resulting exports into the
web target's bindings via __wbg_set_wasm (a function we add to the web target during
post-processing). This way the bundler handles wasm loading optimally while still sharing
state with /slim.
Fixing Vite's Asset Preprocessor
Vite's asset scanner looks for patterns like:
new URL("./<path>", import.meta.url)
The --target web output contains this pattern, which can cause Vite to bundle multiple copies of the wasm file.
We add a /* @vite-ignore */ comment (undocumented usage, may break) inside the expression:
new /* @vite-ignore */ URL('./my_lib_bg.wasm', import.meta.url);
The comment must be inside the new URL(...) because Vite:
- Uses a regex to match
new URL(...)patterns - Searches each match for
/* @vite-ignore */
Dependencies
~4.5–7MB
~123K SLoC