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)

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

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

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

The same classes, imported from runtime:http. request.cookies works the same way in serve(): changes are sent with the response.

JavaScript
// ❌ 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=None or Partitioned without secure;

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

JavaScript
// ❌ 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;
Bunesrun
new AsyncLocalStorage()createContext({ name })
.getStore().get()
.run(store, fn).run(value, fn)
.enterWith()Removed — snapshot()
AsyncResourceRemoved

6. Observability and process metrics

JavaScript
// ❌ 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
BunesrunNeeds
process.memoryUsage().heapUsedmemoryUsage().heapUsed—
process.memoryUsage().rssmetrics().process.rssdiagnostics
process.cpuUsage() { user, system }cpuTime() — one total—
bun:jsc heapStats()metrics().gcdiagnostics
Bun.gc(true)Removed — no forced collection—
--console-depth, ad-hoc tracingsubscribe(filter, onBatch)diagnostics
OpenTelemetry SDKesrun --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.

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

BunesrunWhy
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()).widthmetadata() works at any point in a chain, before anything is encoded.
metadata() → { width, height, format }{ width, height, format, pixelFormat, hasAlpha, animation, pages, … }A superset, with sharp's source fields.
jpeg({ progressive }), png({ compressionLevel, palette, colors, dither })The samedither may also be sharp's level from 0 to 1.

Not available

BunInstead
heic(), HEIC inputNo 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.
mks2013, mks2021 filters"lanczos3" (the default) or "mitchell".
Bun.Image.fromClipboard() and the clipboard helpersNone. A server has no clipboard.
Bun.Image.backendNone. There is one backend.

Added

  • The "outside", "cover" and "contain" fits, and background for "contain".

  • crop, blur, sharpen, flatten, grayscale, extractChannel and modulate({ hue }).

  • The otf-pixels 0.3.0 additions: resize position and withoutReduction, modulate({ lightness }), the adjustments negate, gamma, linear, threshold, tint, greyscale, recomb, ensureAlpha and removeAlpha, and multi-page TIFF (page, metadata().pages).

  • composite, extend, trim and stats(), with sharp's options.

  • Raw pixels in (raw) and out (raw()), a blank canvas ({ create }) and convolve.

  • keepExif, keepXmp, keepMetadata, withXmp and withMetadata({ density }).

  • sharp's encoder options: JPEG chromaSubsampling, WebP effort, alphaQuality and nearLossless, AVIF effort and chromaSubsampling, GIF colours and dither.

  • gif(), tiff() and jxl() output, and JPEG XL input.

  • AVIF and TIFF on Linux, where Bun has neither.

  • The toSrgb and animated options.

8. Archives (Bun.Archive)

runtime:archive has Bun.Archive's shape: the same constructor split between entries and bytes, and the same bytes(), blob(), files() and extract().

JavaScript
// ❌ Bun
const archive = new Bun.Archive({ "hello.txt": "Hello" }, { compress: "gzip" });
await Bun.write("bundle.tar.gz", await archive.bytes());
await new Bun.Archive(await Bun.file("bundle.zip").bytes()).extract("out");

// ✅ esrun
import { Archive } from "runtime:archive";
import { file } from "runtime:fs";

const archive = new Archive({ "hello.txt": "Hello" }, { compress: "gzip" });
await Archive.write(file("bundle.tar.gz"), archive);
await new Archive(file("bundle.zip")).extract("out");   // needs --allow-write

What carries over unchanged

new Archive(entries), { compress: "gzip" } for tar, bytes(), blob(), files(glob) returning Map<string, File>, and extract(directory). Globs read as Bun reads them: a string or a list, with ! excluding, and a list of only exclusions taking everything else. Builds are deterministic.

What changes

BunesrunWhy
new Bun.Archive("bundle.zip")new Archive(file("bundle.zip"))A path string is a TypeError; a file() goes through runtime:fs, so --allow-read and the jail apply.
new Bun.Archive(bytes).extract("out")the same, with --allow-writeExtraction is FileWrite, with the destination as the target.
extract("out", { glob })extract("out", { filter })The same globs, under the name files() uses; a RegExp or a function also works.
{ compress: "gzip", level: 10–12 }level: 1–9Bun's 10–12 are libdeflate's; zlib's range is 1–9. Outside it is a RangeError.
Symlinks extracted (POSIX); .. normalizedrefusedAn archive is never rewritten into a different one, and nothing is written through a link (D174, D180).
extract() resolves to a count{ files, directories }The two kinds are counted separately.
reverse/order and arbitrary metadatanoneEntries are sorted by path, ownership is 0:0, modes are 0644/0755, and timestamps are epoch unless supplied.

Added

  • entries(), returning metadata with lazy bytes(), text(), blob() and stream() per entry.

  • { format: "zip" } with compress: "deflate".

  • RegExp and function filters, besides globs.

  • maxEntries and maxBytes limits, enforced on declared and actual bytes.

  • Stable ERR_ARCHIVE_* codes, and refusal of unsafe paths, links, special entries, encrypted zips, duplicates and file/directory collisions.

Not available

7z and rar; passwords; extraction to a stream or arbitrary sink; appending; and preserving an input archive's ownership, modes or timestamps.

Last updated on
Edit this page