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. Install
  7. Usage examples
  8. CommonJS require()
  9. exports maps
  10. Use from Rust
  11. Building from source
  12. Technical notes
  13. Roadmap

Status

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

At a glance

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

Capabilities

Limitations

Networking

The runtime can serve connections. It cannot 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 run --tcplisten already works this way:

wasmtime run --tcplisten 127.0.0.1:8080 \
  --env WASMHUB_LISTEN_FD=3 --env WASMHUB_LISTEN_ADDR=127.0.0.1:8080 \
  --dir ./app nodejs-20.wasm -- run ./app/server.js
// 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.

Install

wasmhub get nodejs 20

Usage examples

# 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 env
wasmrun exec nodejs-20.wasm -- env

# Run a JS file (requires --dir mount)
wasmrun exec --dir /path/to/scripts nodejs-20.wasm -- run /path/to/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.

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. Cross-compiles all sources with WASI SDK clang (wasm32-wasi target)
  4. Links with 8 MB C stack (required for QuickJS's parser depth)
  5. Optimizes with wasm-opt -O3

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