Migrating from Bun
Bun's Web-standard surface carries over unchanged. What moves is the Bun.* namespace: each ambient global becomes an explicit runtime: import.
1. File handling (Bun.file, Bun.write)
// ❌ Bun const text = await Bun.file("./data.txt").text(); const json = await Bun.file("./data.json").json(); await Bun.write("./out.txt", "data"); // ✅ esrun import { file, write } from "runtime:fs"; const text = await file("./data.txt").text(); const json = await file("./data.json").json(); await write("./out.txt", "data");
2. Subprocesses (Bun.spawn, Bun.$)
// ❌ Bun const proc = Bun.spawn(["git", "rev-parse", "HEAD"]); const text = await new Response(proc.stdout).text(); // ✅ esrun import { Command } from "runtime:system"; const { stdout } = await new Command("git", { args: ["rev-parse", "HEAD"], }).output(); const text = new TextDecoder().decode(stdout).trim();
3. Hashing and passwords (Bun.hash, Bun.CryptoHasher, Bun.password)
// ❌ Bun const key = Bun.hash(data); const digest = new Bun.CryptoHasher("sha256").update("data").digest("hex"); const stored = await Bun.password.hash(input); // ✅ esrun import { hash, Hasher, password } from "runtime:hashing"; const key = hash("xxhash64", data); const digest = new Hasher("sha256").update("data").digest("hex"); const stored = await password.hash(input);
4. Cookies (Bun.Cookie, Bun.CookieMap)
The same classes, imported from runtime:http. request.cookies works the same way in serve(): changes are sent with the response.
// ❌ Bun Bun.serve({ fetch(req) { req.cookies.set("theme", "dark"); return new Response("ok"); } }); const cookie = new Bun.Cookie("sid", id, { httpOnly: true }); // ✅ esrun import { serve, Cookie } from "runtime:http"; serve((req) => { req.cookies.set("theme", "dark"); return new Response("ok"); }); const cookie = new Cookie("sid", id, { httpOnly: true });
Output matches Bun's, with one intentional difference: a cookie a browser would drop throws instead of being sent. That covers:
SameSite=NoneorPartitionedwithoutsecure;a
__Host-/__Secure-cookie that breaks its prefix rules;a fractional
maxAge.
When you delete a __Host- or __Secure- cookie, pass { secure: true }. Otherwise the deletion is refused, where in Bun the browser would have ignored it. Cookie.parse also decodes the value, as CookieMap does in both runtimes.
5. Async context (node:async_hooks)
Bun's AsyncLocalStorage becomes runtime:context — a rename, with no async_hooks shim.
// ❌ Bun 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;
| Bun | esrun |
|---|---|
new AsyncLocalStorage() | createContext({ name }) |
.getStore() | .get() |
.run(store, fn) | .run(value, fn) |
.enterWith() | Removed — snapshot() |
AsyncResource | Removed |
6. Observability and process metrics
// ❌ Bun process.memoryUsage().heapUsed; process.cpuUsage().user; // microseconds import { heapStats } from "bun:jsc"; // ✅ esrun import { memoryUsage, cpuTime, uptime } from "runtime:process"; import { metrics, subscribe } from "runtime:diagnostics"; memoryUsage().heapUsed; cpuTime(); // milliseconds, this agent's thread metrics().gc.pauseMs.p99; // needs --allow-diagnostics
| Bun | esrun | Needs |
|---|---|---|
process.memoryUsage().heapUsed | memoryUsage().heapUsed | — |
process.memoryUsage().rss | metrics().process.rss | diagnostics |
process.cpuUsage() { user, system } | cpuTime() — one total | — |
bun:jsc heapStats() | metrics().gc | diagnostics |
Bun.gc(true) | Removed — no forced collection | — |
--console-depth, ad-hoc tracing | subscribe(filter, onBatch) | diagnostics |
| OpenTelemetry SDK | esrun --otel=<url> | — |
Units and scope both change: milliseconds rather than microseconds, and the per-agent figures describe the calling agent rather than the process — which is what makes them answerable inside a worker. The process-wide ones are in metrics(), behind --allow-diagnostics.
See the Request context and Observing the runtime guides.
7. Images (Bun.Image)
runtime:images has Bun.Image's chain: the same transform names, the same format methods and the same terminals, so most pipelines move with an import and a rename.
// ❌ Bun const thumb = await Bun.file("photo.jpg").image() .resize(400, 400, { fit: "inside" }) .webp({ quality: 80 }) .bytes(); await new Bun.Image(upload).resize(1600).write("public/large.avif"); // ✅ esrun import { Image } from "runtime:images"; import { file } from "runtime:fs"; const thumb = await new Image(file("photo.jpg")) .resize(400, 400, { fit: "inside" }) .webp({ quality: 80 }) .bytes(); await new Image(upload).resize(1600).avif().write(file("public/large.avif"));
What carries over unchanged
resize(width, height?, { fit, filter, withoutEnlargement }) with "fill" and "inside"; rotate, flip, flop; modulate({ brightness, saturation }); jpeg, png, webp and avif with quality, and webp({ lossless }); bytes() and blob(); the maxPixels and autoOrient options with the same defaults; and the error codes ERR_IMAGE_DECODE_FAILED, ERR_IMAGE_FORMAT_UNSUPPORTED and ERR_IMAGE_TOO_MANY_PIXELS.
What changes
| Bun | esrun | Why |
|---|---|---|
new Bun.Image("photo.jpg") | new Image(file("photo.jpg")) | A path string is a TypeError. A file() is read through runtime:fs, so --allow-read and the project jail apply. Bun's docs warn that a path argument is an arbitrary-file-read primitive. |
Bun.file(p).image() | new Image(file(p)) | |
img.write("out.webp") | img.webp().write(file("out.webp")) | The format is the one chained, or else the source's. The extension is never read, so write(file("out.webp")) with no format method writes the source's format. Writing needs --allow-write. |
img.buffer() | img.bytes() | There is no Buffer. |
img.toBase64() | (await img.bytes()).toBase64() | |
img.dataurl() | `data:image/webp;base64,${(await img.webp().bytes()).toBase64()}` | |
new Response(img) | new Response(await img.bytes(), { headers: { "content-type": "image/webp" } }) | An Image is not a body yet. |
img.width, img.height after a terminal | (await img.metadata()).width | metadata() works at any point in a chain, before anything is encoded. |
metadata() → { width, height, format } | { width, height, format, pixelFormat, hasAlpha, animation } | A superset. |
Not available
| Bun | Instead |
|---|---|
heic(), HEIC input | No HEIC codec. Convert to AVIF or JPEG before upload. |
placeholder() | No ThumbHash yet. A tiny resize(16).webp() inlined as a data URL is the nearest. |
jpeg({ progressive }) | Baseline JPEG only. The option is a TypeError. |
png({ compressionLevel, palette, colors, dither }) | A TypeError. gif() writes a palette image. |
mks2013, mks2021 filters | "lanczos3" (the default) or "mitchell". |
Bun.Image.fromClipboard() and the clipboard helpers | None. A server has no clipboard. |
Bun.Image.backend | None. There is one backend. |
Added
The
"outside","cover"and"contain"fits, andbackgroundfor"contain".crop,blur,sharpen,flatten,grayscale,extractChannelandmodulate({ hue }).gif()andtiff()output.AVIF and TIFF on Linux, where Bun has neither.
The
toSrgbandanimatedoptions.