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_preview1 imports), for example from compiling Rust to wasm32-wasip1.
  • [ ] An understanding that node:wasi is 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.

A WASI module running under node:wasi The WASI module calls preview 1 functions such as fd_write and path_open. node:wasi implements them by mapping guest file descriptors and preopened directories onto Node's file-system APIs. Only explicitly preopened directories, arguments and environment variables are visible. WASI module _start, fd_write, path_open, args_get … node:wasi wasi_snapshot_preview1 imports configuration args, env, preopens: { '/data': './data' } Node.js fs and process the real file system and stdio not a hardened sandbox trusted modules only

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.

Running a WASI tool from Node and collecting its result Node configures arguments, environment, preopened directories and stdio file descriptors, instantiates the module with the WASI imports, and calls start. The guest reads inputs from preopened paths, writes to stdout, and exits with a code that start returns. configure WASI args, env, preopens, stdio instantiate getImportObject() wasi. start(instance) runs _start guest does I/O only via preopens exit code returned 0 = success

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 start twice. It throws. Instantiate again from the compiled module.
  • Treating node:wasi as a sandbox. It is not hardened. Use it for trusted code only.
  • Preview 2 components. node:wasi runs 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. start is 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.

Running a word-count tool on a 1 MB file Milliseconds to process a one-megabyte text file with the native build, the WASI build under node:wasi with a cached compiled module, and the WASI build including compilation. ms per run native binary 9 ms WASI, cached module 14 ms WASI, compile + run 36 ms

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.

← Back to Wasm in Node.js, Deno & Bun