JS Runtimes Have Forked in 2025: Ship Cross‑Runtime Libraries for Node, Bun, Deno, and Edge Workers
It finally happened: in 2025, the JavaScript runtime landscape is fully plural. Node still dominates servers, Deno is a strong alternative that leans into Web APIs and permissions, Bun is fast and pragmatic in production, and Edge Worker platforms (Cloudflare Workers, Vercel Edge, Deno Deploy, Fastly, etc.) are everywhere. The good news: you can ship one package that runs across these environments without a combinatorial explosion of forks. The bad news: the success path is opinionated and involves a handful of sharp edges.
This guide lays out the practical path to author, build, publish, test, and maintain a cross‑runtime library that runs in Node, Bun, Deno, and edge workers—while keeping performance, DX, and determinism in check.
What follows is not a survey; it’s a toolbox with defaults that work now.
TL;DR (the short opinionated checklist)
- Author in ESM first; provide CJS only for Node consumers that still require it.
- Prefer Web Platform APIs (URL, fetch, Streams, Crypto, TextEncoder/Decoder, AbortController) as your abstraction baseline.
- Use conditional exports with explicit
node
,deno
,bun
,browser
(for edge/bundlers) along withimport
/require
. - Avoid Node core modules in your public API surface. If you must, isolate via tiny adapters behind conditional exports.
- Don’t ship native addons if you can avoid it; prefer WebAssembly. If you can’t avoid native, use Node‑API and prebuilds for Node/Bun only, and offer a WASM fallback for Deno/edge.
- Test all targets on CI: Node (LTS), Bun, Deno, and a local edge simulator (workerd/Miniflare). Keep a compatibility matrix green before publish.
- Pin everything and use deterministic builds (lockfiles, reproducible bundling, pinned toolchain).
- Budget time for runtime quirks with fetch and streams. Expect subtle differences in backpressure, buffering, and abort semantics.
1) Package structure that scales cross‑runtime
Author in TypeScript or modern JS (ES2022+), target ESM, and produce a tiny CJS wrapper. Keep your surface area Web‑API‑first. A working layout:
my-lib/
src/
index.ts # public API (web-first)
adapters/
node.ts # node-specific functions (fs, path, Node streams)
web.ts # edge/web-specific tweaks
bun.ts # bun-specific quirks (rarely needed)
deno.ts # deno-specific quirks (permissions)
dist/
esm/
index.js
adapters/*.js
cjs/
index.cjs
package.json
tsconfig.json
README.md
Key rules:
- Put all Node‑specific code behind explicit adapters. Never import
node:
modules from your ESM entry by default. - If you need to detect the environment, use conditional exports (preferable) before resorting to runtime checks.
2) ESM, CJS, and conditional exports that actually work
Your package.json should declare ESM as the default, and expose both ESM and CJS via exports
. You will also add environment conditions for Node, Deno, Bun, and the browser/edge bundlers. Unknown conditions are ignored by runtimes that don’t recognize them, so you can safely include environment flags.
Example package.json:
json{ "name": "@acme/x-runtime-lib", "version": "1.0.0", "type": "module", "main": "./dist/cjs/index.cjs", "module": "./dist/esm/index.js", "exports": { ".": { "types": "./dist/esm/index.d.ts", "import": { "deno": "./dist/esm/index.js", "bun": "./dist/esm/index.js", "browser": "./dist/esm/index.js", "node": "./dist/esm/index.js", "default": "./dist/esm/index.js" }, "require": { "node": "./dist/cjs/index.cjs", "default": "./dist/cjs/index.cjs" } }, "./adapters/node": { "import": "./dist/esm/adapters/node.js", "require": "./dist/cjs/adapters/node.cjs" }, "./adapters/web": { "import": "./dist/esm/adapters/web.js" } }, "types": "./dist/esm/index.d.ts", "sideEffects": false, "engines": { "node": ">=18", "bun": ">=1.0" }, "packageManager": "pnpm@9.0.0" }
Notes and opinions:
- Use
type: "module"
to make ESM the default. Generate CJS indist/cjs
and expose viarequire
conditions. - Deno recognizes the
exports
map and thedeno
condition (it is ignored elsewhere). Bun recognizesbun
. Bundlers (wrangler/esbuild/rollup/vite) commonly honorbrowser
. - Node will ignore unknown conditions and select the
import
/require
it understands. Keepdefault
as a last resort pointing to your best ESM build. - Prefer
exports
over historicalmain/module/browser
fields; keepmain
/module
mostly for bundlers and legacy consumers. - For TypeScript typings, either rely on top‑level
types
, or provide per‑subpath types usingexports
with thetypes
key (TypeScript >= 5.2). If you usetypesVersions
, keep it minimal.
A dual build with Rollup or tsup is straightforward. With tsup:
json{ "scripts": { "build": "tsup src/index.ts src/adapters/*.ts --format esm,cjs --dts --out-dir dist --clean" } }
3) Choose Web APIs as your baseline
The single best design choice for portability is to adopt the Web Platform as your public contract. These are available across Node (>=18), Bun, Deno, and edge workers:
- URL, URLSearchParams
- fetch, Request, Response, Headers
- Web Streams: ReadableStream, WritableStream, TransformStream
- TextEncoder/TextDecoder
- Crypto.subtle, crypto.getRandomValues, crypto.randomUUID
- AbortController, AbortSignal
What to avoid in your public API surface:
- Node‑only constructs: Buffer (as a type), Node streams (
stream.Readable
),http
/https
modules,fs
,path
,net
. - Globals like
process
,__dirname
, CommonJS patterns (unless in CJS build).
Instead, types should reference ArrayBufferView
/Uint8Array
for binary, ReadableStream<Uint8Array>
for streaming, and “fetch‑shaped” Request/Response for I/O.
When you absolutely need Node features, expose optional adapters:
ts// src/adapters/node.ts import { createReadStream } from "node:fs"; import { Readable } from "node:stream"; import { ReadableStream } from "node:stream/web"; export function nodeReadableToWeb(rs: Readable): ReadableStream<Uint8Array> { return Readable.toWeb(rs) as ReadableStream<Uint8Array>; }
Consumers who run in Node can import that subpath explicitly, while everyone else uses the web‑first API.
4) fetch and streams: the non‑obvious edge cases
Even with fetch and Web Streams standardizing, there are runtime differences that can break assumptions:
- Compression and content decoding:
- Node’s fetch (undici) auto‑decompresses by default for gzip/br, sets implicit
accept-encoding
. Edge runtimes vary; some pass through. - Bun’s fetch tracks undici semantics but has historically had differences in headers casing and trailer handling.
- Node’s fetch (undici) auto‑decompresses by default for gzip/br, sets implicit
- Redirects:
- Node honors
redirect: "error"|"follow"|"manual"
with specific semantics; Cloudflare Workers may handlemanual
differently (location exposure).
- Node honors
- Abort semantics:
- All support
AbortSignal
. But the point at which the abort is observable varies. In streaming uploads/downloads, make sure to propagate abort to your transforms.
- All support
- Streams bridging in Node:
- Node has both Node streams and Web Streams. Convert carefully with
node:stream/web
methods. Don’t mix backpressure semantics casually.
- Node has both Node streams and Web Streams. Convert carefully with
- Blob/FormData:
- In Node 18+, Blob and FormData are from undici; in Deno and edge they’re native web implementations. MIME boundary behavior is compatible, but different error messages and strictness can surprise tests.
Practical tips:
- Use
Response.prototype.arrayBuffer()
andtext()
in tests to normalize differences. - Prefer
new Request(url, { body: ReadableStream })
over Node stream bodies. In Node, wrap Node streams withReadable.toWeb()
. - If you parse JSON streams, work with WHATWG streams and a streaming JSON parser that accepts
ReadableStream<Uint8Array>
. - If you need content length, compute it yourself when you control the body; don’t rely on runtime estimation.
5) N‑API vs FFI vs WebAssembly: shipping native safely
Native addons are where cross‑runtime portability usually dies. Here is the pragmatic decision tree in 2025:
- Can it be implemented in pure JS with Web APIs? Do that.
- Can a WebAssembly (WASM) implementation meet your perf needs? Do that next.
- Only if you absolutely must, ship Node‑API (N‑API) native addons for Node/Bun, and gate them behind conditional exports with a WASM fallback for Deno/edge.
Why:
- Node and Bun support Node‑API; most addons work in both (Bun is compatible with many Node‑API modules, but not all edge cases). Deno dropped native plugins in favor of FFI (
Deno.dlopen
) and does not support Node‑API. Edge workers do not allow native addons at all. - Deno and Edge accept WASM; Cloudflare Workers can import WASM modules via bindings or static imports; Deno supports
WebAssembly.instantiate
and WASI; Node supports WASM and WASI via--experimental-wasi-unstable-preview1
historically, and stable WASI libraries in userland.
A shipping pattern:
- Build your core in Rust or C/C++.
- Produce both:
- A Node‑API prebuilt binary for popular platforms (via napi‑rs or node‑gyp + prebuildify).
- A WASM build (prefer WASI if feasible) that runs everywhere else.
- Conditional exports choose native on Node/Bun, WASM on Deno/edge.
Example conditional exports for a compute‑heavy function:
json{ "exports": { ".": { "import": { "node": "./dist/esm/native-node.mjs", "bun": "./dist/esm/native-node.mjs", "default": "./dist/esm/wasm.mjs" }, "require": { "node": "./dist/cjs/native-node.cjs", "default": "./dist/cjs/wasm.cjs" } } }, "optionalDependencies": { "@acme/x-runtime-lib-native": "1.0.0" } }
WASM loader example:
ts// src/wasm.ts let wasmInstance: WebAssembly.Instance | null = null; export async function initWasm() { if (wasmInstance) return wasmInstance; // Bundlers & Deno: static import const url = new URL("./pkg/my_wasm_bg.wasm", import.meta.url); const wasm = await WebAssembly.instantiateStreaming(fetch(url), {}); wasmInstance = wasm.instance; return wasmInstance; } export async function hash(data: Uint8Array): Promise<string> { const inst = await initWasm(); // call export e.g., inst.exports.hash // ... return "..."; }
Node‑API loader example (Node/Bun only):
ts// src/native-node.ts import { createRequire } from "node:module"; const require = createRequire(import.meta.url); // Prebuilt binary package, optionalDependency to avoid install failures on unsupported platforms const native = require("@acme/x-runtime-lib-native"); export const hash = native.hash;
If you absolutely must call system libraries on Deno, use FFI behind a permission gate, but do not make it a default path:
ts// src/adapters/deno.ts export async function dlopen(path: string, symbols: Record<string, unknown>) { // Requires --allow-ffi, query permission at runtime if not granted const status = await Deno.permissions.query({ name: "ffi" }); if (status.state !== "granted") throw new Error("FFI not allowed"); return Deno.dlopen(path, symbols); }
6) Permissions and environment detection
-
Deno enforces permissions (
--allow-net
,--allow-read
,--allow-env
,--allow-ffi
, etc.). Your library should:- Not request permissions implicitly.
- Fail with clear error messages when a permission is required.
- Provide a APIs that accept injected I/O instead of grabbing it globally.
-
Edge workers have no filesystem and limited outbound networking rules depending on the platform. Avoid filesystem assumptions. Provide APIs that accept a
fetch
implementation so users can inject platform credentials/bindings as needed. -
Node and Bun have no built‑in permission gating. That doesn’t mean you should perform dangerous defaults. E.g., never write to disk implicitly.
Prefer explicit runtime detection only when conditional exports can’t solve it. A minimal, side‑effect‑free detector:
tsexport const runtime = (() => { // Edge-like check (Workers, ServiceWorker, browser): if (typeof globalThis.fetch === "function" && typeof globalThis.Deno === "undefined" && typeof process === "undefined") { return "edge" as const; } if (typeof (globalThis as any).Bun !== "undefined") return "bun" as const; if (typeof (globalThis as any).Deno !== "undefined") return "deno" as const; if (typeof process !== "undefined" && process.versions?.node) return "node" as const; return "unknown" as const; })();
But again: strive to avoid this with packaging.
7) Deterministic builds you can actually reproduce
Your library should be reproducible by you and by any downstream maintainer. A non‑negotiable list:
- Pin your package manager via
packageManager
and use a lockfile:- npm:
package-lock.json
, usenpm ci
in CI. - pnpm:
pnpm-lock.yaml
, usepnpm install --frozen-lockfile
. - yarn:
yarn.lock
, useyarn --immutable
. - Bun:
bun.lockb
, usebun install --frozen-lockfile
. - Deno:
deno.lock
, usedeno install/npm: true
anddeno task
.
- npm:
- Pin your toolchain: Node version with
.nvmrc
or.tool-versions
, pnpm version viapackageManager
, Deno/Bun versions in CI matrix. - Avoid postinstall scripts and dynamic downloads at install time. If you need prebuilt binaries, publish them as packages and mark as
optionalDependencies
. - Make your build path deterministic:
- Fixed source order, no timestamp banners, deterministic bundler config.
- Check in your
dist/
only if you need to support non‑build consumers. Otherwise, publishdist/
to npm but don’t commit it.
- For TypeScript, enable
tsconfig
options that help stability:moduleResolution: bundler
ornodeNext
, exacttarget
, andskipLibCheck
off for your own types.
8) API surface: small, adapters optional, no Node types by default
Patterns that keep you portable:
- Accept
Request | URL | string
for resources and normalize toRequest
inside. - Return
Promise<Response>
or portable data types (string, Uint8Array, JSON) instead of Node streams. - For streaming transforms, accept and return
ReadableStream<Uint8Array>
. - If you need file I/O, accept an injected
readFile
/writeFile
function instead of importingfs
inside the core module. Provideadapters/node
that wire Node’sfs/promises
for convenience.
Example:
ts// src/index.ts export interface IO { read?(path: string): Promise<Uint8Array>; write?(path: string, data: Uint8Array): Promise<void>; } export async function processResource(input: Request | URL | string, io?: IO): Promise<Response> { const req = typeof input === "string" || input instanceof URL ? new Request(String(input)) : input; const res = await fetch(req); if (!res.ok) return new Response("Bad upstream", { status: 502 }); const data = new Uint8Array(await res.arrayBuffer()); // optionally persist if io provided if (io?.write) await io.write("/tmp/cache.bin", data); return new Response(data, { headers: { "content-type": res.headers.get("content-type") ?? "application/octet-stream" } }); }
Node adapter wiring:
ts// src/adapters/node.ts import { readFile, writeFile } from "node:fs/promises"; export const nodeIO = { async read(path: string) { return new Uint8Array(await readFile(path)); }, async write(path: string, data: Uint8Array) { await writeFile(path, data); } };
This keeps the core portable and still ergonomic for Node consumers.
9) Test matrices and CI you’ll actually run
A minimal, effective GitHub Actions setup:
yamlname: ci on: push: branches: [ main ] pull_request: jobs: test-node: runs-on: ubuntu-latest strategy: matrix: node-version: [18, 20, 22] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - uses: pnpm/action-setup@v4 with: version: 9 run_install: true - run: pnpm build - run: pnpm test:node test-deno: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: denoland/setup-deno@v1 with: deno-version: v1.x - run: deno task test:deno test-bun: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.1.x - run: bun install --frozen-lockfile - run: bun run build - run: bun test test-edge: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: 20 - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test:edge # uses workerd or miniflare
Recommended practices:
- Keep unit tests runtime‑agnostic: test against Web APIs. Separate Node‑specific integration tests into their own files, run only under Node.
- For edge tests, use Miniflare 3 or
workerd
to simulate workers. For Cloudflare specifics (KV, R2), keep those in a separate integration suite. - Deno tests use
deno test
anddeno.json
tasks. You can run TypeScript tests without compilers. - Bun’s
bun test
is fast; ensure no Node‑only globals leak in tests.
Example testing tasks:
json{ "scripts": { "test:node": "node --test --conditions=node,import tests/*.test.js", "test:edge": "miniflare tests/edge/*.test.js --modules", "test": "pnpm test:node && pnpm test:edge" } }
Deno deno.json
:
json{ "tasks": { "test:deno": "deno test -A --no-lock=write" } }
10) Performance pitfalls across runtimes
If you care about performance (you should), expect the following:
- Text encoding/decoding:
TextEncoder
/TextDecoder
are generally fast everywhere. In Node,Buffer
conversions are also fast, but don’t exposeBuffer
types publicly. Internally, converting viaBuffer.from(u8)
can be faster than iterating. Measure in your hotspot.
- Timers and microtasks:
queueMicrotask
exists everywhere. Don’t rely onsetImmediate
(Node‑only). Avoid assumptions about microtask timing afterawait
in streaming loops.
- Streams:
- Web Streams in Node are implemented on top of the event loop with adapters. Avoid frequent
controller.enqueue(new Uint8Array(1))
tiny chunks; batch to reduce overhead.
- Web Streams in Node are implemented on top of the event loop with adapters. Avoid frequent
- fetch concurrency:
- Edge runtimes often handle thousands of concurrent requests efficiently, but with stricter CPU time budgets. Avoid CPU‑bound work on edge; offload to WASM or upstream.
- Crypto:
- Use
crypto.subtle
for standardized algorithms (SHA‑256, AES‑GCM). For Node‑only ciphers, gate behind adapters.randomUUID()
is fast and portable; avoid homegrown UUID.
- Use
- JSON parsing:
- Streaming parsers can win on large payloads, but they introduce backpressure complexity. Benchmark against
Response.json()
and a single pass first.
- Streaming parsers can win on large payloads, but they introduce backpressure complexity. Benchmark against
- WASM initialization:
- Cache your WASM instance.
instantiateStreaming
is faster and uses less memory than fetching, arrayBuffer, then instantiate.
- Cache your WASM instance.
Benchmarking advice:
- Use the runtime’s native benchmark facility or a cross‑runtime micro‑benchmark scaffold that avoids bundle differences. Keep your harness identical across Node, Deno, and Bun.
- Warm up caches and JITs. Run multiple samples, report median and p95, not just mean.
11) Concrete example: shipping a cross‑runtime hashing lib
A simplified library that exposes a single function sha256(data: Uint8Array): Promise<string>
, using:
- Native Node‑API addon on Node/Bun if available.
- WebCrypto
crypto.subtle.digest
everywhere else.
Public API:
ts// src/index.ts export async function sha256(data: Uint8Array): Promise<string> { if (typeof crypto?.subtle?.digest === "function") { const hashBuf = await crypto.subtle.digest("SHA-256", data); return hex(new Uint8Array(hashBuf)); } // Fallback to Node adapter (if chosen via conditional export for Node) throw new Error("crypto.subtle not available; use Node adapter build"); } function hex(bytes: Uint8Array) { const lut = Array.from({ length: 256 }, (_, i) => i.toString(16).padStart(2, "0")); let out = ""; for (let i = 0; i < bytes.length; i++) out += lut[bytes[i]]; return out; }
Node adapter using Node‑API native addon (optional):
ts// src/native-node.ts import { createRequire } from "node:module"; const require = createRequire(import.meta.url); let native: any; try { native = require("@acme/sha256-native"); } catch { native = null; } export async function sha256(data: Uint8Array): Promise<string> { if (native) return native.sha256(data); // If native not available, delegate to web path const { sha256: webSha256 } = await import("./index.js"); return webSha256(data); }
Conditional exports:
json{ "exports": { ".": { "import": { "node": "./dist/esm/native-node.js", "bun": "./dist/esm/native-node.js", "default": "./dist/esm/index.js" }, "require": { "node": "./dist/cjs/native-node.cjs", "default": "./dist/cjs/index.cjs" } } }, "optionalDependencies": { "@acme/sha256-native": "1.0.0" } }
Tests:
ts// tests/basic.test.ts import { strict as assert } from "assert"; import { sha256 } from "@acme/x-runtime-lib"; (async () => { const out = await sha256(new TextEncoder().encode("hello")); assert.equal(out, "2cf24dba5fb0a...".slice(0, 10), "hash prefix matches"); })();
This pattern scales: ship fast paths per runtime without breaking portability.
12) Documentation that doesn’t confuse users
Your README should state, clearly:
- Supported runtimes and versions.
- Which APIs are used (Web APIs baseline) and what adapters exist.
- Any permissions needed on Deno (e.g.,
--allow-net
for network features) and platform constraints on edge (no filesystem, limited CPU time). - How to import subpath exports for runtime‑specific adapters.
- Performance notes: native vs WASM vs web crypto.
Example import guidance:
mdNode (ESM): import { sha256 } from "@acme/x-runtime-lib"; // uses Node-native addon when available Browser/Edge/Deno/Bun: import { sha256 } from "@acme/x-runtime-lib"; // uses WebCrypto
13) Common mistakes to avoid
- Exposing Node types in your public API (Buffer, Node streams, fs paths). This locks you to Node.
- Importing
node:
modules at top level in your ESM entry. Even with conditional exports, some bundlers statically analyze and include them. - Depending on
process.env
in code paths that run at import time. Edge runtimes don’t haveprocess
. - Using dynamic require/import hacks instead of clean
exports
maps. - Shipping only ESM without a CJS entry for Node users stuck on CommonJS. Provide a small CJS build; it’s worth the small cost.
- Relying on unpinned toolchains. Your users will hit non‑reproducible bugs.
- Assuming fetch/streams behave identically across runtimes without testing.
14) A note on Deno, Bun, and JSR/edge registries
- Deno can consume npm packages directly, including
exports
maps. Publishing to npm is still the best way to reach Deno users. Optionally, publish to JSR for Deno‑first users with richer metadata; keep the source identical. - Bun is npm‑first and honors conditional exports. It also has good Node‑API compatibility.
- Edge platforms consume npm via bundlers that understand
browser
conditions, or via import maps. Keep yourbrowser
condition pointing at the web build.
15) Release management and semver in a forked runtime world
- Treat a runtime break as semver‑major. If you drop Node 18 in favor of Node 20+, bump major.
- Keep a changelog with runtime‑targeted notes: Node, Bun, Deno, Edge sections.
- Automate releases with a single command that runs the full matrix before publishing.
- Consider canary tags (
next
,beta
) to validate on all platforms before cutting stable.
16) Opinionated defaults you can copy today
- The baseline target: Node 18+, Bun 1.1+, Deno 1.40+, Edge workers on modern platforms.
- Use tsup or Rollup to emit ESM and CJS; prefer minimal transforms.
- Expose only Web APIs publicly; put Node‑specific code in
adapters/node
. - Use conditional exports with
node
,bun
,deno
, andbrowser
. - Avoid native; choose WASM; Node‑API only as an optional fast path.
- CI tests Node, Deno, Bun, and an edge simulator. Don’t skip the edge job.
- Pin everything and avoid postinstall scripts.
If you adopt these defaults, you will ship one package that works everywhere—and you’ll debug less.
Appendix: snippets you’ll reuse
TypeScript config tuned for bundler‑friendly resolution:
json{ "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", "lib": ["ES2022", "DOM"], "declaration": true, "emitDeclarationOnly": false, "sourceMap": true, "outDir": "dist/esm", "skipLibCheck": false, "strict": true, "types": [] }, "include": ["src/**/*"] }
Miniflare edge test using Web APIs only:
js// tests/edge/fetch.test.js import assert from "node:assert/strict"; import { Miniflare } from "miniflare"; const mf = new Miniflare({ modules: true, script: ` export default { async fetch() { const res = await fetch("https://example.com"); return new Response(res.status.toString()); } }` }); const res = await mf.dispatchFetch("http://localhost/"); assert.equal(await res.text(), "200");
Deno permission‑aware test:
ts// tests/deno/perm.test.ts Deno.test("no fs access by default", async () => { const status = await Deno.permissions.query({ name: "read" }); if (status.state === "granted") { throw new Error("Test must run without --allow-read"); } });
Runtime‑agnostic fetch adapter injection:
tsexport type FetchLike = (input: RequestInfo | URL | string, init?: RequestInit) => Promise<Response>; export async function getJson(url: string, fetchImpl: FetchLike = fetch) { const res = await fetchImpl(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }
Closing
The JavaScript runtime ecosystem has forked—but in a productive way. Each runtime optimizes for a different niche, and the overlaps are now large enough that you can write portable, fast, and ergonomic libraries without a pile of if (isNode)
branches. The critical path is discipline: Web‑first APIs, clean packaging with conditional exports, optional runtime adapters, and a CI matrix that mirrors reality.
If you adopt the patterns in this guide, your users will import your package in Node, Bun, Deno, and on the edge—and it will just work. That’s the bar in 2025. Reach it, and you’ll spend your time iterating on features, not chasing runtime ghosts.