Skip to content

WebAssembly and WASI

INFO

There is a amazing WebAssembly course developed by @Dominic Elm: Learn WebAssembly

NAPI-RS can compile an addon to wasm32-wasip1-threads and generate loaders for Node.js and browsers. The primary use cases are:

  • a portable fallback when no prebuilt native addon matches the host;
  • a browser, StackBlitz, or WebContainer demo of the same Rust API;
  • an explicitly WASI-targeted package.

wasm32-wasip1-threads is currently the supported default. Lower-level wasm32-unknown-unknown and non-threaded WASI targets require you to adapt threading and dependencies yourself and are not generated by this workflow.

WARNING

A WASI build is not automatically equivalent to a native addon. Operating system APIs, native C/C++ dependencies, filesystem behavior, threads, memory limits, and host runtime support can differ. Test the WASI artifact as a separate release target.

Quick start

The easiest starting point is napi new with the WASI target enabled. For an existing project, install the Rust target and add it to napi.targets:

sh
rustup target add wasm32-wasip1-threads
package.json
json
{
  "name": "@scope/my-addon",
  "main": "index.js",
  "types": "index.d.ts",
  "browser": "browser.js",
  "napi": {
    "binaryName": "my-addon",
    "targets": [
      "x86_64-unknown-linux-gnu",
      "aarch64-apple-darwin",
      "x86_64-pc-windows-msvc",
      "wasm32-wasip1-threads"
    ],
    "wasm": {
      "initialMemory": 4000,
      "maximumMemory": 65536,
      "browser": {
        "fs": false,
        "asyncInit": false,
        "buffer": false,
        "errorEvent": true
      }
    }
  }
}

The project must have compatible @emnapi/core and @emnapi/runtime dependencies. The scaffolds created by current napi new include them. If the versions resolved by the project differ from the emnapi version used by the CLI, the build stops with an explicit version-mismatch error; update them together rather than bypassing the check.

Build the WASI target:

sh
napi build --platform --release --target wasm32-wasip1-threads

No cross-compilation flag is needed. Pure-Rust crates use Rust's WASI linker. WASI_SDK_PATH is only needed when C/C++ code in the dependency tree requires a WASI C toolchain.

Generated artifacts

For binaryName: "my-addon", the build creates:

File Purpose
my-addon.wasm32-wasi.wasm Stripped release WASM module
my-addon.wasm32-wasi.debug.wasm Module retaining debug/name information when generation succeeds
my-addon.wasi.cjs Node.js WASI loader
my-addon.wasi-browser.js Browser ESM loader
wasi-worker.mjs Node worker used by emnapi threads
wasi-worker-browser.mjs Browser worker used by emnapi threads
browser.js Package browser entry that re-exports the WASI platform package
index.js and index.d.ts Normal platform-selecting loader and shared types

Keep each loader with its worker and WASM files. Renaming or moving one file without regenerating the loader breaks its relative URLs.

The runtime package: @napi-rs/wasm-runtime

The generated loaders and workers are thin glue on top of the published @napi-rs/wasm-runtime package, which the generated WASI platform package depends on (alongside @emnapi/core and @emnapi/runtime). Its main entry provides the WASI implementation and the emnapi glue:

  • WASI — a WASI preview1 implementation with pluggable fs and preopens;
  • instantiateNapiModuleSync — instantiate the WASM module and bind its napi exports;
  • MessageHandler, createOnMessage, createFsProxy — the worker/fs-proxy machinery used by emnapi threads;
  • emnapiAsyncWorkPlugin, emnapiTSFNPlugin — the emnapi plugins that back AsyncTask and ThreadsafeFunction on WASM.

The @napi-rs/wasm-runtime/fs subpath provides the browser filesystem: memfs() returns a memfs-backed { fs, vol } pair, plus memfsExported and a browser Buffer. This is the "memfs" the browser configuration below refers to — with browser.fs: true, the generated loader wires it into WASI and exports it:

my-addon.wasi-browser.js
ts
import {
  WASI,
  instantiateNapiModuleSync,
  emnapiAsyncWorkPlugin,
  emnapiTSFNPlugin,
} from '@napi-rs/wasm-runtime'
import { memfs, Buffer } from '@napi-rs/wasm-runtime/fs'

// the generated loader re-exports these when `browser.fs` is true
const { fs: __fs, vol: __volume } = memfs()

const __wasi = new WASI({
  version: 'preview1',
  fs: __fs,
  preopens: {
    '/': '/',
  },
})

The browser worker uses the same package from the other side — proxying fs calls back to the main thread:

wasi-worker-browser.mjs
js
import {
  MessageHandler,
  WASI,
  createFsProxy,
  emnapiAsyncWorkPlugin,
  emnapiTSFNPlugin,
} from '@napi-rs/wasm-runtime'
import { memfsExported } from '@napi-rs/wasm-runtime/fs'

const fs = createFsProxy(memfsExported)

Both snippets are condensed from the generated loaders in examples/napi (example.wasi-browser.js and wasi-worker-browser.mjs); regenerate your own loaders with napi build rather than editing them by hand.

How native-to-WASI fallback works

The normal index.js loader first tries the native binary for the current platform. If native loading fails, it tries:

  1. a local my-addon.wasi.cjs;
  2. the separately published @scope/my-addon-wasm32-wasi package.

Use NAPI_RS_FORCE_WASI to test the fallback even on a supported native host. For loaders generated by @napi-rs/cli 3.7 or newer:

Value Behavior
unset or any other string Prefer native; try WASI only after native fails
true Attempt and select WASI even if native loaded; no strict missing-WASI assertion
error Attempt WASI and throw if no local or packaged WASI binding exists

Values such as 1, 0, and false do not force WASI. Use error in tests so a missing artifact cannot silently use the native implementation:

sh
NAPI_RS_FORCE_WASI=error node ./test.cjs

In Node, the generated WASI loader:

  • uses NAPI_RS_ASYNC_WORK_POOL_SIZE, then UV_THREADPOOL_SIZE, then 4 for the emnapi async-work pool;
  • preopens the host filesystem root through Node's WASI implementation;
  • reuses and unreferences worker threads so idle workers do not keep Node alive;
  • prefers the .debug.wasm file when it is present beside the loader.

WARNING

The Node WASI loader preopens the filesystem root. Treat the WASI addon as trusted native application code, not as a security sandbox for untrusted modules or input.

Browser demo

This image transformer uses @napi-rs/image through its WASI browser entry:

ts
import { Transformer } from '@napi-rs/image'

export async function transform() {
  const imageBytes = await fetch(
    'https://images-assets.nasa.gov/image/carina_nebula/carina_nebula~orig.png',
  ).then((res) => res.arrayBuffer())
  const transformer = new Transformer(new Uint8Array(imageBytes))
  return transformer.webp()
}

After installing the WASI package and configuring cross-origin isolation, it can be bundled with Vite or Webpack.

For a local, unpublished build, import ./my-addon.wasi-browser.js directly. For a published package, the root browser entry re-exports the separately published -wasm32-wasi package.

Browser server configuration

The threads target uses shared WebAssembly memory, SharedArrayBuffer, workers, and atomics. Browsers expose SharedArrayBuffer only in a cross-origin-isolated page because of side-channel security mitigations:

Mitigations: Landing new class of timing attacks @Luke Wagner

MDNblog.mozilla.org

preview

Serve the document with both headers:

text
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

For example, in Vite:

vite.config.ts
ts
import { defineConfig } from 'vite'

export default defineConfig({
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  plugins: [
    {
      name: 'configure-preview-response-headers',
      configurePreviewServer(server) {
        server.middlewares.use((_req, res, next) => {
          res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
          res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp')
          next()
        })
      },
    },
  ],
})

Configure the production CDN/server as well; development headers do not carry over to a deployment. Cross-origin scripts, workers, WASM, images, and fonts must also satisfy the selected COEP policy.

Verify the result in the browser:

js
console.log(globalThis.crossOriginIsolated) // true
console.log(typeof SharedArrayBuffer) // 'function'

Browser runtime configuration

The napi.wasm fields control generated browser glue:

Field Default Effect
initialMemory 4000 pages Initial shared memory (a WebAssembly page is 64 KiB)
maximumMemory 65536 pages Maximum shared memory, 4 GiB
browser.fs false Create an in-memory filesystem, preopen /, and export __fs / __volume
browser.asyncInit false Use emnapi's asynchronous instantiation API
browser.buffer false Inject the buffer package's Buffer into the emnapi context
browser.errorEvent false Forward worker failures as napi-rs-worker-error window events

The browser entry fetches the WASM file and therefore uses top-level await even when asyncInit is false. Make sure the bundler/output target supports ESM workers, import.meta.url, and top-level await.

With fs: true, filesystem access is to memfs, not the user's host filesystem:

ts
import { __fs } from './my-addon.wasi-browser.js'

__fs.writeFileSync('/input.txt', 'hello')

With errorEvent: true, observe worker errors before starting work:

ts
window.addEventListener('napi-rs-worker-error', (event) => {
  const { detail } = event as CustomEvent<unknown>
  console.error('NAPI-RS WASI worker failed', detail)
})

Install the WebAssembly package

To avoid increasing every native install, NAPI-RS marks the WASI platform package with cpu: ["wasm32"]. Package managers skip it unless wasm32 is an enabled installation architecture.

WebAssembly package release strategy @Brooooooklyn

napi-rs/napi-rs

preview

Yarn

For Yarn 4, add wasm32 to .yarnrc.yml:

.yarnrc.yml
yaml
supportedArchitectures:
  cpu:
    - current
    - wasm32

Yarn 1 has no equivalent maintained architecture setting. Its --ignore-engines workaround is broad and bypasses other compatibility checks; prefer a current package manager for packages that rely on WASI fallback.

pnpm

pnpm-workspace.yaml
yaml
supportedArchitectures:
  cpu:
    - current
    - wasm32

npm

npm supports a target CPU flag in current releases:

sh
npm install --cpu=wasm32

After installation, verify the package rather than assuming the setting was honored:

sh
npm ls @scope/my-addon-wasm32-wasi

Package and publish WASI

WASI uses the same separate-package release flow as native targets:

  1. Include wasm32-wasip1-threads in napi.targets.
  2. Build and test it in its own CI job.
  3. Run napi create-npm-dirs; the generated WASI package gets cpu: ["wasm32"], a minimum Node engine compatible with the loader, and its emnapi/runtime dependencies.
  4. Download all target artifacts and run napi artifacts. This copies the WASM module, Node/browser loaders, and both workers into the WASI package.
  5. Run Node tests with NAPI_RS_FORCE_WASI=error and browser tests with cross-origin isolation.
  6. Follow the normal release guide.

Inspect the final npm pack --dry-run output for the root package and the WASI platform package. The latter must contain the .wasm, .wasi.cjs, .wasi-browser.js, and worker files.

Build C/C++ dependencies

If the dependency tree compiles C or C++, install wasi-sdk and set WASI_SDK_PATH to the extracted SDK root:

sh
export WASI_SDK_PATH=/absolute/path/to/wasi-sdk
test -x "$WASI_SDK_PATH/bin/clang"
test -x "$WASI_SDK_PATH/bin/wasm-ld"
napi build --platform --release --target wasm32-wasip1-threads

The CLI always points Cargo's WASI linker variables at $WASI_SDK_PATH/bin/wasm-ld. For the C/C++ toolchain variables (TARGET_CC, TARGET_CXX, TARGET_AR, TARGET_RANLIB, TARGET_CFLAGS, TARGET_CXXFLAGS, and TARGET_LDFLAGS), an existing environment value wins; the CLI fills only unset values. A dependency may still be incompatible when it assumes POSIX APIs that WASI does not provide; a successful native build is not evidence that the same dependency supports WASI.

Runtime support matrix

Host Status and constraints
Node.js Generated .wasi.cjs path; platform package requires Node 14 or newer and uses Node WASI/worker APIs
Cross-origin-isolated browser Generated ESM + module worker path; requires shared memory, top-level await, and correct asset serving
Browser without isolation Unsupported for the threads target because shared memory is unavailable
Bun and Deno Do not claim support without a runtime test; Node-compatible WASI loading currently has an open incompatibility report
Edge/serverless isolates Host-specific; many do not expose Node WASI, filesystem, or worker APIs expected by the generated loader

The Bun/Deno limitation is tracked in napi-rs#2965. It is a runtime compatibility gap, not something package installation instructions can fix.

Test checklist

Before publishing a WASI target, test:

  • one plain Node import with NAPI_RS_FORCE_WASI=error;
  • errors and rejected async operations, not only successful functions;
  • process exit after threads and async work complete;
  • the final separately packed/installed WASI npm package;
  • a production-style browser server with COOP/COEP headers;
  • worker startup, multiple concurrent calls, and worker error forwarding;
  • browser memory growth and out-of-memory behavior under representative input;
  • memfs behavior when filesystem APIs are part of the public contract;
  • every non-Node runtime you list as supported.

For failure signatures and exact probes, see Troubleshooting: WASI failures.

Last updated on
LongYinan