-
Notifications
You must be signed in to change notification settings - Fork 2
compat
Cross-runtime compatibility layer for Bun, Deno, and Node.js.
The @tundralibs/compat package provides unified APIs that work consistently across Bun, Deno, and Node.js runtimes. Write your code once and run it anywhere.
| Module | Description | Documentation |
|---|---|---|
| WebServer | HTTP/HTTPS + WebSocket server (all 3 runtimes) | Full Docs |
| WebSocketServer | Middleware-aware WebSocket primitive (codec, broadcast, lifecycle) | Docs |
| Common | TLS types, error classes | Docs |
| Runtime | Runtime detection, OS/arch, env, signals, memory | Docs |
| CLI | Args/argv, terminal, prompt, progress, spinner | Docs |
| File | File system operations | Docs |
| Watch | Cross-runtime filesystem watching | Docs |
| Net | Networking utilities | Docs |
| Path | Path utilities | Docs |
| Permissions | Permission checking | Docs |
| Test | Testing utilities | Docs |
| Bench | Cross-runtime micro-benchmark harness | Docs |
| Fetch | HTTP client utilities | Docs |
| Http | Methods/status/text, negotiation, ranges, cookies, content-type | Docs |
No blanket badge — compat's whole job is smoothing over Bun / Deno / Node.js, and most of its modules wrap concepts (a listening TCP socket, a real filesystem, a terminal) that don't exist in a browser or a standard Worker, not something compat itself could paper over.
runtime detects both targets: RUNTIME reports 'WORKERS' (Cloudflare
Workers / workerd) and 'BROWSER', with isWorkers / isBrowser flags.
Cloudflare Workers under nodejs_compat exposes process.versions.node,
so isNode deliberately excludes it — every module either works,
returns a documented safe fallback, or throws UnsupportedRuntimeError;
none TypeErrors on a missing built-in. Per module:
| Module | Browser / Workers | Why |
|---|---|---|
fetch |
✅ | Wraps the native fetch global directly. |
path |
✅ | Pure string manipulation, no I/O. |
common (TLS types, errors) |
✅ | Types and error classes only. |
runtime |
✅ | Detects 'WORKERS' / 'BROWSER'; isWorkers / isBrowser flags. Informational helpers return safe fallbacks (cpus() → 1, cwd() → '', getEnv() → {} or process.env on Workers, on* no-op); exit() throws. |
net — connect, upgradeTls
|
✅ on Workers | Outbound TCP runs on cloudflare:sockets (Hyperdrive's own primitive), so the hand-rolled Postgres / Redis / Memcached drivers connect from a Worker. Three workerd limits, each a clear UnsupportedRuntimeError rather than a silent drop: no UNIX sockets; no TLS material (cert/key/ca and their *File forms, and rejectUnauthorized: false — workerd verifies against its own trust store, so the peer needs a publicly-trusted certificate); and upgradeTls's hostname must match the one connect dialed, because startTls() takes no override. Browsers: still throws. |
net — listen
|
throws | Not a gap: workerd's model has no way to accept an inbound TCP connection, so there is nothing to bind. Throws UnsupportedRuntimeError. |
file — path-based operations |
✅ on Workers |
readFile, readFileSync, readTextFile, readTextFileSync, readFileStream, writeFile, writeFileSync, writeTextFile, writeTextFileSync, stat, statSync, pathExists, pathExistsSync, isFile, isFileSync, isDirectory, isDirectorySync, deleteFile, deleteFileSync run on node:fs under nodejs_compat. Only /tmp works — workerd itself refuses every other location, so the path you pass is the boundary and compat adds no guard. /tmp is in-memory and does not survive the request: stage, read back and relay within one request, never treat it as storage. Browsers: still throws. |
file — temp-path creators |
opt-in |
makeTempFile, makeTempFileSync, makeTempDir, makeTempDirSync pick the location themselves, so the ephemerality isn't visible at the call site the way a hand-written /tmp/... path is. They keep throwing UnsupportedRuntimeError on Workers unless you pass allowEphemeral: true; with it they return a path under workerd's /tmp. |
file — everything else |
throws | Directory operations (makeDir, readDir, copyDir, …), copyFile / moveFile / renameFile, ensureFile, realPath, remove and the openFile handle API are out of scope for now and still throw UnsupportedRuntimeError. |
watch, udp
|
throws | Genuinely impossible, not a gap: there is no persistent filesystem to watch and no UDP socket on Workers or in the browser. Each public entry throws UnsupportedRuntimeError (not a TypeError) when the backing built-in is absent. |
websocket — handleUpgrade
|
✅ on Workers |
WebSocketServer.handleUpgrade(request) serves a connection from an already-arrived request and hands back the 101 — no listening socket involved, so it works inside a Worker's fetch. Middleware, codec, connections and broadcast behave exactly as on the listen-based path. Also available on Deno (Deno.upgradeWebSocket); Bun and Node throw, because neither can answer an upgrade with a Response. Workerd exposes no bufferedAmount, so onBackpressure never fires there, and ping/pong stay unreachable as on Deno. |
webserver, websocket.listen
|
throws |
new WebServer(...) constructs fine; start() throws UnsupportedRuntimeError, and so does WebSocketServer.listen() on top of it — nothing can bind a port on Workers. The Node path loads the ws npm package lazily on start (never at import), so importing the module is bundle-safe. A browser/Workers client should use the native WebSocket global directly. |
permissions, cli
|
mixed |
cli prompts (prompt/choose) throw UnsupportedRuntimeError rather than fake a terminal; permissions reports 'GRANTED' (no permission system to consult). |
Deno:
deno add @tundralibs/compatBun:
bunx jsr add @tundralibs/compatNode.js:
npx jsr add @tundralibs/compat// Import entire module
import * as compat from '@tundralibs/compat';
// Import specific modules
import { WebServer } from '@tundralibs/compat/webserver';
import { isBun, isDeno, isNode, RUNTIME } from '@tundralibs/compat/runtime';
import { readTextFile, writeTextFile } from '@tundralibs/compat/file';
import { connect, hostname, listen, upgradeTls } from '@tundralibs/compat/net';
import { argv, ProgressBar, prompt, Spinner } from '@tundralibs/compat/cli';
import { watch } from '@tundralibs/compat/watch';Direct import (Deno):
import { WebServer } from 'jsr:@tundralibs/compat/webserver';
import { RUNTIME } from 'jsr:@tundralibs/compat/runtime';import { isBun, isDeno, isNode, RUNTIME } from '@tundralibs/compat/runtime';
console.log(`Running on: ${RUNTIME}`);
// Output: "Running on: DENO" or "BUN" or "NODE"
if (isDeno) {
console.log('Deno-specific code');
} else if (isBun) {
console.log('Bun-specific code');
} else if (isNode) {
console.log('Node.js-specific code');
}import {
isDirectory,
isFile,
pathExists,
readTextFile,
writeTextFile,
} from '@tundralibs/compat/file';
// Read file
const content = await readTextFile('./config.json');
// Write file
await writeTextFile('./output.txt', 'Hello World');
// Check existence
if (await pathExists('./data')) {
console.log('Data directory exists');
}import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('MyAPI', {
mode: 'TCP',
port: 8080,
handler: (request, info) => {
return new Response(`Hello from ${info.requestId}`);
},
});
server.on('onStart', (name) => {
console.log(`${name} listening on ${server.address}`);
});
server.start();import { describe, it } from '@tundralibs/compat/test';
import { strictEqual } from 'node:assert';
describe('Math operations', () => {
it('should add numbers', () => {
strictEqual(1 + 1, 2);
});
it('should handle async', async () => {
const result = await Promise.resolve(42);
strictEqual(result, 42);
});
});| Feature | Bun | Deno | Node.js |
|---|---|---|---|
| Runtime / OS / arch detection | ✅ | ✅ | ✅ |
| Process info (pid, env, cwd) | ✅ | ✅ | ✅ |
| Process exit + signals | ✅ | ✅ | ✅ |
| System resources (cpu/mem/uptime) | ✅ | ✅* | ✅ |
| File operations | ✅ | ✅ | ✅ |
| Filesystem watching | ✅ | ✅ | ✅‡ |
| Networking utilities | ✅ | ✅ | ✅ |
| Path utilities | ✅ | ✅ | ✅ |
| HTTP Server | ✅ | ✅ | ✅ |
| WebSocket | ✅ | ✅ | ✅¶ |
| Permission checks | ✅** | ✅ | ✅** |
| Testing utilities | ✅ | ✅ | ✅ |
| TLS upgrade (STARTTLS) | ✅ | ✅ | ✅ |
rejectUnauthorized: false |
✅ | ❌† | ✅ |
| CLI args + prompt + widgets | ✅ | ✅ | ✅ |
| WS middleware + codecs + broadcast | ✅ | ✅ | ✅ |
*memoryUsage().arrayBuffers is 0 on Deno (the runtime doesn't expose it).
**Bun and Node.js permissions always return true (no permission system) — only Deno performs a real check.
†Deno requires --unsafely-ignore-certificate-errors=hostname CLI flag.
‡Recursive watching on Linux requires Node 20+; older Node throws.
¶Node.js WebSocket built on the ws npm package (normal dependency, pure-JS, no native deps).
@tundralibs/compat ships with one runtime npm dep — ws — used only
on Node.js to provide the WebSocket server (Bun and Deno use their
native primitives and don't load it).
The package may take additional npm deps in the future where:
- There's a genuine cross-runtime gap that can't be filled with
node:*builtins or runtime globals, - The dep is small, mature, widely-used, and pure-JS (no native binaries — keeps deployment simple),
- The dep is loaded only on the runtime that needs it.
Bun and Deno paths through compat must remain free of npm deps unless
no node:/native alternative exists.
Full-featured HTTP/HTTPS server with WebSocket support.
- TCP and UNIX socket modes
- TLS/HTTPS with file or string certificates
- WebSocket on all three runtimes — Bun + Deno native, Node via
ws - Typed connection state via the upgrade hook (
WebServer<T>) - Subprotocol selection,
bufferedAmountfor backpressure - Request metrics and analytics
- Event-driven architecture
- Graceful shutdown
import { WebServer } from '@tundralibs/compat/webserver';→ Full WebServer Documentation
Shared TLS types and error classes used by Fetch and Net. These are
available from the ./common sub-path and re-exported from the package
root.
import {
FetchFileNotFoundError,
FetchInvalidPEMError,
FetchPathTraversalError,
FetchTLSError,
type TLSOptions,
} from '@tundralibs/compat';Runtime detection, OS / architecture info, environment access, process event handlers and signals, and system-resource probes (CPU count, total / free memory, uptime, per-process memory usage).
import {
ARCH,
cpus,
exit,
freemem,
getEnv,
isBun,
isDeno,
isNode,
memoryUsage,
onSignal,
RUNTIME,
totalmem,
uptime,
} from '@tundralibs/compat/runtime';CLI argument access and parsing, terminal info, line-based prompts, and in-place display widgets (progress bar, spinner). Each piece is small and standalone — pull just what you need.
import {
argv,
choose,
consoleSize,
isTTY,
ProgressBar,
prompt,
Spinner,
} from '@tundralibs/compat/cli';Cross-runtime file system operations. On Cloudflare Workers the
path-based subset — read, write, stat, existence and file/directory
checks, delete — works under /tmp, and the temp-path creators need
allowEphemeral: true (see the caveats
above); directory operations,
copy/move and the handle API still throw.
import {
isDirectory,
isDirectorySync,
isFile,
isFileSync,
pathExists,
pathExistsSync,
readTextFile,
readTextFileSync,
remove,
removeSync,
writeTextFile,
writeTextFileSync,
} from '@tundralibs/compat/file';Filesystem watching with a single async-iterable API and normalized
event kinds. Deno reports distinct create/modify/remove/rename;
Node and Bun's fs.watch is lossier (everything but 'change' is
reported as 'rename').
import { watch } from '@tundralibs/compat/watch';
const w = watch('./src', { recursive: true });
for await (const ev of w) {
console.log(ev.kind, ev.paths);
}Middleware-aware WebSocket server primitive on top of the WebServer's
WebSocket support: Koa-style middleware over every incoming message, a
pluggable codec (string identity by default; JsonCodec / BinaryCodec
ship alongside), lifecycle hooks, connection tracking, and single-call
broadcast. Mount it onto an existing WebServer, run it standalone, or —
where there is no socket to listen on — answer one upgrade request at a
time with handleUpgrade(request) (Cloudflare Workers and Deno; see the
caveats above).
It is intentionally opinion-light — no command dispatch, channels, or
pub/sub. For a higher-level RPC + pub/sub layer built on this primitive,
see @tundralibs/rpc.
import { WebSocketServer } from '@tundralibs/compat/websocket';
// Your own auth check.
const verifyToken = (header: string | null): string | null =>
header?.startsWith('Bearer ') ? header.slice(7) : null;
const wss = new WebSocketServer<{ userId: string }>({
upgrade: (req) => {
const userId = verifyToken(req.headers.get('authorization'));
return userId ? { data: { userId } } : false;
},
});
wss.use(async (ctx, next) => {
console.log(`message from ${ctx.ws.data.userId}`);
await next();
});
wss.onMessage((ctx) => ctx.ws.send(`echo: ${ctx.message}`));
// Mount onto an existing WebServer:
// websocket: wss.handlers()
// Or run standalone:
await wss.listen({ port: 8080 });→ WebSocketServer Documentation
Cross-runtime networking utilities. On Cloudflare Workers connect and
upgradeTls run on cloudflare:sockets (see the caveats
above); listen throws, because
workerd cannot accept inbound connections.
import { connect, hostname, listen, upgradeTls } from '@tundralibs/compat/net';
// Create TCP listener
const listener = await listen({ port: 8080 });
listener.close();
// Connect to remote host
const conn = await connect({ hostname: 'example.com', port: 80 });
await conn.write('GET / HTTP/1.1\r\n\r\n');
conn.close();
// Upgrade plain TCP connection to TLS (e.g. Postgres SSLRequest, SMTP STARTTLS)
const tlsConn = await upgradeTls(conn, {
hostname: 'db.example.com',
tls: true,
});
// Get hostname
const host = hostname();Path manipulation utilities.
import * as path from '@tundralibs/compat/path';
path.join('foo', 'bar', 'baz'); // 'foo/bar/baz'
path.dirname('/foo/bar/baz'); // '/foo/bar'
path.basename('/foo/bar/baz'); // 'baz'
path.extname('file.txt'); // '.txt'Check runtime permissions (Deno) or simulate (Bun/Node).
import {
hasPermission,
hasPermissionSync,
} from '@tundralibs/compat/permissions';
const canRead = await hasPermission({ name: 'read', path: './data' });
const canWrite = hasPermissionSync({ name: 'write', path: './output' });Cross-runtime testing utilities. Available on the ./test sub-path only —
the package root does not re-export them, because the module imports
bun:test and node:test and bundlers such as esbuild cannot resolve
those, which would break every Cloudflare Workers build.
import { afterEach, beforeEach, describe, it } from '@tundralibs/compat/test';All modules use CompatError as the base class for consistent error handling:
import {
CompatError,
FetchFileNotFoundError,
FetchInvalidPEMError,
FetchPathTraversalError,
} from '@tundralibs/compat';
try {
// compat operation
} catch (error) {
if (error instanceof FetchPathTraversalError) {
console.error('SECURITY: Path traversal attempt:', error.path);
} else if (error instanceof FetchFileNotFoundError) {
console.error('Missing file:', error.path);
} else if (error instanceof FetchInvalidPEMError) {
console.error('Invalid PEM in', error.source);
} else if (error instanceof CompatError) {
console.error(error.toJSON());
}
}See the main TundraLibs Contributing Guide.
MIT