#javascript #wasm-bindgen #npm

app wasm-bodge

A tool that takes wasm-bindgen output and wraps it for all JavaScript runtimes

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

MIT license

70KB
993 lines

wasm-bodge

CI Crates.io License: MIT

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

# 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-unknown target (rustup target add wasm32-unknown-unknown)
  • wasm-bindgen-cli (cargo install wasm-bindgen-cli)
  • wasm-opt (cargo install wasm-opt) — disable with --no-wasm-opt
  • esbuild (npm install -g esbuild or 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:

  1. Write code in Rust annotated with the wasm-bindgen macros
  2. Compile the Rust code to WebAssembly using cargo build --target wasm32-unknown-unknown
  3. Use the wasm-bindgen CLI 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:

  1. Bundle all the different wasm-bindgen strategies into the package
  2. Use subpath exports to choose the right strategy for the current environment
  3. 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:

  1. Uses a regex to match new URL(...) patterns
  2. Searches each match for /* @vite-ignore */

Dependencies

~4.5–7MB
~123K SLoC