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.

JavaScript
// ❌ 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.

JavaScript
// ❌ 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.

JavaScript
// ❌ 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.

JavaScript
// ❌ 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);
JavaScript
// ✅ 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.

JavaScript
// ❌ 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.

JavaScript
// ❌ 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");
Nodeesrun
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, pbkdf2password.hash() / crypto.subtle.deriveBits
bcrypt, argon2 (npm)password.hash() / password.verify()
randomUUID, randomBytescrypto.randomUUID(), crypto.getRandomValues()
createSign, createCipheriv, generateKeyPaircrypto.subtle
JavaScript
// 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.

JavaScript
// ❌ 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;
Nodeesrun
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, createHookRemoved

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.

JavaScript
// ❌ 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;
Nodeesrun
createHook({ init, before, after, destroy })subscribe(filter, onBatch)
diagnostics_channel subscribe/publishsubscribe() / span()
monitorEventLoopDelay()metrics().loopLagMs
PerformanceObserver on gcmetrics().gc
a manual live-resource Mapinventory()
@opentelemetry/sdk-nodeesrun --otel=<url>
AsyncResourceRemoved

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)

JavaScript
// ❌ 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
NodeesrunScope
memoryUsage().heapUsed / .externalmemoryUsage().heapUsed / .externalthis agent
—memoryUsage().heapLimitthis agent
memoryUsage().rssmetrics().process.rssthe process
cpuUsage() { user, system }cpuTime() — one totalthis agent's thread
—metrics().process.cpuevery 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.

Last updated on
Edit this page