Node.js runtime
JavaScript runtime for WASM, built with QuickJS targeting WASI
Kumar Anirudha
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
eval— evaluate JavaScript expressions (including complex ES2020)run— execute a.jsfile with CommonJSrequire()(requires WASI filesystem pre-open)echo— print arguments to stdoutenv— print environment variablesversion— print runtime info- Environment variables via WASI
- Command-line args
- Standard I/O (stdin/stdout/stderr)
- Filesystem read/write (via WASI pre-open)
- ES2020: async/await, optional chaining, nullish coalescing, BigInt
- CommonJS
require()with relative paths (./foo), absolute paths (/abs), JSON imports,package.jsonmainresolution, andnode_moduleslookup walking up the directory tree module.exports,exports,__filename,__dirname,require.cache,require.resolve,require.main- Built-in modules:
path,fs,fs/promises,os,buffer,events,util,assert,stream,crypto,url,querystring,string_decoder,timers,timers/promises,process,tty,net,http(all also under thenode:prefix), plusnode:test, which is prefix-only as it is in Node events— fullEventEmitter(on/once/off/prependListener/removeAllListeners/emit/listeners/listenerCount/eventNames, theerrorspecial-case,newListener/removeListenermeta-events, staticEventEmitter.once)util—format,inspect,inherits,promisify,callbackify,deprecate,debuglog,isDeepStrictEqual,types.*,TextEncoder/TextDecoderassert—ok/equal/strictEqual/deepStrictEqual/throws/rejects/ifError/match/… plusassert.strictandAssertionErrorstream—Readable(incl.Readable.from),Writable,Duplex,Transform,PassThrough,pipeline,finished,.pipe()crypto—createHashandcreateHmac(sha256,sha1,md5),randomBytes,randomInt,randomUUID,randomFillSync,timingSafeEqual,getHashes, andwebcrypto. Digests are implemented in the runtime itself, so no crypto library is linked inurl— the WHATWGURL/URLSearchParamsclasses plus the legacyparse/format/resolveAPI,fileURLToPath/pathToFileURL, anddomainToASCII/domainToUnicodequerystring—parse/stringify/escape/unescapewith thedecode/encodealiases. Parsed objects have a null prototype, so a__proto__key in a query string cannot pollute anythingstring_decoder—StringDecoderforutf8,base64,hex,latin1, andutf16le, holding back partial sequences so a multi-byte character is never split across chunksfs/promises— the promise API over the same synchronous implementations, also reachable asfs.promisesnode:test— the built-in test runner:test/itwith sync, async, promise and callback bodies,describe/suitenesting,before/after/beforeEach/afterEach,skip/todoas methods, options or context calls, TAP 13 output shaped like Node's, and a non-zero exit code when anything failsprocess— the same object as theprocessglobal, sorequire('node:process')and the global cannot divergenet— inbound only:createServer,Server(listen/close/address/getConnections,connection/listening/close/errorevents),Socketas a duplex stream (data/end/error/close,write,end,destroy,pipe,for await), andisIP/isIPv4/isIPv6.connect/createConnectionthrowERR_NOT_SUPPORTED, because Preview 1 has no way to open a socket. See Networkinghttp— the server half overnet:createServer,IncomingMessage(method, url, lowercasedheaders,rawHeaders, body as a readable stream),ServerResponse(writeHead,setHeader/getHeader/removeHeader,write,end),STATUS_CODESandMETHODS. Request bodies are decoded byContent-LengthorTransfer-Encoding: chunked; responses are chunked automatically when their length is not known in advance, and HTTP/1.1 keep-alive is honoured when the response can delimit itself.request/getthrowERR_NOT_SUPPORTEDtty—isatty(), which answersfalse: nothing in the sandbox is a terminal.ReadStream/WriteStreamthrowERR_NOT_SUPPORTEDrather than pretending to open a devicetimers/timers/promises— the callback forms plus promisesetTimeout/setImmediate,setIntervalas an async generator, andscheduler.waitBuffer— fullUint8Array-subclass implementation:from/alloc/allocUnsafe/concat/isBuffer/byteLength/compare,toString/write/slice/copy/fill/equals/indexOf/includes, and fixed-width int/float accessors (readUInt32BE,writeDoubleLE, …). Encodings:utf8,hex,base64,base64url,latin1,ascii,utf16leTextEncoder/TextDecoder(utf-8), plusatob/btoaglobals- Binary file I/O:
fs.readFileSync(path)returns aBuffer(or a string when an encoding is given);fs.writeFileSync/appendFileSyncaccept aBuffer/Uint8Arrayor string - Standard input:
process.stdinis a readable stream over fd 0 (data/endevents,read(),pipe(),setEncoding, andfor await), andfs.readFileSync(0)/fs.readFileSync('/dev/stdin')read the same bytes. fd 0 can only be drained once, so the input is read on first use and shared between them; no input at all is an immediate end of file rather than a hang - Globals:
process(argv,env,cwd(),exit(),platform,stdout.write,stderr.write,stdin,nextTick,hrtime),global,console - Exit codes:
process.exit(code)ends the run with that code, flushing stdout and stderr first. An uncaught error, an unreadable entry file, and a failingnode:testrun all exit non-zero, which is how a caller tells a failed run from a successful one - Stack traces: frames name the module they came from and report that file's own line numbers, so
at inner (/app/lib/boom.js:4)points at real source. Modules are compiled throughstd.evalScriptwith afilename(a wasmhub patch adds the option) rather thannew Function, which QuickJS would name<input> - Timers & event loop:
setTimeout,clearTimeout,setInterval,clearInterval,setImmediate,clearImmediate,queueMicrotask, and a deferredprocess.nextTick— driven by the QuickJS event loop.async/await, Promise chains, and timer callbacks resolve after the entry script returns and the loop drains. - Web platform globals:
URL/URLSearchParams(WHATWG parsing, relative resolution against a base,searchParamskept in sync with the URL),crypto.getRandomValues/crypto.randomUUID(entropy from the WASIrandom_getsyscall viaos.getentropy),structuredClone(cycles,Map/Set/Date/RegExp/ArrayBuffer/TypedArrays; functions and symbols throwDataCloneError), andfetch— defined but always rejecting with a clear network-unsupported error (code: 'ERR_NETWORK_UNSUPPORTED') rather than a bareReferenceError
Limitations
- Inbound networking only. A server works when the host hands in a listening socket; nothing in the sandbox can open an outbound connection, so
net.connect,http.requestandfetchall fail with a clear error rather than hanging - No worker threads (
worker_threadsreportsisMainThread: true; constructing aWorkerthrows) - No native addons (.node files)
- Built-in modules cover common APIs but not everything.
zlib,child_process,worker_threads,https,dgramandtlsare present but throwERR_NOT_SUPPORTEDwhen used, with a message naming the constraint: a package that merely imports one keeps working, and one that calls it gets a clear error instead of "Cannot find module" fsis synchronous under the hood.fs/promisesandfs.promiseswrap the same calls, so they resolve immediately rather than doing real async I/O, andfs.createReadStreamis unavailablecryptoofferssha256/sha1/md5only; other digests throw an error naming the ones that exist. There is nocreateCipheriv, no key generation, and no certificate handlingBuffercovers the common API but not everything (e.g.swap16/swap32,BigInt64accessors);TextDecoderis utf-8 onlystreamis a pragmatic subset (no full backpressure/highWaterMark semantics), thoughReadabledoes supportfor await;util.inspectoutput approximates Node's but is not byte-identical- Timers return a numeric id (browser-style), not a Node
Timeoutobject —.ref()/.unref()are unavailable.process.nextTickis a microtask (no separate higher-priority queue), and the trailing-args forms are supported
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):
- Built-in —
path,fs,os,node:path,node:fs,node:os. - Relative / absolute —
./x,../x,/abs/x. Triesx,x.js,x.json,x/package.jsonmainfield,x/index.js,x/index.json. - Bare specifier — walks up from the requiring file's directory looking for
node_modules/<name>. A package that declaresexportsis resolved through it; one that does not falls back to themain/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.
- Conditions are matched in the order the package wrote them, which is what the specification says and what resolves
{"import": …, "require": …}correctly. The set isrequire,nodeanddefault;importis not in it, because the module wrapper is CommonJS - The
"."root key, subpaths (require('pkg/sub')), and subpath patterns ("./*": "./dist/*.js") all resolve, with the longest matching prefix winning - A
nulltarget blocks the subpath withERR_PACKAGE_PATH_NOT_EXPORTED, as does a subpath the map does not cover, even when the file exists - If no condition matches at the package root, resolution falls back to
main. That is what an ES module package looks like once it has been lowered to CommonJS in place, which is how wasmrun ships them into the sandbox
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:
- Downloads QuickJS 2024-01-13 source
- Compiles
main.jsto C bytecode via nativeqjsc - Cross-compiles all sources with WASI SDK clang (
wasm32-wasitarget) - Links with 8 MB C stack (required for QuickJS's parser depth)
- 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:
- C stack overflow — QuickJS's parser uses deep call frames. The default WASM C stack (64 KB) is too small; fixed with
-Wl,-z,stack-size=8388608. -fbignumincompatibility —qjsc -fbignumemits BigNum intrinsics that fail in WASI; removed.- Module linking phase — QuickJS runs the module body during linking before C module
init_funcs run, sostd.outisundefinedat that point; guarded withif (std.out).
Roadmap
- [ ] Node.js v22 and v24 builds
- [x]
node:fsshim via WASI filesystem APIs (minimal synchronous subset) - [x] CommonJS
require()support — implemented inmain.js(no bundler pre-pass needed) - [x]
Bufferand binaryfsreads —Uint8Array-subclassBuffer,TextEncoder/TextDecoder, andfs.readFileSync→Buffer - [x]
events,util,assert,streambuilt-ins - [x]
crypto,url,querystring,string_decoderbuilt-ins (plusfs/promisesandtimers/promises) - [ ]
zlib,child_process,worker_threadsbeyond the present-but-throwing stubs - [x]
node:testrunner,exports-map resolution, readableprocess.stdin - [ ] Native ESM in the module wrapper (
importis lowered to CommonJS before it reaches the runtime today) - [x] Event-loop driven
setTimeout/setIntervalexposed as globals (plussetImmediate,queueMicrotask, deferredprocess.nextTick)