Familiar, conventional APIs
(fs.readFileSync, path.join, process.cwd),
statically typed and compiled to native code. Each function carries a
stability pill
(Stable
Experimental
Deprecated)
and, where it differs from the rest of its section, a target pill
(Native only
Wasm: limited)
marking WebAssembly support — hover either for details.
Stable
| API | Type | Stream |
|---|---|---|
console.log(x) | any → void | stdout |
console.info(x) | any → void | stdout, alias of log |
console.debug(x) | any → void | stdout, alias of log |
console.error(x) | any → void | stderr |
console.warn(x) | any → void | stderr, alias of error |
console.trace(x) | any → void | stderr, prints Trace: x — no call stack (Lumen has none to show) |
Stable — thin wrappers over one of the oldest, least-churned corners of the runtime.
| API | Type |
|---|---|
Math.abs(n) | number → number |
Math.max(a, b, …) · Math.min(a, b, …) · Math.max(...arr) · Math.min(...arr) | (number, number, …) → number (two or more, same type; or a spread of one numeric array) |
Math.sign(n) | number → int |
Math.clamp(n, lo, hi) | (number, number, number) → number |
Math.sqrt(n) | number → number |
Math.floor(n) · Math.ceil(n) · Math.round(n) · Math.trunc(n) | number → int |
Math.pow(base, exp) | (number, number) → number |
Math.imul(a, b) | (int, int) → int (32-bit wrapping multiply) |
Math.clz32(x) | int → int (count leading zero bits in 32-bit) |
Math.fround(x) | number → number (round to nearest 32-bit float) |
Math.log(n) · Math.log2(n) · Math.log10(n) · Math.log1p(n) · Math.exp(n) · Math.exp2(n) · Math.expm1(n) | number → number |
Math.sin(n) · Math.cos(n) · Math.tan(n) | number → number |
Math.asin(n) · Math.acos(n) · Math.atan(n) · Math.cbrt(n) | number → number |
Math.atan2(y, x) · Math.hypot(x, y, …) | (number, number) → number (hypot is variadic, two or more) |
Math.sinh(n) · Math.cosh(n) · Math.tanh(n) · Math.asinh(n) · Math.acosh(n) · Math.atanh(n) | number → number |
Math.PI · Math.E · Math.LN2 · Math.LN10 · Math.LOG2E · Math.LOG10E · Math.SQRT2 · Math.SQRT1_2 | number (property; the Math.PI() call form also still works) |
n.toFixed(digits) · n.toExponential(digits?) · n.toString(radix?) | number → string (fixed-point; exponential; base-10 / integer in radix 2–36) |
Number.parseInt(s, radix?) · Number.parseFloat(s) · parseInt(...) · parseFloat(...) | string → int | null · f64 | null (globals alias the Number.* forms) |
Number.isInteger(x) · Number.isSafeInteger(x) · Number.isFinite(x) · Number.isNaN(x) · isNaN(x) · isFinite(x) | number → bool (bare isNaN/isFinite are globals) |
Number.EPSILON · Number.MAX_VALUE · Number.MIN_VALUE · Number.POSITIVE_INFINITY · Number.NEGATIVE_INFINITY · Number.MAX_SAFE_INTEGER · Number.MIN_SAFE_INTEGER · Number.NaN | number (property; the Number.EPSILON() call form also still works) |
Infinity · NaN · Number.NaN() | number (global float constants) |
Stable — pure slice operations, no I/O, conformance-covered.
Called directly on any string value.
| API | Type |
|---|---|
s.length · s + t · s.concat(t, …) | int · concatenation · string (variadic) |
s.split(sep, limit?) | (string | regex, int) → string[] (splits on a string or a regex separator; at most limit parts for the string form) |
s.slice(start, end?) · s.substring(start, end) | → string |
s.indexOf(sub, from?) · s.lastIndexOf(sub) · s.includes(sub, from?) | → int · int · bool |
s.search(regex) | regex → int (index of the first match, or −1) |
s.startsWith(prefix, pos?) · s.endsWith(suffix, end?) | → bool |
s.localeCompare(t) | string → int (−1 / 0 / 1 by byte order; use as a sort comparator) |
s.toUpperCase() · s.toLowerCase() · s.trim() | → string |
s.trimStart() · s.trimEnd() | → string |
s.repeat(n) · s.padStart(len, pad?) · s.padEnd(len, pad?) | → string (pad defaults to a space) |
s.replace(from, to) · s.replaceAll(from, to) | → string (from is a string or a regex; a regex with the g flag replaces every match, otherwise the first) |
s.charAt(i) · s.at(i) · s.charCodeAt(i) · s.codePointAt(i) | → string · string · int · int (at allows negative index; byte-oriented) |
String.isEmpty(s) · String.contains(s, sub) | → bool |
String.compare(a, b) | (string, string) → int (−1 / 0 / 1; use as an array sort comparator) |
String(x) | number | bool | string → string (global conversion) |
Boolean(x) | number | bool | string → bool (truthiness: nonzero / nonempty / itself) |
Number(x) | number | bool | string → f64 (NaN if a string doesn't parse) |
String.fromCharCode(code, …) · String.fromCodePoint(code, …) | (int, …) → string (one byte per code, each & 0xFF) |
Stable —
fixed-size []const T slices underneath; no allocation, no
growable-array machinery, conformance-covered.
Called directly on any T[] value; callbacks are
written as typed arrow functions.
| API | Type |
|---|---|
a[i] · a.length · a.at(i) | element access · int · T | null (at allows negative index) |
a.map(fn) | ((T) => U) or ((T, int) => U) → U[] (optional index) |
a.flatMap(fn) | ((T) => U[]) or ((T, int) => U[]) → U[] (each callback's array concatenated into one flat array) |
a.filter(fn) | ((T) or (T, int) => bool) → T[] (optional index) |
a.reduce(fn, init) · a.reduceRight(fn, init) | (((U, T) or (U, T, int) => U), U) → U (optional index; reduceRight folds from the end) |
a.forEach(fn) | ((T) => void) or ((T, int) => void) → void (optional index) |
a.find(fn) · a.findIndex(fn) · a.some(fn) · a.every(fn) | ((T) or (T, int) => bool) predicate → T · int · bool · bool (optional index) |
a.findLast(fn) · a.findLastIndex(fn) | same predicate, scanning from the end → T | null · int |
a.indexOf(x, from?) · a.lastIndexOf(x, from?) · a.includes(x, from?) | → int · int · bool (from allows negative index; lastIndexOf searches backward from it) |
a.slice(start, end?) · a.reverse() · a.concat(b) · a.with(i, v) · a.fill(v, start?, end?) · a.copyWithin(target, start?, end?) | → T[] (new array; source unchanged; with replaces index i; fill sets [start, end); copyWithin copies a block to target; negatives from the end) |
a.sort(cmp) · a.toSorted(cmp) | ((T, T) => int) → T[] (new, stable; cmp<0 ⇒ a first) |
a.toReversed() | → T[] (alias of reverse; new array) |
a.join(sep?) · a.toString() | → string (sep defaults to ","; toString is the comma-joined form) |
Array.isEmpty(a) | T[] → bool |
Array.of(...items) | (T, …) → T[] (new array from the arguments) |
new Array(n).fill(v) · Array(n).fill(v) | (int, T) → T[] (an n-length array with every element v) |
Array.from(x) · Array.from(x, (v, i?) => u) | string → string[] (chars) · T[] → T[] (copy) · Set<T> → T[]; with a map callback → u[] (each element mapped, optional index) |
Array.isArray(x) | any → bool — a compile-time verdict (types are static), true for any array value |
Object.keys(record) | T → string[] — the record type's field names, in declaration order (static shape) |
Stable — built on long-stable, widely used container implementations.
Typed, generic containers, no dynamic shapes.
| API | Notes |
|---|---|
Map<K, V> | get · set · has · delete · clear · size · keys · values · entries · forEach |
Set<T> | add · has · delete · clear · size · values · keys · forEach |
[A, B] tuple types | fixed-length, per-slot types; t[0], t[1] |
Experimental — new this milestone, not yet covered by the conformance suite; manually smoke-tested only. Pure in-memory state, no syscalls, works identically on native and WebAssembly.
A statically-typed EventEmitter<T>,
built the same way Map/Set are (a real,
dedicated type, not a plain class). A practical subset of
Node's
events module, with one necessary departure: Node lets
one emitter mix wildly different listener payload types across
event names ('data' gets a Buffer, 'error'
gets an Error) with no static checking at all. Lumen can't express that
without reflection, so every event name on one
EventEmitter<T> instance shares the same payload type
T — event names are still real string keys, just
constrained to one shared type. A program that genuinely needs
different payload shapes creates two separate emitters (or wraps both
shapes in one union/record T).
~3.5× faster than Node's native
EventEmitter on a tight emit loop: 10M emit()
calls to one listener, compiled with --release-fast,
measured around 43ms versus Node 20's ~160ms for the equivalent loop.
No hidden-class/property-lookup machinery to warm up (a native binary
starts at full speed), and listener dispatch goes through a plain
function-pointer call rather than V8's more general call machinery.
| API | Notes |
|---|---|
new EventEmitter<T>() | construct an emitter for payload type T |
.on(name, listener) | (string, (T) => void) → void — registers a listener |
.once(name, listener) | (string, (T) => void) → void — fires at most once, then is dropped |
.emit(name, value) | (string, T) → void — calls every listener for name, in registration order |
.removeAllListeners(name?) | (string?) → void — clears one name, or every name if omitted |
.listenerCount(name) | string → int |
function onGreeting(msg: string): void {
console.log(msg);
}
const emitter = new EventEmitter<string>();
emitter.on("greet", onGreeting);
emitter.emit("greet", "hello"); // hello
console.log(emitter.listenerCount("greet")); // 1
Not guaranteed-safe: adding or removing a listener for the same event name from inside one of that name's own listeners, while it's still being emitted. Straightforward use (register listeners up front, emit later) is unaffected.
Experimental — the native event loop is still gaining backend coverage.
Asynchronous code runs on a native event loop. The program drains pending callbacks and promises before it exits.
| API | Notes |
|---|---|
async function f(): Promise<T> { … } | declares an async function |
await expr | awaits a Promise<T>, yields T |
Promise.resolve(x) | T → Promise<T> |
setTimeout(fn, ms) | (() => void, int) → int — schedules a callback after a delay, returns a handle |
clearTimeout(id) | int → void — cancels a pending setTimeout |
setInterval(fn, ms) | (() => void, int) → int — repeats a callback every delay until cancelled |
clearInterval(id) | int → void — cancels a running setInterval; the same underlying function as clearTimeout, two names to match Node |
Experimental — strings and scalars only cross the boundary (no structs, callbacks, or arrays yet).
| API | Notes |
|---|---|
declare function name(args): T; | calls a C function directly (extern function also works) |
// @link m · // @link ./shim.o | link a library or object |
| string & scalar marshaling | string params/returns and numeric scalars cross the boundary |
Stable — lowers to the runtime's native error-union/catch machinery, its primary error-handling mechanism.
| API | Notes |
|---|---|
Error("message") · throw e | throw an error value |
try { … } catch (e) { … } finally { … } | e.message holds the message |
Stable — the
synchronous core (everything except the individually-marked functions
below) wraps long-stable POSIX syscalls (open,
read, stat, ...) that predate the runtime's newer
async I/O layer.
Mostly synchronous, statically typed and Node-conventional, plus
a small async trio (readFile/writeFile/appendFile),
file watching (watch), and file-backed streams
(createReadStream/createWriteStream). This is a
practical subset, not the full Node fs API
(a Lumen "fd" is a plain int index, not a raw OS handle)
— see Planned below for what is next.
fs.readFileSync(path, encoding?)(string, string?) → string — UTF-8 text; throws a catchable Error naming the path if the file can't be read.
try {
const data: string = fs.readFileSync("notes.txt");
console.log(data);
} catch (e) {
console.log(e.message); // cannot read 'notes.txt': FileNotFound
}
fs.readFile(path) Experimental Native onlystring → Promise<string> — true
non-blocking I/O, no thread pool, unlike Node's
fs.promises.readFile.
async function main(): Promise<void> {
const data: string = await fs.readFile("notes.txt");
console.log(data);
}
fs.writeFile(path, data) Experimental Native only(string, string) → Promise<void> —
the async counterpart to fs.writeFileSync; creates the file
or truncates it first, same as the sync version.
async function main(): Promise<void> {
await fs.writeFile("notes.txt", "hello async world");
console.log("done");
}
fs.appendFile(path, data) Experimental Native only(string, string) → Promise<void> —
the async counterpart to fs.appendFileSync; creates the file
if it does not exist yet.
async function main(): Promise<void> {
await fs.appendFile("log.txt", "line one\n");
await fs.appendFile("log.txt", "line two\n");
console.log("done");
}
fs.unlink(path) Experimental Native onlystring → Promise<void> —
runs on a real thread pool, unlike
readFile/writeFile/appendFile
above, so it never blocks the main thread — the same approach
Node's own async fs uses. Genuinely cross-platform, not a
Linux-only shortcut.
async function main(): Promise<void> {
await fs.unlink("stale.txt");
console.log("gone");
}
fs.mkdir(path) Experimental Native onlystring → Promise<void> — thread-pool
backed, see fs.unlink above. No recursive
option this pass, matching mkdirSync's default-false
convention.
async function main(): Promise<void> {
await fs.mkdir("build");
}
fs.rmdir(path) Experimental Native onlystring → Promise<void> — thread-pool
backed, see fs.unlink above.
async function main(): Promise<void> {
await fs.rmdir("build");
}
fs.stat(path) Experimental Native onlystring → Promise<Stats> — thread-pool
backed, see fs.unlink above. Same Stats shape
as fs.statSync.
async function main(): Promise<void> {
const st = await fs.stat("notes.txt");
console.log(st.size);
}
fs.writeFileSync(path, data)(string, string) → void — creates the file or truncates it first; throws a catchable Error on an I/O failure.
fs.writeFileSync("notes.txt", "hello world");
fs.appendFileSync(path, data)(string, string) → void — creates the file if it does not exist yet.
fs.appendFileSync("log.txt", "new line\n");
fs.existsSync(path)string → bool
if (fs.existsSync("config.json")) {
console.log("found it");
}
fs.mkdirSync(path, recursive?)(string, bool?) → void — recursive defaults to false.
fs.mkdirSync("build/output", true);
fs.unlinkSync(path)string → void — delete a file.
fs.unlinkSync("scratch.tmp");
fs.renameSync(oldPath, newPath)(string, string) → void
fs.renameSync("draft.md", "final.md");
fs.copyFileSync(src, dest)(string, string) → void
fs.copyFileSync("template.txt", "output.txt");
fs.rmdirSync(path)string → void — removes an empty directory.
fs.rmdirSync("empty-dir");
fs.rmSync(path, recursive?)(string, bool?) → void — recursive removes a whole directory tree.
fs.rmSync("build", true);
fs.truncateSync(path, len)(string, int) → void
fs.truncateSync("log.txt", 0);
fs.linkSync(existingPath, newPath)(string, string) → void — hard link.
fs.linkSync("original.txt", "alias.txt");
fs.symlinkSync(target, path)(string, string) → void
fs.symlinkSync("/etc/hosts", "hosts-link");
fs.readlinkSync(path)string → string
console.log(fs.readlinkSync("hosts-link")); // /etc/hosts
fs.chmodSync(path, mode)(string, int) → void — POSIX mode bits.
fs.chmodSync("script.sh", 0o755); // rwxr-xr-x
fs.accessSync(path, mode?)(string, int?) → bool — mode bitmask R_OK=4 W_OK=2 X_OK=1; returns
bool rather than throwing.
console.log(fs.accessSync("data.csv", 4)); // readable?
fs.cpSync(src, dest, recursive?)(string, string, bool?) → void — falls back to a single file copy when
src is not a directory.
fs.cpSync("assets", "dist/assets", true);
fs.mkdtempSync(prefix)string → string — unique suffix from a timestamp + counter, not cryptographic; returns the created path.
const dir: string = fs.mkdtempSync("/tmp/build-");
console.log(dir); // /tmp/build-1a2b3c4d
fs.statSync(path)string → { size, isFile, isDirectory, mtimeMs } — the first
record-returning builtin; isFile/isDirectory are bool fields, not
methods.
const info = fs.statSync("report.pdf");
console.log(info.size);
console.log(info.isFile);
fs.openSync(path, flags)(string, string) → int — a Lumen "fd" is an index into an internal
file table, not a raw OS handle. flags is "r" (read, must exist),
"w" (write, create/truncate), or "a" (append,
create if missing, existing content kept, every writeSync
lands at the current end of file — verified across separate
open/close cycles, each reopen correctly re-seeks to wherever the file
ends at that point, not just where it ended at the first open).
const fd: int = fs.openSync("data.bin", "r");
const logFd: int = fs.openSync("app.log", "a");
fs.writeSync(logFd, "another line\n");
fs.closeSync(logFd);
fs.closeSync(fd)int → void
fs.closeSync(fd);
fs.readSync(fd, length)(int, int) → string — reads up to length bytes from the
current file position; works on string, not a Buffer (Lumen has none yet).
const fd: int = fs.openSync("data.bin", "r");
const chunk: string = fs.readSync(fd, 1024);
fs.closeSync(fd);
fs.writeSync(fd, data)(int, string) → int — writes at the current file position, returns bytes written.
const fd: int = fs.openSync("out.bin", "w");
const n: int = fs.writeSync(fd, "hello");
fs.closeSync(fd);
fs.lstatSync(path) Experimentalstring → { size, isFile, isDirectory, mtimeMs } — like
statSync, but does not follow a symlink at path; stats the
link itself.
const info = fs.lstatSync("hosts-link");
console.log(info.isFile); // false: the link itself isn't a regular file
fs.fstatSync(fd) Experimentalint → { size, isFile, isDirectory, mtimeMs } — stats an
already-open fd instead of resolving a path again.
const fd: int = fs.openSync("data.bin", "r");
console.log(fs.fstatSync(fd).size);
fs.closeSync(fd);
fs.fchmodSync(fd, mode) Experimental(int, int) → void — fd-based form of chmodSync.
const fd: int = fs.openSync("script.sh", "w");
fs.fchmodSync(fd, 0o755);
fs.closeSync(fd);
fs.lchmodSync(path, mode) Experimental(string, int) → void — chmod without following a symlink at
path; best effort (not every OS lets you chmod a symlink directly).
fs.lchmodSync("hosts-link", 0o644);
fs.fchownSync(fd, uid, gid) Experimental(int, int, int) → void — pass -1 for either id to leave
it unchanged.
const fd: int = fs.openSync("data.bin", "w");
fs.fchownSync(fd, 1000, -1); // change owner, leave group alone
fs.closeSync(fd);
fs.chownSync(path, uid, gid) Experimental(string, int, int) → void — pass -1 for either id
to leave it unchanged. Follows symlinks (see fs.lchownSync
below for the one that shouldn't). Implemented by opening the file
and reusing fchownSync's working fd-based path, since the
path-based owner-change call panics unconditionally in this Zig version's
runtime.
fs.chownSync("data.bin", 1000, 1000);
fs.lchownSync(path, uid, gid) Experimental(string, int, int) → void — like chownSync
but changes the symlink itself, not what it points to. Linux only (raw
lchown, no libc) — unlike chownSync, this
can't be built by opening the file first, since opening a symlink path
normally follows it.
fs.symlinkSync("/data/real.bin", "link.bin");
fs.lchownSync("link.bin", 1000, 1000);
fs.realpathSync(path) Experimentalstring → string — resolves symlinks and returns the
canonical path. Returns path unchanged if it doesn't exist or
can't be resolved.
console.log(fs.realpathSync("./current-link"));
fs.writevSync(fd, buffers) Experimental(int, string[]) → int — writes every chunk in
buffers in a single syscall, returns the total bytes
written. Linux only (raw writev, no libc).
const fd: int = fs.openSync("out.bin", "w");
const n: int = fs.writevSync(fd, ["hello ", "world"]);
fs.closeSync(fd);
fs.readvSync(fd, sizes) Experimental(int, int[]) → string[] — reads into
sizes.length chunks in a single syscall, returning that many
strings sized to what was actually read. Deviation from
Node: Node's readv fills caller-provided mutable
buffers; Lumen's string is immutable, so this takes desired
chunk sizes instead and owns the buffers itself — same underlying
vectored read, a shape that fits the type system. Linux only (raw
readv, no libc).
const fd: int = fs.openSync("data.bin", "r");
const chunks: string[] = fs.readvSync(fd, [4, 100]); // header, then the rest
fs.closeSync(fd);
fs.fsyncSync(fd) Experimentalint → void — flush file data and metadata to disk.
fs.fsyncSync(fd);
fs.fdatasyncSync(fd) Experimentalint → void — aliases fsyncSync (no
data-only sync separate from a full sync is exposed here).
fs.fdatasyncSync(fd);
fs.ftruncateSync(fd, len) Experimental(int, int) → void — fd-based form of truncateSync.
const fd: int = fs.openSync("log.txt", "w");
fs.ftruncateSync(fd, 0);
fs.closeSync(fd);
fs.futimesSync(fd, atimeMs, mtimeMs) Experimental(int, int, int) → void — set access/modify times (milliseconds since epoch) on an open fd.
fs.futimesSync(fd, 1700000000000, 1700000000000);
fs.utimesSync(path, atimeMs, mtimeMs) Experimental(string, int, int) → void — path-based form, follows symlinks.
fs.utimesSync("notes.txt", 1700000000000, 1700000000000);
fs.lutimesSync(path, atimeMs, mtimeMs) Experimental(string, int, int) → void — like utimesSync, but sets
the timestamps on a symlink itself rather than its target.
fs.lutimesSync("hosts-link", 1700000000000, 1700000000000);
fs.readdirSync(path) Experimentalstring → string[] — entry names in a directory, in no
particular order.
const names: string[] = fs.readdirSync(".");
for (const n of names) {
console.log(n);
}
fs.watch(path, listener) Experimental Wasm: limited(string, (string, string) → void) → void — blocking,
never returns; calls listener with the name of the file that
changed and an event type ("change" for a data
modification, "rename" for a create/delete/move —
matching Node's own fs.watch convention, not inotify's full
granularity) on every event under path. Linux only (raw
inotify, no libc). Not EventEmitter-based like
Node's real FSWatcher — nothing drives an emitter
asynchronously on its own here, so this calls the listener directly in a
blocking loop instead, the same shape as http.createServer.
function onChange(name: string, eventType: string): void {
console.log(name + " " + eventType);
}
fs.watch("./src", onChange);
fs.createReadStream(path) Experimentalstring → ReadableStream — built the
same way Map/Set/EventEmitter
are, a real dedicated type. .read() returns the next
chunk (up to 64KB), an empty string at EOF; .close()
closes the file. A missing/unopenable file degrades to a stream that
always reads "" rather than crashing, the same fallback
shape every other fs function already uses.
Synchronous/blocking, no async or backpressure integration yet.
const r = fs.createReadStream("large.log");
let chunk: string = r.read();
while (chunk != "") {
console.log(chunk.length);
chunk = r.read();
}
r.close();
fs.createWriteStream(path) Experimentalstring → WritableStream —
.write(chunk) appends a chunk to the file (opens/
truncates on creation); .close() flushes and closes.
const w = fs.createWriteStream("out.log");
w.write("line one\n");
w.write("line two\n");
w.close();
Stable — pure string manipulation, no filesystem calls of its own. Newly added to Lumen, but resting on one of the oldest, least-churned corners of the runtime.
No filesystem access, just string logic. A practical subset of
Node's path
API — POSIX only (no path.win32), and
path.sep/path.delimiter are called as
path.sep()/path.delimiter() rather than read as
properties, since Lumen has no static-namespace constant mechanism yet.
path.basename(path, suffix?)(string, string?) → string — the last path segment,
with suffix stripped if present.
console.log(path.basename("/foo/bar/baz.html")); // baz.html
console.log(path.basename("/foo/bar/baz.html", ".html")); // baz
path.dirname(path)string → string — returns "." for a path
with no directory segment.
console.log(path.dirname("/foo/bar/baz.html")); // /foo/bar
path.extname(path)string → string — "" for dotfiles like
.gitignore.
console.log(path.extname("index.html")); // .html
path.isAbsolute(path)string → bool
console.log(path.isAbsolute("/foo/bar")); // true
path.normalize(path)string → string — collapses ./..
segments; does not require the path to exist.
console.log(path.normalize("/foo/bar//baz/asdf/quux/..")); // /foo/bar/baz/asdf
path.join(...paths)(string, string, ...) → string — 2 to 6 arguments;
joins then normalizes, matching Node's join definition.
console.log(path.join("/foo", "bar", "baz/asdf", "quux", "..")); // /foo/bar/baz/asdf
path.resolve(...paths)(string, string, ...) → string — 1 to 6 arguments;
"cd"-chains left to right, an absolute segment resets the result. If no
argument is absolute, anchors to the real working directory to
guarantee an absolute result, matching Node. The only path.*
function that reads the real cwd — every other one stays pure
string manipulation.
console.log(path.resolve("/foo/bar", "./baz")); // /foo/bar/baz
console.log(path.resolve("foo", "bar")); // /foo/bar
path.parse(path)string → { root, dir, base, name, ext } —
the second record-returning builtin after fs.statSync.
const p = path.parse("/home/user/dir/file.txt");
console.log(p.dir); // /home/user/dir
console.log(p.name); // file
path.format(parts){ root, dir, base, name, ext } → string
— the inverse of parse. Deviation from
Node: all five fields are required (Node allows omitting any of
them); round-tripping path.format(path.parse(p)) works
perfectly, building a literal by hand needs every field filled in.
console.log(path.format(path.parse("/home/user/dir/file.txt")));
// /home/user/dir/file.txt
path.sep()→ string — always "/" (Lumen is POSIX-only).
Called as a function, not read as a property (see above).
console.log(path.sep()); // /
path.delimiter()→ string — always ":".
console.log(path.delimiter()); // :
Stable —
cwd/chdir go through the same Io
primitives as fs; env and argv()
reuse state the program's own startup already collects;
platform/arch are compile-time constants;
pid()/uptime()/hrtime()/
kill()/umask()/getuid()-family are
raw Linux syscalls; memoryUsage() reads
/proc/self/status. No libc linking required for any of it.
A practical subset of
Node's process
object — kill() can send a signal, but nothing yet
can receive one back into user code, and there's still no IPC or worker
threads. platform()/arch() are called as
functions, not read as properties, the same deviation as
path.sep().
process.cwd()→ string — the real current working directory.
console.log(process.cwd());
process.chdir(directory)string → void
process.chdir("/tmp");
console.log(process.cwd());
process.exit(code)int → void — terminates immediately; nothing after the call runs. Truncates to a byte-sized exit code, matching the OS.
console.log("before");
process.exit(42);
console.log("never runs");
process.env(key)string → string | null — called as a function rather
than indexed as a property (process.env.FOO in Node), since
Lumen has no dynamic-object indexing.
const home = process.env("HOME");
if (home != null) {
console.log(home);
}
process.platform()→ string — "linux", "darwin",
"win32", ...
console.log(process.platform()); // linux
process.arch()→ string — "x64", "arm64", ...
console.log(process.arch()); // x64
process.pid() Wasm: limited→ int
console.log(process.pid() > 0); // true
process.argv()→ string[] — deviation from
Node: follows C/POSIX argv convention (index 0 is the invoked
binary itself), not Node's node-then-script convention where a user's
first real argument is argv[2].
const argv: string[] = process.argv();
for (const a of argv) {
console.log(a);
}
argsCount() · arg(i)→ int · int → string — unnamespaced, pre-dates
process.argv(); reads the same underlying argument data.
console.log(argsCount());
console.log(arg(0));
process.uptime()→ number — fractional seconds since this process
started. A start timestamp is recorded once at program startup (the same
monotonic clock time.monotonic() uses); each call
subtracts.
console.log(process.uptime() >= 0.0);
process.hrtime()→ i64 — monotonic nanoseconds. Deviation from
Node: a single scalar, not Node's [seconds, nanoseconds]
tuple — Lumen's i64 is a real 64-bit integer (unlike a
JS double), so there's no precision problem to work around by splitting
it, the same reasoning Node's own newer hrtime.bigint()
uses. Diff two readings for an elapsed duration.
const start: i64 = process.hrtime();
const end: i64 = process.hrtime();
console.log(end > start);
process.memoryUsage()→ { rss: i64, vsize: i64 } (bytes) —
parsed from /proc/self/status. Deviation from
Node: only rss/vsize, not Node's full
heapTotal/heapUsed/external/
arrayBuffers shape — those describe V8's
garbage-collected heap, which has no Lumen-side equivalent.
const mem = process.memoryUsage();
console.log(mem.rss);
console.log(mem.vsize);
process.kill(pid, signal)(int, string) → bool — sends a signal to another
process. signal is a name ("SIGTERM" or
"TERM", either works); an unrecognized name resolves to
POSIX's signal 0 (existence/permission check only, never
destructive) rather than guessing. Returns whether the syscall
succeeded.
const ok: bool = process.kill(childPid, "SIGTERM");
console.log(ok);
process.umask() · process.setUmask(mask)→ int · int → int — two names instead of one
arity-overloaded call (Node's process.umask([mask])
dispatches on argument count; Lumen's checker has no call-site
overloading, the same reason cwd()/chdir() are
two names). setUmask returns the previous mask,
matching what the real umask() syscall itself returns.
const old: int = process.umask();
console.log(old);
process.getuid() · getgid() · geteuid() · getegid() Wasm: limited→ int — POSIX-only, raw syscalls, the same shape as
pid().
console.log(process.getuid());
console.log(process.getgid());
process.abort()→ void — abnormal termination via SIGABRT.
Nothing after the call runs.
console.log("before");
process.abort();
console.log("never runs");
process.version()→ string — not Node's version. Lumen's own version marker, matching the repository's latest release tag. Bumped by hand alongside future tags.
console.log(process.version());
Experimental — new, manually tested rather than conformance-suite covered.
process.stdin()/stdout()/
stderr() reuse the exact ReadableStream/
WritableStream types
fs.createReadStream/createWriteStream return,
just wired to the process's real stdio file descriptors instead of an
opened file — every method below (.read()/
.close()/.write()) already works on them.
stdout()/stderr() flush after every
.write() call (unlike a file-backed WritableStream,
which still buffers until .close()), so writes interleave
correctly, in order, with console.log in the same run.
process.stdin() Experimental→ ReadableStream — each call allocates a
fresh wrapper around the same real stdin file descriptor (no shared/
cached instance). .read() (64KB chunks) works exactly as it
does on any other ReadableStream; see
ReadableStream.readLine() below for line-oriented reading.
const input = process.stdin();
const chunk: string = input.read();
console.log(chunk);
process.stdout() Experimental→ WritableStream — .write(chunk)
flushes immediately, every call, so it interleaves correctly with
console.log. Closing it (.close()) closes the
real stdout fd — usually not something a program needs to do
before it exits.
console.log("before");
process.stdout().write("interleaved\n");
console.log("after");
process.stderr() Experimental→ WritableStream — same shape as
process.stdout(), wired to fd 2 instead of fd 1.
process.stderr().write("warning: low disk space\n");
ReadableStream.readLine() Experimental→ string — available on any
ReadableStream (process.stdin() or
fs.createReadStream(...) alike). Returns the next line
with its trailing \n/\r\n kept, not
stripped — deliberate: stripping it would make a
genuinely blank line indistinguishable from real end-of-stream (both
would be ""), silently truncating a
while (line != "") read loop on ordinary blank input
lines. "" means true EOF only; call .trim()
for a stripped line. A final line with no trailing newline (a piped
input cut off mid-line) is still returned intact, not dropped.
const input = process.stdin();
while (true) {
const raw: string = input.readLine();
if (raw == "") {
break;
}
console.log(raw.trim());
}
Experimental — new, manually tested rather than conformance-suite covered.
A practical subset of
Node's
readline module: one blocking function, not Node's
event-based readline.createInterface()/
.on('line', cb) — every stdio primitive this is built
on (.read()/.write()/.readLine())
is a blocking call, not an event-driven one, so a synchronous
question() fits the shape already there rather than needing
a background line-pump the language doesn't have.
readline.question(prompt) Experimentalstring → string — writes prompt to
process.stdout(), then blocks reading one line from
process.stdin(). Deviation from
ReadableStream.readLine(): the trailing
\n/\r\n is stripped before returning, matching
Node's own question() callback value —
readLine() keeps it for its own blank-line-vs-EOF safety,
which question() doesn't need. Every call shares one
underlying stdin reader internally, so consecutive calls see
consecutive lines rather than each silently discarding the next line's
already-buffered bytes. Returns "" at true end-of-stream
(piped input exhausted).
const name = readline.question("Name: ");
console.log("Hi " + name);
Stable —
almost the entire namespace is two raw Linux syscalls
(uname() covers type/release/
version/machine/hostname in one
call; sysinfo() covers uptime/loadavg/
totalmem/freemem in one call), no libc.
A practical subset of
Node's os
module. All called as functions, not read as properties, the same
deviation as path.sep()/process.platform().
os.platform()/os.arch() duplicate
process.platform()/process.arch() intentionally
— Node itself defines both independently with identical values.
os.platform()→ string — same as process.platform().
console.log(os.platform()); // linux
os.arch()→ string — same as process.arch().
console.log(os.arch()); // x64
os.type() Wasm: limited→ string — the kernel name, via uname(2).
console.log(os.type()); // Linux
os.release() Wasm: limited→ string — the kernel release string.
console.log(os.release());
os.version() Wasm: limited→ string
console.log(os.version());
os.machine() Wasm: limited→ string
console.log(os.machine()); // x86_64
os.hostname() Wasm: limited→ string
console.log(os.hostname());
os.endianness()→ string — "LE" or "BE".
console.log(os.endianness()); // LE
os.tmpdir()→ string — checks TMPDIR, TMP,
TEMP, falls back to "/tmp".
console.log(os.tmpdir());
os.homedir()→ string — the HOME environment
variable. Deviation from Node: Node falls back to a
passwd-database lookup when HOME is unset; Lumen returns
"" instead, rather than introduce the compiler's first libc
dependency for one rarely-hit fallback path.
console.log(os.homedir());
os.uptime() Wasm: limited→ int — system uptime in seconds.
console.log(os.uptime() > 0); // true
os.totalmem() Experimental Wasm: limited→ int — total system memory in bytes.
Deviation from Node: truncates to a 32-bit int
(the same tradeoff as fs.statSync's size field);
on a host with more than ~2GB of RAM the value can come out negative.
console.log(os.totalmem() != 0);
os.freemem() Experimental Wasm: limited→ int — same 32-bit truncation caveat as
totalmem().
console.log(os.freemem() != 0);
os.loadavg() Wasm: limited→ number[] — 1, 5, and 15 minute load
averages, always exactly 3 elements.
const loads: number[] = os.loadavg();
console.log(loads[0]);
os.availableParallelism()→ int
console.log(os.availableParallelism() > 0); // true
os.EOL()→ string — always "\n" (Lumen is
POSIX-only).
console.log(os.EOL() == "\n"); // true
os.devNull()→ string — always "/dev/null".
console.log(os.devNull());
Stable — pure computation
throughout (an entropy source and a hash implementation, no syscalls
involved), so unlike os and process.pid() this
works identically on the native and WebAssembly targets.
A practical subset of
Node's
crypto module: mostly static one-shot functions, plus
(spec 060) two real streaming builder objects,
createHash/createHmac, for hashing data
incrementally without holding it all in memory at once.
crypto.randomBytes(n)int → string — n random bytes,
hex-encoded (a 2n-character string). Kept as-is (not
changed to return Buffer) now that Buffer
exists — see randomBytesBuffer below for the raw-bytes
form.
const token: string = crypto.randomBytes(16);
console.log(token.length); // 32
crypto.randomUUID()→ string — a v4 UUID
(xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx).
console.log(crypto.randomUUID());
crypto.sha256(data)string → string — hex digest of the SHA-256 hash of
data's bytes.
console.log(crypto.sha256("hello"));
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
crypto.randomBytesBuffer(n) Experimentalint → Buffer — the same entropy source
as randomBytes, without the hex encoding. Additive, not a
replacement: randomBytes keeps returning a hex
string, since that's an already-shipped contract other code
relies on.
const raw = crypto.randomBytesBuffer(16);
console.log(raw.length); // 16
crypto.hmacSync(key, data) Experimental(Buffer, Buffer) →
Buffer — HMAC-SHA256, a 32-byte MAC. One fixed
algorithm, no name parameter — the same "one well-chosen option"
shape as sha256 itself.
const key = Buffer.from("key");
const data = Buffer.from("The quick brown fox jumps over the lazy dog");
console.log(crypto.hmacSync(key, data).toString("hex"));
// f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
crypto.encryptSync(key, iv, data) Experimental(Buffer, Buffer, Buffer)
→ Buffer — AES-256-GCM authenticated encryption.
key must be exactly 32 bytes and iv exactly 12
bytes (GCM's standard nonce length); either wrong-length input returns
an empty Buffer rather than crashing. The result is
ciphertext followed by a 16-byte authentication tag,
concatenated into one Buffer — the simplest
single-value shape, since decryptSync re-splits it
internally.
const key = Buffer.alloc(32); // in real use, crypto.randomBytesBuffer(32)
const iv = Buffer.alloc(12); // in real use, a fresh nonce per message
const ct = crypto.encryptSync(key, iv, Buffer.from("attack at dawn"));
console.log(ct.length); // 30 -- 14-byte message + 16-byte tag
crypto.decryptSync(key, iv, data) Experimental(Buffer, Buffer, Buffer)
→ Buffer — inverse of encryptSync.
Deviation from Node: on a wrong-length key/iv, a
too-short data, or a failed authentication check (tampered
ciphertext or tag), returns an empty Buffer rather than
throwing — the same fallback-don't-crash shape every other
fallible builtin here uses.
const key = Buffer.alloc(32);
const iv = Buffer.alloc(12);
const ct = crypto.encryptSync(key, iv, Buffer.from("attack at dawn"));
const pt = crypto.decryptSync(key, iv, ct);
console.log(pt.toString("utf8")); // attack at dawn
const tampered = Buffer.alloc(ct.length);
console.log(crypto.decryptSync(key, iv, tampered).length); // 0 -- auth failed
crypto.createHash(algorithm) Experimentalstring → Hash — a stateful hash
builder. algorithm is a runtime string, matching Node's
own real createHash('sha256') API: "md5" |
"sha1" | "sha256" | "sha512";
an unrecognized name falls back to "sha256" rather than
throwing (the same "fallback, don't crash" shape as
Buffer.from(s, encoding)'s unrecognized encoding).
Hash.update(data) (Buffer → Hash)
feeds bytes in, any number of times, and returns self for
chaining; Hash.digest() (() → Buffer)
finalizes once. Deviation from Node: digest()
returns a raw Buffer, not a string with an
encoding argument — call .toString("hex")/
.toString("base64") on the result, which composes with
every other Buffer-returning API here instead of
reimplementing encoding a second time.
const h = crypto.createHash("sha256");
h.update(Buffer.from("ab"));
h.update(Buffer.from("c"));
console.log(h.digest().toString("hex"));
// ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad -- same as sha256("abc")
crypto.createHmac(algorithm, key) Experimental(string, Buffer) → Hmac
— the streaming, algorithm-selectable counterpart to
hmacSync (which stays fixed at HMAC-SHA256). Same four
algorithms and unrecognized-name fallback as createHash.
Hmac.update(data)/Hmac.digest() have the
identical shape and the same Buffer-return design as
Hash's.
const key = Buffer.from("key");
const mac = crypto.createHmac("sha256", key);
mac.update(Buffer.from("The quick brown fox jumps over the lazy dog"));
console.log(mac.digest().toString("hex"));
// f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 -- same as hmacSync
crypto.pbkdf2Sync(password, salt, iterations, keylen) Experimental(Buffer, Buffer, int, int) →
Buffer — PBKDF2-HMAC-SHA256 key derivation. One fixed
PRF, no algorithm parameter, matching hmacSync's own
"one well-chosen option" shape. iterations < 1 or
keylen <= 0 returns an empty Buffer rather
than throwing.
const key = crypto.pbkdf2Sync(Buffer.from("password"), Buffer.from("salt"), 4096, 20);
console.log(key.toString("hex"));
// c5e478d59288c841aa530db6845c4c8d962893a0
crypto.scryptSync(password, salt, keylen) Experimental(Buffer, Buffer, int) →
Buffer — scrypt key derivation with fixed cost
parameters (N=16384, r=8, p=1, Node's own crypto.scrypt
default). Cost parameters aren't exposed as an option in v1.
keylen <= 0 returns an empty Buffer.
const key = crypto.scryptSync(Buffer.from("password"), Buffer.from("salt"), 32);
console.log(key.length); // 32
crypto.timingSafeEqual(a, b) Experimental(Buffer, Buffer) → bool
— constant-time byte comparison, for checking secrets (MACs,
tokens) without leaking timing information about where two values
first differ. Deviation from Node: a length mismatch
returns false instead of throwing — lengths aren't
secret, so this leaks nothing a length-mismatch throw wouldn't already
reveal.
const a = Buffer.from("secret");
const b = Buffer.from("secret");
console.log(crypto.timingSafeEqual(a, b)); // true
Stable — pure string parsing (the same underlying URI parser used elsewhere for HTTP requests), no syscalls, works identically on the native and WebAssembly targets.
A practical subset of
Node's url
module: the older, function-based url.parse()/
format() API, not the class-based URL object.
protocol keeps its trailing colon, search/
hash keep their leading ?/#, matching
WHATWG conventions; unlike path.parse/format,
parsing a malformed URL doesn't error, it falls back to an empty record
(pathname stays "/", href always
keeps the original input verbatim).
url.parse(str)string → { protocol, hostname, port, pathname, search, hash, href, query }
— the second record-returning builtin family after
path.parse, same pattern: a synthetic record type, all
fields plain (non-optional) strings, except query.
query is search (which stays the raw
"?a=1&b=2" string, unchanged) additionally parsed
into a real Map<string, string> — both are
available, not a choice between them. A repeated key overwrites (last
one wins), the same behavior any repeated Map.set() call
already has.
const u = url.parse("https://example.com:8080/foo?a=1&b=hello#c");
console.log(u.hostname); // example.com
console.log(u.port); // 8080
console.log(u.pathname); // /foo
console.log(u.search); // ?a=1&b=hello
console.log(u.hash); // #c
console.log(u.query.get("a")); // 1
console.log(u.query.get("b")); // hello
url.format(parts){ protocol, hostname, port, pathname, search, hash, href } → string
— reconstructs a URL string from the record; round-trips with
parse.
const u = url.parse("https://example.com/foo?a=1");
console.log(url.format(u)); // https://example.com/foo?a=1
Experimental — new, manually tested rather than conformance-suite covered.
A first, sync-only slice of
Node's
child_process module: the biggest capability gap in the
stdlib closed so far, since Lumen programs previously couldn't invoke
another program at all.
child_process.spawnSync(command, args) Wasm: limited(string, string[]) → { stdout, stderr, status }
— runs command with args (no shell
involved, matching Node's real spawnSync, not
execSync's shell-string form -- safer by default, no
shell-quoting needed), waits for it to exit. status is
-1 if the command could not even be spawned (e.g. not
found).
const r = child_process.spawnSync("echo", ["hello", "world"]);
console.log(r.stdout); // hello world
console.log(r.status); // 0
Stable — wraps the language's own panic mechanism, works identically on native and WebAssembly.
A minimal, practical subset of
Node's
assert module. A failed assertion crashes the program
(uncatchable), the same idiom as C's assert() or an
uncaught Node AssertionError — not integrated with
try/catch.
assert.ok(cond)bool → void — panics if cond is false.
assert.ok(2 + 2 == 4);
console.log("still running");
assert.equal(a, b)(T, T) → void — panics with both values shown if they are not equal. Strings compare by bytes.
assert.equal(2 + 2, 4);
assert.equal("hello", "hello");
console.log("still running");
Stable — one clock read each, the same primitive already used elsewhere in the compiler; pure computation, works identically on native and WebAssembly.
Millisecond timestamps as i64, not
int: real epoch milliseconds hugely exceeds a 32-bit range,
so this returns the wider type rather than a truncated, effectively
meaningless value. Note that i64 values can't be compared or
used in arithmetic directly against an int literal yet -- keep both sides
of a comparison as i64.
time.now()→ i64 — milliseconds since the Unix epoch (wall-clock/real time).
const n: i64 = time.now();
console.log(n);
Date.now()→ i64 — the familiar JS spelling of
time.now(): milliseconds since the Unix epoch. Promotes to
number in float contexts. The rest of the Date
object is planned.
const start: i64 = Date.now();
// ... work ...
console.log(Date.now() - start >= 0); // true
time.monotonic()→ i64 — milliseconds from an arbitrary, consistent starting point; never goes backwards, safe for measuring elapsed durations, unaffected by the system clock being changed.
const start: i64 = time.monotonic();
// ... do work ...
const elapsed: i64 = time.monotonic() - start;
console.log(elapsed);
Experimental — new, manually tested rather than conformance-suite covered.
A one-shot HTTP client, the practical core of
Node's http
module. Not a 1:1 port: Node's classes
(ClientRequest/IncomingMessage/Agent)
are all built on EventEmitter and streams together. Custom
request headers and response headers (server-side) both work, via
Map<string, string>; response headers on the client
side are the one piece still deferred (the underlying HTTP client's
convenience wrapper only surfaces the status code -- reading real
headers needs a lower-level request flow than what's wired up yet).
~1.5× faster than Node's native
http client against a local server: 300 sequential
GETs, compiled with --release-fast, measured around 110ms
versus Node 20's ~160ms for the equivalent loop. Only skips work Node's
own client also skips for a plain (non-TLS) request — loading the
system CA bundle means reading and parsing a real certificate file from
disk, so it's only done for https:// URLs, not
unconditionally on every call.
http.request(url, method, body, headers) Wasm: limited(string, string, string, Map<string, string>) →
{ status, body, ok, headers } — pass
"" for body on methods that don't take one,
and an empty Map for headers if there are
none to send. ok is status >= 200 and status
< 300, computed once here so every caller doesn't have to
write that range check themselves. status is
-1 if the request could not even connect. The response's
own headers is currently always an empty Map
— reading real response headers needs a lower-level request
flow than what's wired up yet, a deliberate, documented gap, not a
silent one.
const headers = new Map<string, string>();
headers.set("X-Request-Id", "abc123");
const r = http.request("https://postman-echo.com/post", "POST", "hello=world", headers);
console.log(r.status); // 200
console.log(r.ok); // true
http.get(url) Wasm: limitedstring → { status, body, ok, headers }
— http.request(url, "GET", "", ...) with an empty
headers map.
const r = http.get("https://example.com");
console.log(r.status); // 200
console.log(r.body.includes("Example Domain")); // true
http.createServer(port, handler)(int, (HttpRequest) → HttpResponse) → void —
accepts connections forever, never returns. handler must
be a named function (block-bodied inline arrow functions don't parse
in this position yet), and returns the same { status,
body, ok, headers } record the client side uses (ok
goes unused server-side; headers is written into the real
response, one line per entry). Superseded the old canned-response
serve() global, which always returned the same body to
every request; this one genuinely inspects each request.
function handleRequest(req: HttpRequest): HttpResponse {
const headers = new Map<string, string>();
headers.set("X-Powered-By", "lumen");
if (req.path == "/notfound") {
return { status: 404, body: "not found", ok: false, headers: headers };
}
return { status: 200, body: "hello from lumen (" + req.method + ")", ok: true, headers: headers };
}
http.createServer(8080, handleRequest);
Concurrent: each accepted connection's
full handling (including its keep-alive requests) now runs on a
worker thread from a dedicated thread pool, so accept()
loops back for the next connection immediately instead of waiting for
earlier ones to finish -- a genuinely blocking or slow handler on one
connection no longer stalls every other client. Verified against a
real compiled binary: three concurrent requests to a handler doing
~5s of real CPU work each completed in ~5.1s total (not ~15s), and a
trivial request fired mid-flight against a slow one returned in under
a millisecond. A handler now genuinely runs on multiple OS
threads at once — a handler that reads or mutates
shared global state without its own synchronization has a real data
race, unlike Node's single-threaded http.createServer;
Lumen has no general-purpose locking primitive yet, so this is a
documented trade-off, not a solved problem. Still no idle-connection
timeout: a connection stays open as long as the client keeps sending
requests. (Under --wasm: unchanged from before, still a
single-connection-at-a-time loop -- wasm32-wasi has no real OS
threads.)
~1.1–1.2× faster than Node's native
http.createServer on 300 sequential GETs (same
Node.js client hitting both servers, isolating the server as the only
variable): ~139ms vs Node's ~157ms. Wasn't always true — the
first version closed the connection after every response, forcing a
fresh TCP handshake per request and running ~1.3–1.5×
slower than Node; this server now supports HTTP keep-alive
(the connection stays open and serves further requests until the
client sends Connection: close), the same thing Node's
server does by default.
http.METHODS()() → string[] — the 35 HTTP method
name strings ("ACL", "BIND", ...,
"UNSUBSCRIBE"), alphabetically sorted, matching Node's own
http.METHODS value exactly. Called as a zero-arg function
rather than read as a property, the same shape Math.PI()/
os.EOL() already use — Lumen has no static namespace
property access.
const methods = http.METHODS();
console.log(methods.length); // 35
http.STATUS_CODES()() → Map<int, string> — every
standard HTTP status code (100 through 511) mapped to its reason
phrase, verbatim from Node's own STATUS_CODES table.
const codes = http.STATUS_CODES();
console.log(codes.get(404) ?? "?"); // "Not Found"
Experimental — new, manually tested rather than conformance-suite covered.
Raw TCP sockets — the layer http's own
client/server are already built on, exposed directly for protocols that
aren't HTTP. No TLS (matching Node's own split between net
and tls).
net.connect(host, port) Wasm: limited(string, int) → Socket — blocking
connect, real DNS resolution for hostnames (via the same
std.Io.net.HostName.connect path Lumen's own
http.request client already uses internally to reach a
hostname, not a new, unverified code path) as well as literal IPs. A
failed connect (refused, unknown host, timeout) doesn't throw —
it degrades to a Socket whose .read()
always returns "" and .write() is a no-op,
the same "fallback, don't crash" convention
fs.createReadStream already established for a
missing file.
const sock: Socket = net.connect("127.0.0.1", 9301);
sock.write("hello\n");
console.log(sock.read());
sock.close();
net.createServer(port, handler) Wasm: limited(int, (Socket) → void) → void — accepts
connections forever, never returns. Unlike
http.createServer's (HttpRequest) → HttpResponse
handler (a call-and-return shape that fits HTTP's own framing), a raw
TCP handler gets the Socket itself and drives it in
whatever order its own protocol needs. The socket is closed
automatically once handler returns, whether or not the
handler already called .close() itself (idempotent,
no double-close error) — a handler that forgets to close won't
leak the connection for the life of the server.
function handleConn(sock: Socket): void {
const line = sock.read();
sock.write("echo:" + line);
}
net.createServer(9301, handleConn);
Single connection at a time, unlike
http.createServer's thread-pool-backed concurrency:
nothing analogous has been benchmarked yet for a raw-bytes protocol
with no request/response cadence to measure against, so a slow or
long-lived handler blocks new connections from being accepted until
it returns. The same worker-thread-pool mechanism
http.createServer already uses is a direct, mechanical
extension here later; not attempted this pass so the Socket
type itself stayed the focus of this slice.
Socket.read()() → string — the next chunk
(bounded by a fixed 64KB internal buffer, matching
ReadableStream.read()'s exact convention).
"" at EOF, on a closed socket, or on a socket that never
connected — a real empty chunk and end-of-stream are
indistinguishable here, the same simplification Streams (spec 046)
already documented and accepted.
Socket.write(chunk)string → void — writes and flushes
immediately on every call, unlike WritableStream.write()
(which defers flushing to .close()): a long-lived socket
conversation has no single "I'm done" moment the way a one-shot file
write does, so buffering until some later .close() could
mean the peer never sees the bytes in time. No-op on a closed or
never-connected socket.
Socket.close()() → void — closes the underlying
connection. Safe to call more than once, or on a socket that never
connected.
Experimental — new, manually tested rather than conformance-suite covered.
Automatic (de)serialization for Lumen's record types, so no
custom per-field (de)serialization code was needed. Deviation from
Node: JSON.parse<T> takes an explicit type
argument (there's no dynamic/any value to hand back). Invalid
input throws a catchable Error (like JS's
SyntaxError) — wrap it in try/catch.
Works for primitives, named record types, and arrays of either;
Map/Set/tuples are not supported as
T this pass (see Planned below).
JSON.stringify(value)T → string — T inferred from
value, the same as every other Lumen builtin. Empty string
on an encode failure.
type Person = { name: string, age: int, active: bool };
const p: Person = { name: "Ada", age: 30, active: true };
console.log(JSON.stringify(p)); // {"name":"Ada","age":30,"active":true}
JSON.parse<T>(text)string → T — the first explicit type argument on a
namespace call in Lumen (every other generic call site is a free
function/class). Throws a catchable Error on invalid input.
type Person = { name: string, age: int, active: bool };
const p: Person = JSON.parse<Person>('{"name":"Grace","age":45,"active":false}');
console.log(p.name); // Grace
try {
const broken: Person = JSON.parse<Person>("not json");
} catch (e) {
console.log(e.message); // JSON.parse: invalid JSON (...)
}
Experimental — new, manually tested rather than conformance-suite covered.
A practical subset of
Node's
zlib module: one-shot gzip and raw-deflate compress/
decompress. Static functions, not Node's zlib.createGzip()
transform-stream style — there's no stream/Buffer object
in the language yet, and one-shot whole-buffer compression covers the
common case (config blobs, already-buffered HTTP bodies, small payloads).
Buffer-less v1 shape: Lumen's string is
[]const u8 — raw bytes, not validated UTF-8 (the same
property crypto.randomBytes/sha256 already rely
on) — so compressed binary output flows through string
directly rather than a dedicated byte-buffer type, which doesn't exist
yet.
zlib.gzipSync(data)string → string — compresses data's
bytes into a real gzip container (RFC 1952: 10-byte header, deflate
body, CRC32 + size footer). The result is genuine gzip framing, not a
private format — verified by writing it to a .gz file
and decoding it with the system gunzip.
const input = "hello hello hello hello hello";
const gz = zlib.gzipSync(input);
console.log(gz.length < input.length); // true
zlib.gunzipSync(data)string → string — inverse of gzipSync.
Deviation from Node: on any decode error (bad header,
truncated stream, checksum mismatch, or plain non-gzip garbage) returns
"" rather than throwing — the same fallback-don't-crash
shape every other fallible builtin here uses. A real gzip stream never
decodes to "" in-band, so this is unambiguous as an error
sentinel.
const input = "round trip me";
const back = zlib.gunzipSync(zlib.gzipSync(input));
console.log(back == input); // true
console.log(zlib.gunzipSync("not gzip data") == ""); // true
zlib.deflateSync(data)string → string — raw deflate body only (RFC 1951),
no gzip/zlib header or footer. 18 bytes smaller than
gzipSync's output on the same input (the gzip container's
10-byte header + 8-byte footer).
const input = "hello hello hello hello hello";
console.log(zlib.deflateSync(input).length < zlib.gzipSync(input).length); // true
zlib.inflateSync(data)string → string — inverse of
deflateSync; same ""-on-error fallback as
gunzipSync.
const input = "round trip me";
console.log(zlib.inflateSync(zlib.deflateSync(input)) == input); // true
Experimental — new, manually tested rather than conformance-suite covered.
A real, distinct byte-array type — not string
under another name. Lumen's string is already raw
[]const u8 (not validated UTF-8), so Buffer could
have been just method-name sugar over it; it is a separate type instead so
passing raw binary data where text is expected (or vice versa) is a
compile-time Buffer/string type mismatch, not a
silent runtime bug — the same call Map/Set/
ReadableStream made. Built the same way: a dedicated heap-pointer
type, constructed via a Buffer static namespace rather than
new. Not yet wired into crypto (which still returns
hex/base64 strings), streams (still string-chunked),
or JSON — each a real, separate follow-up migration of an
already-shipped contract, not bundled into the pass that introduces the type
itself (see the roadmap below).
Buffer.from(s) · Buffer.from(s, encoding)string → Buffer — the one-argument form
takes s's raw bytes verbatim, no decoding. The two-argument
form decodes s under "utf8" (same as one-argument,
the default) | "hex" | "base64".
Deviation from Node: an unrecognized encoding string falls
back to raw "utf8" bytes rather than throwing
ERR_UNKNOWN_ENCODING — the same "fallback, don't crash"
convention every other fallible builtin here uses; malformed hex (odd
length, non-hex characters) or malformed base64 similarly fall back to an
empty Buffer instead of throwing.
const b1 = Buffer.from("hi");
console.log(b1.toString("hex")); // 6869
const b2 = Buffer.from("6869", "hex");
console.log(b2.toString("utf8")); // hi
Buffer.alloc(n)int → Buffer — n zeroed
bytes. A negative n clamps to 0 rather than
erroring.
const b = Buffer.alloc(4);
console.log(b.length); // 4
console.log(b.at(0)); // 0
Buffer.length() → int (field syntax, no parens) — the byte count.
Follows Map/Set's .size precedent
(a method call under a property-syntax surface, since Buffer
is heap-pointer-wrapped, not a raw slice at the Lumen type level), not
string/array's raw-slice .length.
console.log(Buffer.from("hello").length); // 5
Buffer.toString(encoding)string → string — "utf8" (raw
passthrough) | "hex" | "base64"; an unrecognized
encoding falls back to "utf8".
console.log(Buffer.from("hi").toString("base64")); // aGk=
Buffer.at(i)int → int — the byte value (0-255) at index
i; out of range (including negative i) returns
0 rather than crashing. Deviation from Node:
there is no buf[i] index syntax — Lumen's [i]
index checker is array-type-specific today, so .at(i) is the
only way to read a byte.
const b = Buffer.from("A");
console.log(b.at(0)); // 65
console.log(b.at(99)); // 0 -- out of range, not a crash
Buffer.slice(start, end)(int, int) → Buffer — a new
Buffer over [start, end), clamped into range the
same way string.slice behaves.
console.log(Buffer.from("hello").slice(1, 3).toString("utf8")); // el
Buffer.equals(other)Buffer → bool — byte-for-byte
comparison.
console.log(Buffer.from("abc").equals(Buffer.from("abc"))); // true
console.log(Buffer.from("abc").equals(Buffer.from("abd"))); // false
Experimental — new, manually tested rather than conformance-suite covered.
Real CPU parallelism: a Lumen function runs on its own
native OS thread. Node's worker_threads loads a
separate script file into an isolated realm —
Lumen has no equivalent (one program compiles to one static binary, no
second module to dynamically load), so Worker.run takes a
Lumen function value instead of a file path, the
closest honest adaptation of the same idea. Deliberately a flat,
one-shot namespace call (Worker.run(fn)) rather than a
new Worker(...) object — Node's class shape exists for
a stateful, message-passing, long-lived entity
(postMessage/.on('message')/.terminate())
that Lumen has no channel primitive for yet; shipping the constructor
without any of the methods it implies would be worse than the flat shape
actually shipped.
Worker.run(fn)(() => T) → Promise<T>, T one
of i32 | i64 | f64 | bool
— spawns a real, detached OS thread (one Worker = one thread, so
a CPU-bound call never queues behind unrelated work), runs
fn on it, and resolves the returned Promise
once it finishes. fn may be a plain named function (no
captures at all) or an arrow capturing only scalar outer bindings
— Lumen's
existing closure lowering copies each capture by value into a
heap-allocated environment at the closure's creation point, so a
captured scalar is a true snapshot with no live aliasing back to the
caller's binding.
function fib(): i32 {
let a: i32 = 0;
let b: i32 = 1;
let i: i32 = 0;
while (i < 40) {
const next = a + b;
a = b;
b = next;
i = i + 1;
}
return a;
}
async function main(): Promise<void> {
const p = Worker.run(fib);
console.log("worker launched, main keeps going");
console.log(await p);
}
What's safe to cross the thread boundary, stated
narrowly on purpose: only the four scalar types above, checker-
enforced — Lumen has no borrow checker and no
Send/Sync distinction, so strings, arrays,
Map/Set/Buffer, and class
instances are deliberately not accepted as T yet (not
verified safe without a real deep-copy/capture-safety analysis).
This does not, by itself, make every closure safe:
capturing a reference-shaped outer binding (an array,
Map/Set/Buffer, a class
instance) only copies the pointer/slice header into the worker's
closure — the pointee is still the same shared, unsynchronized
memory. If the main thread (or another Worker) mutates it while the
worker reads or writes it too, that's a real data race, the
same kind of trade-off http.createServer's concurrent
handlers already document above. Not statically prevented this pass.
For Map/Set specifically (spec 492) this is no
longer silent: each instance detects an overlapping access from another
thread and stops with an explicit runtime error instead of corrupting
its own storage or crashing somewhere unrelated — still not safe
to rely on, but no longer invisible when it happens. Arrays,
Buffer, and class instance fields have no such guard yet
and remain a real, silent data race.
The surface grows in explicit, conformance-backed slices. These modules use the same conventional names; some depend on upcoming language features (noted below).
| API | Notes |
|---|---|
console.assert(cond, msg) · console.time(label)/timeEnd(label) · console.count(label)/countReset(label) | real, scoped follow-up (small global timer/counter state in the generated runtime); not attempted in the pass that shipped warn/info/debug/trace |
console.group/groupEnd | needs indentation state threaded through every log call site, a bigger, cross-cutting change |
console.table · console.dir | need structured/tabular object formatting; today's printFormat is a flat per-type format-string lookup |
console.clear | terminal-control-code territory, low value |
Console class / custom-stream constructor (new Console(stdout, stderr)) | needs a Stream abstraction Lumen doesn't have (the same gap blocking process.stdout/stdin/stderr) |
| API | Notes |
|---|---|
fs.promises.* / most other async callback variants (fs.rename, fs.chmod, fs.readdir, ... most of Node's ~54) | fs.readFile/writeFile/appendFile are true async (io_uring, no thread); fs.unlink/mkdir/rmdir/stat are thread-pool-backed (spec 047) -- every other one needs the same thread-pool wrapping, mechanically the same pattern now that it's proven, just not done yet |
fs.watch recursive watching, non-Linux platforms; fs.watchFile/unwatchFile | fs.watch(path, listener) itself and its create/change/rename event-type distinction shipped (Linux, flat, one filename per event); each of these is a real, separate follow-up |
fs.opendirSync(path) · fs.globSync(pattern) | need a directory-iterator class / a real glob algorithm |
fs.statfsSync(path) | filesystem-level stats; platform-specific, low value for now |
fs.openAsBlob(path) | no Blob type in the language |
| API | Notes |
|---|---|
path.relative(from, to) | not attempted yet, though path.resolve shipping real cwd access removes what used to be the blocker -- a real, separate follow-up now, not a new gap |
path.matchesGlob(path, pattern) | needs a real glob algorithm |
path.win32 / path.posix / path.toNamespacedPath | Lumen targets POSIX only |
| API | Notes |
|---|---|
process.stdout / stdin / stderr | no Stream abstraction in the language |
signal events, process.on(...) | process.kill() can send a signal now, but nothing can receive one back into user code — no event/listener infrastructure yet |
process.nextTick(fn) | needs a design decision about what happens when it's called in a program with no other async machinery running (a plain sync-looking program never starts the event loop today) |
process.resourceUsage() · process.cpuUsage() · process.threadCpuUsage() | no per-thread/per-process CPU-time accounting primitive vetted yet; memoryUsage() covers the highest-value single field |
process.send() / .disconnect() / .channel (IPC) | no persistent child-process channel — child_process.spawnSync is a synchronous one-shot |
process.report.* · .permission.* · .finalization.* | advanced/niche Node-internals surface, out of scope |
process.versions · .release · .config · .features.* | Node-build-metadata specific (V8/OpenSSL/ICU versions); not meaningful here. process.version() itself ships as a distinct Lumen-specific marker |
process.title · .execPath · .argv0 · .mainModule · .dlopen · .execve | niche/process-replacement-level operations |
| API | Notes |
|---|---|
os.cpus() | needs a record array parsed from /proc/cpuinfo and /proc/stat; no single-syscall shortcut |
os.networkInterfaces() | needs interface enumeration (a getifaddrs equivalent) |
os.userInfo() | needs a passwd-database lookup, the same libc gap as homedir()'s Node fallback |
os.getPriority() · os.setPriority() | no wrapped primitive available; would need a raw syscall number |
os.constants | low value without the functions that consume it |
| API | Notes |
|---|---|
crypto.createCipheriv/streaming AEAD | a stateful encrypt/decrypt object is a real, separate shape (associated-data ordering, partial-block buffering) from createHash/createHmac's pure hash accumulator (spec 060); not attempted yet |
crypto.sign()/verify() | asymmetric/keyed crypto, a much larger surface than this milestone's scope |
crypto.pbkdf2() · crypto.scrypt() | deliberately deferred rather than rushed; picking the right default cost parameters deserves its own pass |
| Algorithms beyond md5/sha1/sha256/sha512 | createHash/createHmac (spec 060) cover those four; sha224/sha384/blake2/blake3/etc. are a real, separable expansion once there's a concrete need |
| API | Notes |
|---|---|
a real URL class / .searchParams / .toString() | Node's modern API is class-based; the older function-based parse/format fit the static-function stdlib pattern better for v1 |
username/password/auth fields | niche; straightforward to add later |
relative-to-base URL resolution (new URL(relative, base)) | a separate feature from parsing one string |
| API | Notes |
|---|---|
exec/execSync (shell-string form) | needs shell-quoting/escaping logic to be safe; spawnSync's array-of-args form sidesteps this for v1 |
spawn (async, streaming stdio) | needs the same event-loop integration the async fs trio got, plus a design for streaming stdio |
cwd/env/timeout options | the underlying primitive already supports these; just not exposed as spawnSync parameters yet |
| stdin piping · signal-based exit status | spawnSync's one-shot model doesn't need stdin; no optional-string-or-int union exists yet to represent a signal-based exit |
| API | Notes |
|---|---|
a.push/pop/sort | needs growable arrays |
| API | Notes |
|---|---|
| Idle keep-alive connection timeouts | a connection stays open indefinitely as long as the client keeps sending requests and doesn't send Connection: close |
| Response headers on the client side | http.request's own request headers and http.createServer's response headers both shipped, via Map<string, string>; reading real response headers on the client needs a lower-level request flow than what's wired up yet -- confirmed reachable, deliberately deferred rather than rewriting an already-working, already-benchmarked call under time pressure |
| Case-insensitive header lookup, repeated header/query values as an array | Map<string, string>'s exact-match, last-write-wins lookup is what's available now |
| A general-purpose locking primitive for handler-shared state | concurrent serving (shipped) means a handler now genuinely runs on multiple OS threads; a handler that mutates shared global state without its own synchronization has a real data race. Map/Set now detect an overlapping access and fail loudly instead of corrupting or crashing unpredictably (spec 492), but that is a diagnostic, not a fix -- a handler still cannot safely share mutable state across a concurrent server's connections, since Lumen has no Mutex-shaped stdlib type yet |
Real Server/IncomingMessage/ServerResponse/ClientRequest/Agent classes | Node's classes bundle request/response data with an event mechanism; EventEmitter alone doesn't supply the data half, which still needs the header-collection/streaming gaps closed first |
Server lifecycle events ('error', 'close') via EventEmitter<T> | genuinely reachable now that EventEmitter exists — a real follow-up |
| Streaming request/response bodies | needs a network-backed ReadableStream/WritableStream (spec 046 shipped file-backed streams only, a deliberately separate backing type -- see spec 046's "why file-backed only") |
| HTTPS/TLS-specific configuration | std.http.Client already handles basic TLS internally for https:// URLs; exposing it as configurable is a real, separate feature, disproportionately large for a single pass |
| WebSocket upgrade | a full protocol implementation (handshake, frame parsing/masking, ping/pong) -- a separate feature on the scale of http itself, not an extension of it |
| API | Notes |
|---|---|
zlib.gzip/unzip (async, callback or Promise-based) | every other module's *Sync functions have shipped first before their async twins get a dedicated pass (see fs.readFileSync predating fs.readFile's thread-pool design); compression is comparatively rare on a hot path, sync-first is the right order here too |
zlib.createGzip()/createDeflate() streaming transform | needs a stateful stream/duplex object the language doesn't have yet; one-shot whole-buffer compression covers the common case |
Brotli (zlib.brotliCompressSync/etc.) | not in std.compress at all in this Zig version (only flate, zstd, lzma/lzma2, xz exist) — would need vendoring a C library or a from-scratch implementation |
| zstd/xz/lzma wrappers | std.compress ships these too, but Node's own zlib module only covers gzip/deflate/brotli — out of scope for a module matching that name/surface |
| A compression-level parameter | the stdlib's wrappers so far are plain positional-arg functions (see crypto.randomBytes(n)), not an options-object style; can be added as an optional second argument later |
| API | Notes |
|---|---|
crypto.* returning Buffer instead of hex/base64 strings | a real audit of every existing crypto call site and its current string-returning contract -- a breaking change to an already-shipped API, deliberately not bundled into the pass that first introduces the type it would return |
ReadableStream/WritableStream chunks as Buffer instead of string | spec 046 already shipped and is exercised as string-chunked; switching its chunk type is a separate, real migration |
JSON (de)serialization of Buffer | no existing convention in this codebase for how a byte array should round-trip through JSON (Node uses {"type":"Buffer","data":[...]}); not decided here |
Buffer.concat([...]) | needs a decision on how a Lumen array of Buffer values is spelled (Buffer[] -- an untested combination of a new bare type with array-of syntax) before it's worth building |
buf[i] index syntax | the indexing checker is array-type-specific; .at(i) covers the same need today |
| API | Notes |
|---|---|
new Worker(...) with postMessage/on('message')/terminate() | needs a real cross-thread mutable message channel Lumen has no primitive for yet; shipping the constructor shape without the instance methods it implies would be worse than the flat Worker.run shape actually shipped |
Non-scalar T (strings, arrays, Map/Set/Buffer, objects, class instances) crossing the boundary | not verified safe this pass without a real capture/deep-copy-semantics analysis; scalars alone are enough to prove the thread-spawn + promise-handback mechanism works correctly |
| Static prevention of unsafe reference-shaped captures | would need a real capture/Send-style analysis in the checker; documented as a known hazard instead, the same way http.createServer's multi-threaded-handler trade-off above is documented rather than solved |
A persistent worker pool / reusable Worker handle | no concrete use case needs one yet; one detached thread per Worker.run() call is simpler and sufficient |
| Worker cancellation / timeouts | not exercised by the v1 function set — each call is a single run-to-completion function, no cancellation hook exists to wire up |
--wasm support | Worker.run requires the async/Promise event-loop machinery, which already hits the existing wasm-vs-@import("xev") gate — wasm32-wasi has no real OS threads anyway |
Beyond the built-in surface, curated packages live in the
std-contrib repo and
are imported by URL. They include quickjs, which embeds a
JavaScript sandbox through the C FFI for running untrusted scripts from Lumen.
Lumen is statically typed and has no dynamic JSON value
— JSON.parse<T> requires a type argument and gives you
exactly that shape back, never a shape you inspect at runtime. The built-in
library is a fixed, contracted surface; everything else ships as explicit
community packages.