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.
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
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,console,process,tty,net,http,async_hooks(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—createHashandcreateHmacovermd5,sha1,sha224,sha256,sha384andsha512(also spelledSHA-256,RSA-SHA256),Hash#copy,pbkdf2/pbkdf2Sync,createSecretKey,hash(),randomBytes,randomInt,randomUUID,randomFillSync,timingSafeEqual,getHashes, andwebcryptowithsubtle.digest. The digests are compiled C (runtimes/nodejs/hash.c), not JavaScript: under an interpreting host such as wasmrun, hashing 64 KiB takes about a fifth of a second where the old pure-JS digests took minutes. No crypto library is linked inasync_hooks—AsyncLocalStorage(run,getStore,exit,enterWith,disable,bind,snapshot) andAsyncResource(runInAsyncScope,bind). A store set withrun()survivesawait, promise chains, timers and socket events started inside it, including an await on a promise some other code settles; a wasmhub engine patch carries the context through every promise reaction.createHookis present, and its callbacks never fireurl— 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 divergeconsole— the global console, formatted withutil.formatthe way Node's is:log/info/debugto stdout,error/warn/traceto stderr, plusdir,assert,count/countReset,time/timeLog/timeEnd,group/groupEndindentation,table, and theConsoleclass. Methods are bound, soconst { log } = consoleworks, and output goes throughprocess.stdout/process.stderr, so replacing theirwritecaptures itnet—createServer,Server(listen/close/address/getConnections,connection/listening/close/errorevents),Socketas a duplex stream (data/end/error/close,write,end,destroy,pipe,for await, an idlesetTimeout), andisIP/isIPv4/isIPv6.connect/createConnection/Socket#connectwork in the-netbuild; elsewhere they throwERR_NOT_SUPPORTED. 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. In the-netbuild, the client half too:request/get,ClientRequest(headers,write/end,setTimeout,abort/destroy,signal), with a body of known length sent withContent-Lengthand a streamed one chunked, as Node does; responses delimited by length, by chunks, or by the connection closing, and interim1xxresponses as'information'events. One connection per request (Connection: close);Agentis accepted and does not pool. Elsewhererequest/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(anEventEmitterwithargv,env,cwd(),exit(),exitCode,platform,stdout.write,stderr.write,stdin,nextTick,hrtimeandhrtime.bigint,uptime(),memoryUsage()),global,console - Exit codes: the run fails the way Node's does, which is how a caller tells a failed run from a successful one. An uncaught error, thrown synchronously or from a timer,
nextTickor I/O callback, printsName: messageand its frames and exits 1. So does an unhandled promise rejection, once the job queue drains without a handler attaching.process.exit(code)ends the run with that code, flushing stdout and stderr first, and a run that simply finishes exits withprocess.exitCode. An unreadable entry file and a failingnode:testrun exit non-zero too - Process events:
'beforeExit'(each time the loop runs dry; a listener that schedules more work keeps the run going, as in Node),'exit'(with the exit code; a listener can changeprocess.exitCode),'uncaughtException'and'unhandledRejection', whose listeners take the error over and let the run carry on, and'rejectionHandled'. The engine side is a wasmhub patch to quickjs-libc (os.setHostHooks), because stock QuickJS prints a timer's exception and carries on, ignores unhandled rejections, and exits 0 whatever happened - 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),Headers,Request,Response(withjson(),redirect(),clone()),AbortController/AbortSignal(abort,timeout,any),EventandEventTarget, andfetch. In the-netbuildfetchworks overhttp:, follows redirects, honourssignal, and fails asTypeError('fetch failed')with the reason ascause; elsewhere it rejects withERR_NETWORK_UNSUPPORTEDrather than a bareReferenceError
Limitations
- Outbound connections need the
-netbuild and a host that provides its two extensions; the portable build failsnet.connect,http.requestandfetchwith a clear error rather than hanging. Either way there is no TLS:httpsandtlsthrow, andfetchrefuseshttps:URLs fetchbuffers the whole response before it resolves, so a response that never ends (server-sent events) never resolves either.Response#bodyis a one-chunk stream over that buffer, and there is noBloborFormData- 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 unavailablecryptohas digests, HMAC and PBKDF2 only. There is nocreateCipheriv, no key generation, noscrypt, 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
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):
- 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. #specifier — resolved through theimportsmap of the nearestpackage.jsonabove the requiring file, with the same conditions and patterns asexports. A target may also be a bare specifier, which is how a package maps#async_hooksto 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.
- 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 - 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) - Cross-compiles all sources with WASI SDK clang (
wasm32-wasitarget),wasi_sockets.ctwice: once as is and once with-DWASMHUB_OUTBOUND - Links both builds with an 8 MB C stack (required for QuickJS's parser depth)
- Optimizes each with
wasm-opt -O3, and writes both manifest entries,20stayinglatest
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] Outbound
net, thehttpclient andfetch(the-netbuild) - [x] Native digests,
async_hooks,process.memoryUsage/uptime/'beforeExit', packageimports - [ ] TLS, so
httpsandfetchtohttps:work - [x] Event-loop driven
setTimeout/setIntervalexposed as globals (plussetImmediate,queueMicrotask, deferredprocess.nextTick)