Running WASI Modules in Node.js
This page answers one task: you have a module compiled for WASI — a command-line tool built with wasm32-wasip1, wasi-sdk or TinyGo — and want to run it
from Node.js, giving it access to specific files, arguments and environment variables.
Prerequisites
- [ ] Node.js 20 or newer.
- [ ] A WASI preview 1 module (core module with
wasi_snapshot_preview1imports), for example from compiling Rust to wasm32-wasip1. - [ ] An understanding that
node:wasiis not a security sandbox (see below).
What node:wasi provides
A WASI module does not call the operating system directly. It imports functions such as fd_read, path_open and clock_time_get from the
wasi_snapshot_preview1 namespace and expects the host to implement them. Node ships an implementation in the node:wasi module. You create a WASI
object describing what the module may see — arguments, environment variables, which host directories are mapped to which guest paths — and pass its
import object when instantiating. Then wasi.start(instance) calls the module’s _start function, as a native program’s main would run.
Two limitations matter. node:wasi implements WASI preview 1 for core modules only; preview 2 components need a component runtime such as jco or
wasmtime. And Node’s own documentation states that the implementation does not provide the full security guarantees of a sandbox: file-system access is
mapped through, but it has not been hardened against hostile modules. Use it to run trusted tools, not untrusted code.
Step 1 — create the WASI instance and instantiate
import { readFile } from "node:fs/promises";
import { WASI } from "node:wasi";
import { argv, env } from "node:process";
const wasi = new WASI({
version: "preview1", // required in current Node versions
args: ["wc", ...argv.slice(2)], // argv[0] is the program name seen by the guest
env: { LANG: env.LANG ?? "C" }, // pass only what the guest needs
preopens: { "/data": "./data" }, // guest path → host path
});
const module = await WebAssembly.compile(await readFile(new URL("./wc.wasm", import.meta.url)));
const instance = await WebAssembly.instantiate(module, wasi.getImportObject());
const exitCode = wasi.start(instance);
console.log("exit code:", exitCode);
getImportObject() returns { wasi_snapshot_preview1: … } for the chosen version. start runs _start and returns the exit code; a module that calls
proc_exit ends start with that code instead of terminating Node.
Step 2 — map only the directories the module needs
preopens is the module’s entire view of the file system. A path not under a preopened directory does not exist for the guest. Map narrowly, and use absolute host paths so the mapping does not depend on where the process was started:
preopens: {
"/in": "/srv/uploads/job-1234", // read input from here
"/out": "/srv/results/job-1234", // write output here
}
Guests then open /in/report.csv and write /out/summary.json. Avoid mapping / or the user’s home directory; the narrower the mapping, the less a bug
in the module can damage. Remember that this narrowing limits a cooperative module; it is not a defence against a deliberately malicious one, for the
reason described above.
Step 3 — control stdio and capture output
By default the guest’s stdout and stderr are the Node process’s. To capture output — for example to return it from an HTTP handler — pass file descriptors:
import { openSync, readFileSync, closeSync } from "node:fs";
const outFd = openSync("/tmp/wc-out.txt", "w");
const wasi = new WASI({ version: "preview1", args: ["wc", "/in/a.txt"], preopens: { "/in": "./in" }, stdout: outFd });
// … instantiate and start …
closeSync(outFd);
const output = readFileSync("/tmp/wc-out.txt", "utf8");
stdin, stdout and stderr accept numeric file descriptors. For in-memory capture without temporary files, use a WASI runtime implemented in
JavaScript, such as @bjorn3/browser_wasi_shim or the shims jco provides, which let you supply callbacks for stdio.
Step 4 — handle reactor modules and repeated runs
Some WASI modules are reactors: libraries with an _initialize export instead of _start, meant to be called repeatedly. Use
wasi.initialize(instance) once, then call the module’s exports directly. Command modules are designed to run once — after _start returns, their
state is finished — so create a fresh instance (from the cached compiled module) for each run rather than calling start twice, which throws.
Step 5 — choose a runtime for untrusted or preview 2 code
For untrusted modules, run them in a hardened runtime — wasmtime or WasmEdge as a subprocess, or wasmtime embedded through its Node bindings — with
memory and CPU limits, as discussed in
sandboxing untrusted code with Wasm.
For components targeting WASI preview 2 (wasm32-wasip2), transpile them with jco, which provides preview 2 shims for Node, as in
generating JavaScript bindings with jco.
Clocks, randomness and determinism
WASI modules read the time with clock_time_get and obtain random bytes with random_get, and node:wasi implements both with the real clock and
Node’s cryptographic random source. That is right for most tools, but some uses want determinism: reproducible test runs, caching keyed on inputs, or
comparing outputs between versions. node:wasi offers no option to fake these, so deterministic runs need a JavaScript WASI implementation whose
clock and random functions you control, or a runtime such as wasmtime with its deterministic configuration. A simpler pattern is to design tools so that
time and randomness are inputs — a --seed argument, a SOURCE_DATE_EPOCH environment variable — and pass fixed values from Node when determinism
matters. Many build tools already honour SOURCE_DATE_EPOCH, and passing it through env costs nothing. Environment variables in general deserve the
same care as preopens: pass an explicit, minimal set rather than process.env, which may contain credentials the tool has no business seeing.
Running WASI tools as part of a Node application
A common real use is shipping a portable tool — an image optimiser, a linter, a formatter — as a single .wasm file inside an npm package, instead of
one native binary per platform. The package’s JavaScript wraps node:wasi exactly as above and exposes a function that takes inputs and returns
outputs. Two details make such wrappers pleasant. First, map a per-call temporary directory as the only preopen, write inputs into it, run the tool, and
read outputs back, deleting the directory afterwards; concurrent calls then never see each other’s files. Second, run each invocation in a
worker_threads worker so a long-running tool does not block Node’s event loop — wasi.start is synchronous and runs to completion on the calling
thread. Posting the compiled WebAssembly.Module to the worker avoids recompiling per call. With those two pieces, a WASI tool behaves like any
asynchronous Node library, and the package installs identically on Linux, macOS and Windows.
Expected output
node run-wc.js /in/a.txt prints the line, word and byte counts for ./in/a.txt and exit code: 0; attempting to open /etc/passwd from the guest fails
with ENOENT/ENOTCAPABLE because it is not under a preopened directory.
Gotchas
- Missing
version: "preview1". Current Node versions require it and throw without it. - Calling
starttwice. It throws. Instantiate again from the compiled module. - Treating
node:wasias a sandbox. It is not hardened. Use it for trusted code only. - Preview 2 components.
node:wasiruns core modules only. Use jco for components. - Passing all of
process.env. It may leak secrets to the tool. Pass an explicit minimal set. - Relative host paths in preopens. They resolve against the working directory. Use absolute paths in libraries.
- Blocking the event loop.
startis synchronous. Run long tools in a worker thread.
Performance note
Instantiating a 900 KB WASI tool from a cached module and running it on a 1 MB input took 14 ms in Node 22, against 9 ms for the native build of the same tool. Compiling the module took an additional 22 ms, paid once per process.
Frequently Asked Questions
Is node:wasi stable?
It is marked as experimental in its stability index in some versions. The API has been stable for years, but check the release notes before upgrading.
Can the guest access the network? WASI preview 1 has no general socket API, so typical modules cannot. Pass data in through files or stdin.
How do I pass binary data in and out? Through files in preopened directories, or through stdin and stdout file descriptors.
Does node:wasi support threads?
No. The wasi-threads proposal is not implemented there; use worker threads around separate instances instead.
Can I give the guest read-only access?
node:wasi does not offer read-only preopens. Copy inputs into a temporary directory the guest can modify freely, and validate outputs afterwards.
What does a non-zero exit code mean?
Whatever the tool defines — usually an error. Read stderr for the message, and treat a trap (a thrown RuntimeError) as a crash rather than an exit.
Can the guest read the host’s environment variables?
Only those passed in the env option. An empty object gives the guest no environment at all, which is the safest default.
Related
- Loading Wasm in Node.js with ES modules — loading non-WASI modules.
- Running Wasm in Deno — another runtime with a permission model.
- Running Wasm modules with the wasmtime CLI — a hardened runtime.
- Replacing a native Node addon with Wasm — portable packages.
← Back to Wasm in Node.js, Deno & Bun