Node.js runtime

JavaScript runtime for WASM, built with QuickJS targeting WASI

Kumar Anirudha

Table of content
  1. Status
  2. At a glance
  3. Capabilities
  4. Limitations
  5. Networking
  6. Outbound: the -net build
  7. Install
  8. Usage examples
  9. CommonJS require()
  10. exports maps
  11. Use from Rust
  12. Building from source
  13. Technical notes
  14. Roadmap

Status

Available — nodejs-20.wasm is fully working. Built with QuickJS compiled to WASM via the WASI SDK.

It ships in two builds from the same source:

Build Manifest key Networking Runs on
nodejs-20.wasm 20 (latest) Serves connections a host hands in Any WASI Preview 1 host
nodejs-20-net.wasm 20-net Also opens them: net.connect, http.request, fetch Hosts with wasmrun's sock_open/sock_connect extensions

The -net build imports two calls Preview 1 does not have, so a host without them (plain wasmtime, say) cannot instantiate it. latest stays the portable build. See Networking.

At a glance

Engine QuickJS 2024-01-13 (ES2020)
Node.js compat v20.x API surface
Binary size ~1.3 MB (optimized), either build
Target wasm32-wasi (WASI Preview 1)
License MIT
Source https://bellard.org/quickjs/

Capabilities

Limitations

Networking

Both builds can serve connections. Only nodejs-20-net.wasm can open them.

WASI Preview 1 standardises sock_accept, sock_recv, sock_send and sock_shutdown, but nothing that creates a socket: sock_open, sock_bind, sock_connect and sock_listen are WASIX-style extensions. A WebAssembly import is not optional -- declaring one obliges every host to supply it or the module fails to instantiate -- so this runtime imports only the standard four and keeps running on any host it ran on before.

That means the listening socket has to come from outside, already bound. The host binds the port and passes the descriptor in through the environment:

Variable Meaning
WASMHUB_LISTEN_FD descriptor of a bound, listening socket
WASMHUB_LISTEN_ADDR optional host:port it is bound to, so server.address() can answer truthfully

wasmtime's listen-socket option already works this way, up to wasmtime 46: releases 14 to 46 spell it -S tcplisten and offer it only on the legacy Preview 1 implementation (-S preview2=n), older ones spell it --tcplisten. wasmtime 47 removed the legacy implementation, and with it any way to hand a listening socket to a Preview 1 module, so a newer wasmtime cannot run the server half at all:

wasmtime run -S preview2=n -S tcplisten=127.0.0.1:8080 \
  --env WASMHUB_LISTEN_FD=3 --env WASMHUB_LISTEN_ADDR=127.0.0.1:8080 \
  nodejs-20.wasm eval "$(cat app/server.js)"

The server goes in through eval rather than run ./app/server.js because wasmtime puts the socket on fd 3, ahead of any --dir, and wasi-libc stops looking for preopened directories at the first descriptor that is not one. A --dir given alongside tcplisten is invisible to the runtime, so run cannot open the file. A host that hands the socket in after its preopens, and sets WASMHUB_LISTEN_FD to match, has no such limit.

// app/server.js
const http = require('node:http');

http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ method: req.method, url: req.url }));
}).listen(8080, () => console.log('serving'));

server.listen(port) does not bind anything: the port is advertised for address(), and the server serves the socket it was given. A second concurrent listen() fails with EADDRINUSE, since one process is handed one socket.

Sockets are non-blocking, and a self-scheduling timer drains them. It backs off while idle and stops arming once the last socket closes, so a script that served a request still exits on its own rather than hanging the run.

When the host passes no descriptor, listen() emits an ERR_SOCKET_NO_LISTENER error naming the variable to set. When a build has no socket bindings at all, net and http take the same shape as zlib: present, and throwing a clear ERR_NOT_SUPPORTED when called, so a package that merely requires one keeps loading.

Outbound: the -net build

nodejs-20-net.wasm is the same runtime with two more imports, both wasmrun extensions under the wasi_snapshot_preview1 module:

Call Signature Meaning
sock_open (family, socktype, protocol, *fd_out) -> errno a stream socket (family 0 = unspecified, socktype 1 = stream)
sock_connect (fd, host_ptr, host_len, port) -> errno connect it to host:port, blocking until the host answers

The destination is a host string, not a packed address, so the host's egress policy sees the name the program asked for and resolves it itself. A destination the policy refuses comes back as EACCES, which net reports as an 'error' event saying the host's network policy refused it; the host keeps the detailed reason (wasmrun prints it).

# wasmrun exec, allowing one destination
wasmrun exec --allow-net "api.example.com:80" nodejs-20-net.wasm eval \
  "fetch('http://api.example.com/').then(r => r.text()).then(console.log)"

Under wasmrun's agent mode the policy comes from --allow-net or a tenant's [tenants.network] table, and in OS mode from [os.network] in the project's wasmrun.toml. With no policy at all, nothing is allowed.

The connect runs from a timer, never inside the connect() call, so listeners attached after it returns see the outcome, and writes made before it lands are queued. After that the socket is non-blocking and the same poll loop drives it.

Install

wasmhub get nodejs 20

Usage examples

The runtime is a plain WASI module, so any WASI host runs it. The commands that need no files work the same everywhere:

# Print version info
wasmrun exec nodejs-20.wasm version

# Evaluate JavaScript
wasmrun exec nodejs-20.wasm eval "1 + 1"
# → 2

# Complex expressions
wasmrun exec nodejs-20.wasm eval "[1,2,3].map(x => x * x).join(',')"
# → 1,4,9

# Echo arguments
wasmrun exec nodejs-20.wasm echo hello world
# → hello world

# Print the environment the host passed
wasmrun exec --env GREETING=hi nodejs-20.wasm env
# → GREETING=hi

# Run a file from a preopened directory
wasmrun exec --dir ./scripts:/app nodejs-20.wasm run /app/app.js

run <file> needs the host to give the program a directory to read the file from, which --dir does (wasmrun 1.0 and later). wasmrun exec exits with the program's exit code. wasmrun's agent mode fetches this runtime itself and runs each program in a session with its own filesystem:

wasmrun agent &
SID=$(curl -s -X POST localhost:8430/api/v1/sessions | jq -r .session_id)
curl -s -X POST localhost:8430/api/v1/sessions/$SID/exec \
  -H 'Content-Type: application/json' \
  -d '{"files": {"app.js": "console.log(require(\"path\").basename(__filename))"}, "entry": "app.js"}'
# → {"stdout":"app.js\n","stderr":"","exit_code":0,...}

Agent mode reports the program's exit code as exit_code.

Or with any host that preopens a directory, such as wasmtime:

wasmtime run --dir . nodejs-20.wasm run ./scripts/app.js

CommonJS require()

A worked example is in tests/runtimes/nodejs/fixtures/:

// app.js
const path = require("path");
const { square } = require("./math");
const config = require("./config.json");
const greet = require("greet");          // resolves via node_modules/greet/package.json

console.log(square(4), config.name, greet("world"));
console.log("entry:", path.basename(__filename));
console.log("require.main===module:", require.main === module);

Resolution rules (mirroring Node.js for the supported subset):

  1. Built-in — path, fs, os, node:path, node:fs, node:os.
  2. Relative / absolute — ./x, ../x, /abs/x. Tries x, x.js, x.json, x/package.json main field, x/index.js, x/index.json.
  3. Bare specifier — walks up from the requiring file's directory looking for node_modules/<name>. A package that declares exports is resolved through it; one that does not falls back to the main/index rules above.
  4. # specifier — resolved through the imports map of the nearest package.json above the requiring file, with the same conditions and patterns as exports. A target may also be a bare specifier, which is how a package maps #async_hooks to the builtin on Node and to a stub elsewhere (p-limit does exactly this).

exports maps

Most packages published since 2021 declare their entry points in exports rather than main, and a package with exports is also sealed: only what it lists is reachable.

Modules are evaluated inside new Function('exports','require','module','__filename','__dirname', src), the same wrapper Node.js uses. Cached in require.cache keyed by resolved filename.

Use from Rust

use wasmhub::{RuntimeLoader, Language};

let loader = RuntimeLoader::new()?;
let nodejs = loader.get_runtime(Language::NodeJs, "20").await?;
// Pass nodejs.path to your WASM runtime (wasmtime, wasmrun, etc.)

Building from source

just build-nodejs

Requires Docker (runs inside wasmhub-builder). The build:

  1. Downloads QuickJS 2024-01-13 source
  2. Compiles main.js to C bytecode via native qjsc
  3. Applies the engine patches in scripts/patch-nodejs.sh (entropy, script filenames, the socket and host bindings, the process lifecycle hooks, and the async context)
  4. Cross-compiles all sources with WASI SDK clang (wasm32-wasi target), wasi_sockets.c twice: once as is and once with -DWASMHUB_OUTBOUND
  5. Links both builds with an 8 MB C stack (required for QuickJS's parser depth)
  6. Optimizes each with wasm-opt -O3, and writes both manifest entries, 20 staying latest

Technical notes

The runtime is built from QuickJS rather than full Node.js because Node.js (V8 + libuv) cannot currently compile to WASM/WASI. QuickJS is a complete ES2020 engine in ~210 KB of C, and compiles cleanly with the WASI SDK.

Three non-obvious build issues were debugged and fixed:

Roadmap