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:
rustup target add wasm32-wasip1-threads
{
"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:
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 pluggablefsand 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 backAsyncTaskandThreadsafeFunctionon 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:
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:
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:
- a local
my-addon.wasi.cjs; - the separately published
@scope/my-addon-wasm32-wasipackage.
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:
NAPI_RS_FORCE_WASI=error node ./test.cjs
In Node, the generated WASI loader:
- uses
NAPI_RS_ASYNC_WORK_POOL_SIZE, thenUV_THREADPOOL_SIZE, then4for 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.wasmfile 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:
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:
Several recently-published research articles have demonstrated a new class of timing attacks (Meltdown and Spectre) that work on modern CPUs. Our internal experiments confirm that it is possible to use similar techniques from Web content to read private information between different origins.
Serve the document with both headers:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
For example, in Vite:
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:
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:
import { __fs } from './my-addon.wasi-browser.js'
__fs.writeFileSync('/input.txt', 'hello')
With errorEvent: true, observe worker errors before starting work:
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.
Since we finished the `wasm32-wasi-preview1-threads` target in https://github.com/napi-rs/napi-rs/pull/1669. We need to design a release workflow for wasm package. ## Goals - Automatically fallback to a WASM implementation on platforms where pre-compiling native addons is not supported. - In WebContainer, it can be downloaded and installed automatically, without the need for additional configuration. ## Issues There are three potential implementations. 1. Setting up postinstall scripts for every NAPI-RS package. Detect if the platform is supported, and download the wasm package if not supported. 2. Treat the wasm package as a regular platform-specified package, and set the `os` to a special value like `webcontainer`, so that the package managers can download it on WebContainer automatically. 3. Always distributing wasm package and their dependencies with the package. Unfortunately, these implementations have their own issues. The `postinstall` will not run in the `WebContainer` environment, so setup `postinstall` solution is not perfect for `WebContainer`, beside that, I hate postinstall. Platform-specified package solution need the `WebContainer` host to change some behavior about `process.platform`, it may break some other third-party packages and raise more issues. Always distributing wasm package will increase the download size significantly. There is `308.7kb` bundled runtime JavaScript code besides the wasm file itself.
Yarn
For Yarn 4, add wasm32 to .yarnrc.yml:
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
supportedArchitectures:
cpu:
- current
- wasm32
npm
npm supports a target CPU flag in current releases:
npm install --cpu=wasm32
After installation, verify the package rather than assuming the setting was honored:
npm ls @scope/my-addon-wasm32-wasi
Package and publish WASI
WASI uses the same separate-package release flow as native targets:
- Include
wasm32-wasip1-threadsinnapi.targets. - Build and test it in its own CI job.
- Run
napi create-npm-dirs; the generated WASI package getscpu: ["wasm32"], a minimum Node engine compatible with the loader, and its emnapi/runtime dependencies. - Download all target artifacts and run
napi artifacts. This copies the WASM module, Node/browser loaders, and both workers into the WASI package. - Run Node tests with
NAPI_RS_FORCE_WASI=errorand browser tests with cross-origin isolation. - 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:
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.