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,os,buffer,events,util,assert,stream(also under thenode:prefix) 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()Buffer— 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 - Globals:
process(argv,env,cwd(),exit(),platform,stdout.write,stderr.write,nextTick,hrtime),global,console - 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.
Limitations
- No networking (WASI Preview 1 has no socket API)
- No worker threads
- No native addons (.node files)
- Built-in modules cover common APIs but not everything —
crypto,http/https/net(no sockets under WASI),url,querystring,zlib,child_process,worker_threadsare not implemented;fsis synchronous-only (no callback/promise API, nofs.createReadStream) Buffercovers the common API but not everything (e.g.swap16/swap32,BigInt64accessors);TextDecoderis utf-8 onlystreamis a pragmatic subset (no full backpressure/highWaterMark semantics, no async iterators);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
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>and applying the same file/dir rules.
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 - [ ]
crypto,url,querystring,zlibbuilt-ins - [x] Event-loop driven
setTimeout/setIntervalexposed as globals (plussetImmediate,queueMicrotask, deferredprocess.nextTick)