Migrating from Node.js
1. Environment and process globals
In Node.js process is ambient. In esrun, env, args, cwd and exit are imported from runtime:process, and reading any of them needs --allow-env.
// ❌ Node.js const port = process.env.PORT || 8080; const cliArgs = process.argv.slice(2); const currentDir = process.cwd(); process.exit(0); // ✅ esrun import { env, args, cwd, exit } from "runtime:process"; const port = env.PORT || 8080; const cliArgs = args; // Script arguments (excluding binary/script names) const currentDir = cwd(); exit(0);
2. Filesystem operations (node:fs)
Synchronous and callback node:fs calls become the promise-based, Blob-like surface in runtime:fs. Every path is resolved inside the project root jail, and reads and writes are two separate grants.
// ❌ Node.js import { readFileSync, writeFileSync, appendFileSync, existsSync } from "node:fs"; if (existsSync("./config.json")) { const raw = readFileSync("./config.json", "utf8"); writeFileSync("./out.txt", "processed"); appendFileSync("./app.log", "done\n"); } // ✅ esrun import { file, write } from "runtime:fs"; const cfg = file("./config.json"); if (await cfg.exists()) { const text = await cfg.text(); // UTF-8 text const json = await cfg.json(); // Parsed JSON const bytes = await cfg.bytes(); // Uint8Array await write("./out.txt", "processed"); await write("./app.log", "done\n", { append: true }); }
3. Path resolution (__dirname, __filename, node:path)
__dirname and __filename are CommonJS, and import.meta here carries url and resolve only. Derive them from import.meta.url with runtime:path.
// ❌ Node.js (CommonJS) const path = require("path"); const file = path.join(__dirname, "data", "app.db"); // ✅ esrun import { join, dirname, fromFileURL } from "runtime:path"; const __filename = fromFileURL(import.meta.url); const __dirname = dirname(__filename); const dbPath = join(__dirname, "data", "app.db");
4. Subprocesses (node:child_process)
Replace child_process.spawn() or exec() with Command from runtime:system.
// ❌ Node.js import { spawn } from "node:child_process"; const child = spawn("git", ["status"]); // ✅ esrun (Collect output) import { Command } from "runtime:system"; const { code, stdout, stderr } = await new Command("git", { args: ["status"], }).output(); const outputText = new TextDecoder().decode(stdout);
// ✅ esrun (Stream stdin / stdout with Web Streams) import { Command } from "runtime:system"; const child = await new Command("ffmpeg", { args: ["-i", "pipe:0", "-f", "mp3", "pipe:1"], stdin: request.body, // Web ReadableStream / Response body streams directly in }).spawn(); return new Response(child.stdout); // Web ReadableStream streams directly out
5. Binary data and buffers (node:buffer)
Buffer becomes Uint8Array, with TextEncoder/TextDecoder for text and btoa()/atob() for base64.
// ❌ Node.js const buf = Buffer.from("Hello World", "utf8"); const b64 = buf.toString("base64"); const back = Buffer.from(b64, "base64").toString("utf8"); // ✅ esrun (Standard Web APIs) const encoder = new TextEncoder(); const decoder = new TextDecoder(); const bytes = encoder.encode("Hello World"); // Uint8Array const b64 = btoa(decoder.decode(bytes)); const back = decoder.decode(encoder.encode(atob(b64)));
6. Cryptography and hashing (node:crypto)
createHash, createHmac and timingSafeEqual become runtime:hashing; keys, signatures and ciphers become crypto.subtle; randomUUID and randomBytes become crypto.
// ❌ Node.js import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto"; const id = randomUUID(); const digest = createHash("sha256").update("data").digest("hex"); const mac = createHmac("sha256", secret).update(body).digest("hex"); // ✅ esrun import { hash, Hasher, hmac, timingSafeEqual } from "runtime:hashing"; const id = crypto.randomUUID(); const digest = hash("sha256", "data", "hex"); const mac = hmac("sha256", secret, body, "hex"); // Chunked, for anything too large to hold at once. const h = new Hasher("sha256"); for await (const chunk of stream) h.update(chunk); h.digest("hex");
| Node | esrun |
|---|---|
createHash(alg) | hash(alg, data, "hex") or new Hasher(alg) |
createHmac(alg, key) | hmac(alg, key, data, "hex") |
timingSafeEqual(a, b) | timingSafeEqual(a, b) |
scrypt, pbkdf2 | password.hash() / crypto.subtle.deriveBits |
bcrypt, argon2 (npm) | password.hash() / password.verify() |
randomUUID, randomBytes | crypto.randomUUID(), crypto.getRandomValues() |
createSign, createCipheriv, generateKeyPair | crypto.subtle |
// Passwords: no npm package, and the parameters travel with the hash. import { password } from "runtime:hashing"; const stored = await password.hash(input); // argon2id if (await password.verify(input, user.hash) && password.needsRehash(user.hash)) { user.hash = await password.hash(input); }
7. Async context (node:async_hooks)
AsyncLocalStorage becomes runtime:context. The port is a rename; there is no async_hooks shim.
// ❌ Node.js import { AsyncLocalStorage } from "node:async_hooks"; const store = new AsyncLocalStorage(); store.run({ tenant }, () => handle(req)); store.getStore().tenant; // ✅ esrun import { createContext } from "runtime:context"; const store = createContext({ name: "request" }); store.run({ tenant }, () => handle(req)); store.get().tenant;
| Node | esrun |
|---|---|
new AsyncLocalStorage() | createContext({ name }) |
.getStore() | .get() |
.run(store, fn) | .run(value, fn) |
AsyncLocalStorage.snapshot() | snapshot() |
AsyncLocalStorage.bind(fn) | bind(fn) |
executionAsyncId() | currentTask().id — per task (a timer firing, a request), not per promise |
triggerAsyncId() | currentTask().parentId — the task that started this one |
.enterWith() | Removed — snapshot() |
.exit(fn) | Removed — run(undefined, fn) |
.disable() | Removed |
AsyncResource, createHook | Removed |
Two behaviours differ. An EventTarget listener runs in the dispatcher's mapping, not the one current when it was registered — wrap it in bind() to get Node's behaviour. And a worker starts with every context at its defaultValue; nothing is auto-injected across postMessage.
See the Request context guide.
8. Observability (node:diagnostics_channel, perf_hooks)
runtime:diagnostics replaces all three of Node's observability surfaces, and needs --allow-diagnostics.
// ❌ Node.js import { createHook } from "node:async_hooks"; import { monitorEventLoopDelay } from "node:perf_hooks"; createHook({ init(id, type) { … } }).enable(); const h = monitorEventLoopDelay(); h.enable(); // ✅ esrun import { subscribe, metrics } from "runtime:diagnostics"; subscribe({ kinds: ["op"] }, (batch) => { … }); metrics().loopLagMs.p99;
| Node | esrun |
|---|---|
createHook({ init, before, after, destroy }) | subscribe(filter, onBatch) |
diagnostics_channel subscribe/publish | subscribe() / span() |
monitorEventLoopDelay() | metrics().loopLagMs |
PerformanceObserver on gc | metrics().gc |
a manual live-resource Map | inventory() |
@opentelemetry/sdk-node | esrun --otel=<url> |
AsyncResource | Removed |
Four differences worth knowing before you port.
Delivery is batched per loop turn, not per event. A callback receives { records, dropped }, and with nothing subscribed no event reaches JavaScript at all. There is no per-resource callback to make fast.
A record never carries the resource, only an id and a kind — the thing that made async_hooks impossible to fix. inventory() is the supported way to ask what is open.
record.parentId is the enclosing span, not the trigger task. For Node's triggerAsyncId, use currentTask().parentId from runtime:context.
Exporting is the runtime's job. --otel=<url> ships OTLP without the program importing an SDK, holding net, or being able to read its own traces.
9. Process metrics (process.memoryUsage, cpuUsage, uptime)
// ❌ Node.js process.memoryUsage().heapUsed; process.cpuUsage().user; // microseconds process.uptime(); // seconds // ✅ esrun import { memoryUsage, cpuTime, uptime } from "runtime:process"; memoryUsage().heapUsed; cpuTime(); // milliseconds uptime(); // milliseconds
| Node | esrun | Scope |
|---|---|---|
memoryUsage().heapUsed / .external | memoryUsage().heapUsed / .external | this agent |
| — | memoryUsage().heapLimit | this agent |
memoryUsage().rss | metrics().process.rss | the process |
cpuUsage() { user, system } | cpuTime() — one total | this agent's thread |
| — | metrics().process.cpu | every thread |
uptime() | uptime() | this agent |
Units and scope both change. Node reports microseconds and seconds; these are all milliseconds. And Node's figures are process-wide, while these describe the calling agent — which is what makes them answerable in a worker. The process-wide ones moved to metrics(), behind --allow-diagnostics, because they describe the agents around you.
cpuUsage()'s user/system split has no equivalent: getting it on macOS needs a Mach struct whose layout a wrong guess turns into a memory-safety bug, so it is reported on no platform rather than on two of three.
See the Observing the runtime guide.