This is what makes sxn usable as a Node alternative: CommonJS, the node:
builtins, and native-addon loading. It's the half of the runtime that a
mobile or embedded build can drop entirely without losing anything on the
spec/RUNTIME.md side — spec/NATIVE.md explains why that split exists for
the native-code case, and the same reasoning applies to this whole layer:
Node emulation is dead weight to an embedder with no Node surface of its own.
sxn decides module-or-CommonJS the way Node does: .mjs/.mts are always
modules, .cjs is always CommonJS, and a plain .js file or an extensionless
one (every CLI an npm package ships) follows the nearest package.json's
"type", defaulting to CommonJS. A #!/usr/bin/env node shebang line is
stripped before evaluation, the way Node strips it, so those extensionless
CLIs run directly: sxn ./node_modules/.bin/whatever works.
A CommonJS module gets require, module, exports, __filename, and
__dirname, wrapped exactly the way Node wraps it. require.resolve(spec)
exists on every require. require("node:module").createRequire(path)
returns a require anchored at that path's directory rather than the caller's
— get this wrong and it resolves the wrong package's siblings.
Bare specifiers resolve through node_modules, walking up from the importing
file — plain packages, scoped packages (@scope/name), and subpath imports.
A package's exports field is read for the . subpath, checking conditions
in the order import, module, default, require, node, then falling
back to module, then main. Circular require sees the same partially
filled exports a cycle sees in Node, rather than recursing forever.
require("./thing.node") and the process.dlopen it calls under the hood
both work, through a Node-API implementation built on QuickJS — full detail,
including what's implemented and what isn't, is spec/NATIVE.md. It's
listed here because it's the other reason this layer exists as a separate,
droppable piece: on iOS you can't dlopen code that arrived after the app
was signed, so this half of native-code support is inherently a
desktop-and-server capability, unlike Sxn.ffi.
24 of the ~37 Node ships. What each one covers, briefly, and where it's worth knowing the gap:
| Module | Covers |
|---|---|
assert, assert/strict
|
The standard assertion functions. |
buffer |
See below — this one gets its own section. |
crypto |
Hash, Hmac (standard construction over the digest primitive), randomBytes, randomUUID, timingSafeEqual. |
events |
EventEmitter, including the mixin pattern (Object.assign(fn, EventEmitter.prototype)) Express uses, where _events is created lazily on first on()/emit() rather than in a constructor that never runs. |
fs, fs/promises
|
File I/O, sync and promise-based. |
http |
createServer, IncomingMessage, ServerResponse, ClientRequest, STATUS_CODES, METHODS. The request body defers behind _read rather than pushing eagerly, because a body-parser attaches its listener after the handler returns — push first and it gets nothing. |
module |
The Module constructor (what require('module').prototype expects), createRequire, builtinModules, isBuiltin. |
net |
isIP/isIPv4/isIPv6, including IPv6 zone-index stripping (fe80::1%eth0). Socket/Server are not implemented and throw. |
os, path, querystring, url, util
|
The usual surface — inspect, format, promisify, deepEqual, POSIX/Win32 path handling, and so on. |
perf_hooks |
Enough for timing code that reads performance.now-equivalent values. |
process |
platform, arch, version/versions, stdout/stderr/stdin, hrtime, emitWarning, uptime, pid, env, argv, dlopen. |
stream, stream/promises
|
Readable/Writable/Duplex/Transform/PassThrough, pipeline, finished. The module export is the Stream function itself (some packages require('stream') and call it as a constructor), and Readable supports real pipe/unpipe — the latter matters because finalhandler calls it on every response, piped or not. |
string_decoder, timers, timers/promises, tty
|
Small, focused shims. |
zlib |
gzipSync/gunzipSync/deflateSync/inflateSync and the stream equivalents (createGzip etc.), over the zlib already linked in. No promises namespace — Node doesn't have one either. |
Not implemented: child_process, cluster, dns, http2, https,
readline, stream/web, tls, v8, vm, worker_threads, inspector,
async_hooks. child_process is the one that stops next build today —
see spec/NATIVE.md's account of running Next.js's own compiler for the
full trace of what does and doesn't stand in the way.
Buffer extends Uint8Array and matches Node's encoding behavior, verified
against Node's own output rather than against itself — a divergence in either
runtime fails the fixture:
-
utf8/utf-8,hex,base64,base64url,latin1/binary,ascii,ucs2/ucs-2/utf16le/utf-16le— every encoding name, case-insensitive, in both directions. -
hexdecoding stops at the first invalid pair rather than throwing, the way Node's own reader does (unlike the standardUint8Array.fromHex, which throws). -
base64decoding skips characters it can't use, stops at=, accepts either alphabet, needs no padding, and reads one byte per UTF-16 code unit — which is why a multi-byte character truncates a base64 string early: its high surrogate half masks down to=. -
Buffer.byteLength,compare,equals,concat,toJSON(Node's{type:"Buffer",data:[...]}shape).
Two things specific to this layer are worth knowing if you're profiling
Buffer-heavy code: a literal encoding string ("utf-8", "hex") at a call
site is recognized by pointer identity against the atom table rather than by
hashing and comparing, and Buffer.byteLength computes the UTF-8 byte count
directly rather than encoding the string to measure it. Both are covered in
more depth, with numbers, in the README's benchmark section.
This page is generated from
spec/NODE.md.
Machine-readable copies of the whole set:
llms.txt,
llms-full.txt.