Standard library

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.

Available now

console

Stable

APITypeStream
console.log(x)any → voidstdout
console.info(x)any → voidstdout, alias of log
console.debug(x)any → voidstdout, alias of log
console.error(x)any → voidstderr
console.warn(x)any → voidstderr, alias of error
console.trace(x)any → voidstderr, prints Trace: x — no call stack (Lumen has none to show)

Math

Stable — thin wrappers over one of the oldest, least-churned corners of the runtime.

APIType
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_2number (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.NaNnumber (property; the Number.EPSILON() call form also still works)
Infinity · NaN · Number.NaN()number (global float constants)

String methods

Stable — pure slice operations, no I/O, conformance-covered.

Called directly on any string value.

APIType
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)

Array methods

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.

APIType
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)

Collections

Stable — built on long-stable, widely used container implementations.

Typed, generic containers, no dynamic shapes.

APINotes
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 typesfixed-length, per-slot types; t[0], t[1]

events

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.

APINotes
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.

async · await Native only

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.

APINotes
async function f(): Promise<T> { … }declares an async function
await exprawaits 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

C FFI

Experimental — strings and scalars only cross the boundary (no structs, callbacks, or arrays yet).

APINotes
declare function name(args): T;calls a C function directly (extern function also works)
// @link m · // @link ./shim.olink a library or object
string & scalar marshalingstring params/returns and numeric scalars cross the boundary

Errors

Stable — lowers to the runtime's native error-union/catch machinery, its primary error-handling mechanism.

APINotes
Error("message") · throw ethrow an error value
try { … } catch (e) { … } finally { … }e.message holds the message

fs

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 only

string → 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.mkdir(path) Experimental Native only

string → 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 only

string → Promise<void> — thread-pool backed, see fs.unlink above.

async function main(): Promise<void> {
  await fs.rmdir("build");
}
fs.stat(path) Experimental Native only

string → 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) Experimental

string → { 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) Experimental

int → { 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) Experimental

string → 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) Experimental

int → void — flush file data and metadata to disk.

fs.fsyncSync(fd);
fs.fdatasyncSync(fd) Experimental

int → 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) Experimental

string → 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) Experimental

string → 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) Experimental

string → 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();

path

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()); // :

process

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 objectkill() 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());

stdio streams

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());
}

readline

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) Experimental

string → 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);

os

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());

crypto

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) Experimental

int → 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) Experimental

string → 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) (BufferHash) 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

url

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

child_process

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

assert

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");

time

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);

http

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: limited

string → { 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"

net

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.

JSON

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 (...)
}

zlib

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

Buffer

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 → Buffern 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

worker_threads

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.

Planned

The surface grows in explicit, conformance-backed slices. These modules use the same conventional names; some depend on upcoming language features (noted below).

console

APINotes
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/groupEndneeds indentation state threaded through every log call site, a bigger, cross-cutting change
console.table · console.dirneed structured/tabular object formatting; today's printFormat is a flat per-type format-string lookup
console.clearterminal-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)

fs

APINotes
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/unwatchFilefs.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

path

APINotes
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.toNamespacedPathLumen targets POSIX only

process

APINotes
process.stdout / stdin / stderrno 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 · .execveniche/process-replacement-level operations

os

APINotes
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.constantslow value without the functions that consume it

crypto

APINotes
crypto.createCipheriv/streaming AEADa 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/sha512createHash/createHmac (spec 060) cover those four; sha224/sha384/blake2/blake3/etc. are a real, separable expansion once there's a concrete need

url

APINotes
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 fieldsniche; straightforward to add later
relative-to-base URL resolution (new URL(relative, base))a separate feature from parsing one string

child_process

APINotes
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 optionsthe underlying primitive already supports these; just not exposed as spawnSync parameters yet
stdin piping · signal-based exit statusspawnSync's one-shot model doesn't need stdin; no optional-string-or-int union exists yet to represent a signal-based exit

Array (more)

APINotes
a.push/pop/sortneeds growable arrays

http

APINotes
Idle keep-alive connection timeoutsa connection stays open indefinitely as long as the client keeps sending requests and doesn't send Connection: close
Response headers on the client sidehttp.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 arrayMap<string, string>'s exact-match, last-write-wins lookup is what's available now
A general-purpose locking primitive for handler-shared stateconcurrent 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 classesNode'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 bodiesneeds 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 configurationstd.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 upgradea full protocol implementation (handshake, frame parsing/masking, ping/pong) -- a separate feature on the scale of http itself, not an extension of it

zlib

APINotes
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 transformneeds 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 wrappersstd.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 parameterthe 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

Buffer

APINotes
crypto.* returning Buffer instead of hex/base64 stringsa 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 stringspec 046 already shipped and is exercised as string-chunked; switching its chunk type is a separate, real migration
JSON (de)serialization of Bufferno 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 syntaxthe indexing checker is array-type-specific; .at(i) covers the same need today

Worker

APINotes
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 boundarynot 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 captureswould 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 handleno concrete use case needs one yet; one detached thread per Worker.run() call is simpler and sufficient
Worker cancellation / timeoutsnot exercised by the v1 function set — each call is a single run-to-completion function, no cancellation hook exists to wire up
--wasm supportWorker.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

Community packages

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.