-
Notifications
You must be signed in to change notification settings - Fork 2
Compat Runtime
Cross-runtime detection and environment information.
The Runtime module provides reliable detection of the current JavaScript runtime and operating system, along with utilities for accessing environment information in a cross-runtime compatible way.
| Feature | Bun | Deno | Node.js | Workers | Browser |
|---|---|---|---|---|---|
| Runtime detection | ✅ | ✅ | ✅ | ✅ | ✅ |
| OS detection | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| CPU architecture | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Environment vars | ✅ | ✅ | ✅ | ✅† | ⬜ |
| Process ID | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Process exit | ✅ | ✅ | ✅ | ❌ | ❌ |
| Working directory | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Exit handler | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Error handler | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Unhandled rejection | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Signal handler | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| CPU count | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Memory totals | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| System uptime | ✅ | ✅ | ✅ | ⬜ | ⬜ |
| Memory usage | ✅ | ✅* | ✅ | ⬜ | ⬜ |
* Deno's Deno.memoryUsage() does not expose arrayBuffers; the field is reported as 0.
† On Workers, getEnv() returns process.env when nodejs_compat populates it, else {}.
Legend: ✅ works · ⬜ returns a safe fallback (0, 1, '', {}, no-op) · ❌ throws.
Deno:
deno add @tundralibs/compatBun:
bunx jsr add @tundralibs/compatNode.js:
npx jsr add @tundralibs/compatconst RUNTIME: Runtime; // 'DENO' | 'BUN' | 'NODE' | 'WORKERS' | 'BROWSER' | 'UNKNOWN'
const isDeno: boolean; // true if running in Deno
const isBun: boolean; // true if running in Bun
const isNode: boolean; // true if GENUINE Node (excludes workerd)
const isWorkers: boolean; // true if running on Cloudflare Workers (workerd)
const isBrowser: boolean; // true if a browser or web/service workerisNode is genuine Node only. Cloudflare Workers under nodejs_compat
exposes process.versions.node, so it would otherwise masquerade as Node;
isWorkers is checked first and isNode excludes it. This is what makes a
Node-gated builtin (node:http, node:fs, …) that workerd does not provide
surface as an UnsupportedRuntimeError rather than a raw TypeError.
Example:
import { isBun, isDeno, isNode, RUNTIME } from '@tundralibs/compat/runtime';
console.log(`Running on: ${RUNTIME}`);
if (isDeno) {
console.log('Deno-specific code');
} else if (isBun) {
console.log('Bun-specific code');
} else if (isNode) {
console.log('Node.js-specific code');
}Gets the current runtime.
function getRuntime(): Runtime;
function detectRuntime(globals?: object): Runtime;
type Runtime = 'DENO' | 'BUN' | 'NODE' | 'WORKERS' | 'BROWSER' | 'UNKNOWN';getRuntime() is detectRuntime(globalThis). detectRuntime is pure —
pass a fake globals object to test any outcome without the real runtime.
Detection Order:
- Deno —
globalThis.Deno - Bun —
globalThis.Bun - Workers —
navigator.userAgent === 'Cloudflare-Workers'(checked before Node, since workerd also exposesprocess.versions.node) - Node.js —
process.versions.node - Browser —
document, or a worker global scope (WorkerGlobalScope/importScripts) - Unknown — if none match
A jsdom-under-Node environment carries both document and
process.versions.node; the Node test wins, so it stays 'NODE'.
Example:
import { getRuntime } from '@tundralibs/compat/runtime';
const runtime = getRuntime();
switch (runtime) {
case 'DENO':
console.log('Using Deno APIs');
break;
case 'BUN':
console.log('Using Bun APIs');
break;
case 'NODE':
console.log('Using Node.js APIs');
break;
case 'WORKERS':
console.log('Cloudflare Workers — fetch-style APIs only');
break;
case 'BROWSER':
console.log('Browser or web worker');
break;
}On 'WORKERS' and 'BROWSER' the informational helpers return safe
fallbacks rather than throwing: cpus() → 1, totalmem()/freemem()/
uptime() → 0, cwd() → '', getProcessId() → undefined, memoryUsage()
→ all-zero, and the on* handler registrations are no-ops. getEnv()
returns {} — except on Workers, where it returns process.env when
nodejs_compat has populated it. exit() throws (there is no process to
exit).
Capability-backed operations that need a primitive the runtime lacks throw
UnsupportedRuntimeError instead
of a raw TypeError: the whole of file (filesystem), net/udp
(sockets), watch (file watching), webserver's start() (a
port-listening server), and the interactive cli prompts. Feature-detect
with isWorkers / isBrowser (or RUNTIME) and avoid those paths, or
catch the error.
const OS: OperatingSystem; // 'WINDOWS' | 'LINUX' | 'DARWIN' | 'UNKNOWN'Example:
import { OS } from '@tundralibs/compat/runtime';
if (OS === 'WINDOWS') {
console.log('Running on Windows');
} else if (OS === 'DARWIN') {
console.log('Running on macOS');
}Gets the current operating system.
function getOS(): OperatingSystem;
type OperatingSystem = 'WINDOWS' | 'LINUX' | 'DARWIN' | 'UNKNOWN';Platform Mapping:
-
'windows'/'win32'→'WINDOWS' -
'linux'→'LINUX' -
'darwin'→'DARWIN' - Others →
'UNKNOWN'
Example:
import { getOS } from '@tundralibs/compat/runtime';
const os = getOS();
const pathSeparator = os === 'WINDOWS' ? '\\' : '/';const ARCH: Architecture; // 'X64' | 'ARM64' | 'X86' | 'ARM' | 'UNKNOWN'Architecture is the normalized union — Deno's x86_64/aarch64 and
Node/Bun's x64/arm64/ia32/arm collapse to the same uppercase
labels. Anything else (ppc, mips, riscv64, s390x, …) becomes
'UNKNOWN'.
Example:
import { ARCH } from '@tundralibs/compat/runtime';
declare function loadNative(lib: string): void;
if (ARCH === 'ARM64') {
loadNative('libfoo.aarch64.so');
} else if (ARCH === 'X64') {
loadNative('libfoo.x86_64.so');
}Function form for callers that prefer a call over the constant.
function getArch(): Architecture;Gets the current process ID.
function getProcessId(): number | undefined;
const PID: number | undefined; // Constant formExample:
import { getProcessId, PID } from '@tundralibs/compat/runtime';
console.log(`Process ID: ${PID}`);
// or
console.log(`Process ID: ${getProcessId()}`);Terminates the current process with the given exit code. Wraps
Deno.exit / process.exit. Returns never — execution does not
continue past the call. Throws an Error on unknown runtimes (no
exit primitive available).
function exit(code?: number): never;⚠ Pending I/O is discarded. Prefer letting the event loop drain naturally — reach for
exitonly when you need an explicit status code (CLI tools, soak scripts, fatal-error paths).
Example:
import { exit } from '@tundralibs/compat/runtime';
declare const configMissing: boolean;
if (configMissing) {
console.error('config not found');
exit(1);
}
exit(0);unref a timer handle so it does not, on its own, keep the process
alive. Node and Bun return a timer object with .unref(); Deno returns
a numeric id unref'd via Deno.unrefTimer; on browsers and Cloudflare
Workers it is a safe no-op. Pass the return of setTimeout /
setInterval straight through.
function unrefTimer(handle: unknown): void;Example:
import { unrefTimer } from '@tundralibs/compat/runtime';
const timer = setInterval(() => {}, 60_000);
unrefTimer(timer); // a background tick that won't hold the process openGets environment variables as an object.
function getEnv(): Record<string, string>;Runtime Sources:
-
Deno:
Deno.env.toObject()(cached after first call) -
Bun:
Bun.env(live reference) -
Node.js:
process.env(live reference)
Example:
import { getEnv } from '@tundralibs/compat/runtime';
const env = getEnv();
console.log(env.HOME);
console.log(env.PATH);
console.log(env.NODE_ENV);Gets the current working directory.
function cwd(): string;Example:
import { cwd } from '@tundralibs/compat/runtime';
const currentDir = cwd();
console.log(`Working directory: ${currentDir}`);Gets the machine's hostname. This function is exported from the net module.
function hostname(): string;Example:
import { hostname } from '@tundralibs/compat/net';
const host = hostname();
console.log(`Machine hostname: ${host}`);The Runtime module provides unified event handling for process lifecycle and error events across Deno, Bun, and Node.js.
Registers a handler to be called when the process exits.
function onExit(handler: () => void): () => void;Returns: A cleanup function that removes the listener
Runtime Implementation:
-
Deno:
addEventListener('unload', handler) -
Bun:
process.on('exit', handler) -
Node.js:
process.on('exit', handler)
Important Notes:
- In Node.js/Bun, exit handlers must be synchronous
- In Deno, unload handlers can be async but should complete quickly
- Always call the cleanup function to prevent memory leaks
Example:
import { onExit } from '@tundralibs/compat/runtime';
const cleanup = onExit(() => {
console.log('Process exiting, cleaning up...');
// Perform cleanup tasks
});
// Later, to remove the listener:
cleanup();Example - Class with cleanup:
import { onExit } from '@tundralibs/compat/runtime';
class DatabaseConnection {
private exitCleanup?: () => void;
constructor() {
this.exitCleanup = onExit(() => {
this.close();
});
}
close() {
console.log('Closing database connection');
// Close connection
}
dispose() {
// Remove exit listener when disposing manually
this.exitCleanup?.();
this.close();
}
}Registers a handler to be called when an uncaught error occurs.
function onError(handler: (error: Error) => void): () => void;Returns: A cleanup function that removes the listener
Runtime Implementation:
-
Deno:
addEventListener('error', handler) -
Bun:
process.on('uncaughtException', handler) -
Node.js:
process.on('uncaughtException', handler)
Important Notes:
- After an uncaught error, the process state may be inconsistent
- Best practice is to log the error and exit gracefully
- Handler should not throw errors
Example:
import { onError } from '@tundralibs/compat/runtime';
const cleanup = onError((error) => {
console.error('Uncaught error:', error);
// Log to monitoring service
// Perform graceful shutdown
});Registers a handler to be called when an unhandled promise rejection occurs.
function onUnhandledRejection(handler: (reason: unknown) => void): () => void;Returns: A cleanup function that removes the listener
Runtime Implementation:
-
Deno:
addEventListener('unhandledrejection', handler) -
Bun:
process.on('unhandledRejection', handler) -
Node.js:
process.on('unhandledRejection', handler)
Important Notes:
- Always handle promise rejections properly in production code
- This is a safety net, not a replacement for proper error handling
- Handler receives the rejection reason (often an Error object)
Example:
import { onUnhandledRejection } from '@tundralibs/compat/runtime';
const cleanup = onUnhandledRejection((reason) => {
console.error('Unhandled promise rejection:', reason);
// Log to monitoring service
});Registers a handler to be called when the process receives an OS signal.
function onSignal(signal: Signal, handler: () => void): () => void;
type Signal = 'SIGINT' | 'SIGTERM' | 'SIGHUP' | 'SIGBREAK';Returns: A cleanup function that removes the listener
Supported Signals:
-
SIGINT- Interrupt (Ctrl+C) -
SIGTERM- Termination request -
SIGHUP- Hangup (terminal closed) -
SIGBREAK- Break (Windows Ctrl+Break)
Runtime Implementation:
-
Deno:
Deno.addSignalListener(signal, handler) -
Bun:
process.on(signal, handler) -
Node.js:
process.on(signal, handler)
Platform Support:
-
Windows: Only
SIGINTandSIGBREAKare reliable - Unix/Linux/macOS: All signals supported
Example - Graceful shutdown:
import { exit, onSignal } from '@tundralibs/compat/runtime';
const cleanup = onSignal('SIGINT', () => {
console.log('Received SIGINT, shutting down gracefully...');
// Close database connections
// Save state
// Exit cleanly
exit(0);
});Example - Multiple signals:
import { onSignal } from '@tundralibs/compat/runtime';
function gracefulShutdown() {
console.log('Shutting down...');
// Perform cleanup
}
const cleanup1 = onSignal('SIGINT', gracefulShutdown);
const cleanup2 = onSignal('SIGTERM', gracefulShutdown);
// Clean up both listeners later
function dispose() {
cleanup1();
cleanup2();
}Cross-runtime helpers for reading system-level information. All four
of these are backed by node:os, which Deno also exposes through its
Node compat layer — so the implementation is a single code path.
Logical CPU count. Prefers os.availableParallelism() (Node 19+,
respects cgroup quotas under Docker/K8s) and falls back to
os.cpus().length. Always ≥ 1.
function cpus(): number;Example:
import { cpus } from '@tundralibs/compat/runtime';
declare function createPool(options: { size: number }): unknown;
const pool = createPool({ size: cpus() });Total / free system memory in bytes.
function totalmem(): number;
function freemem(): number;Note: "free" semantics differ across operating systems. On Linux this excludes cached pages that the kernel can reclaim, so the value tends to look smaller than expected. Treat it as a hint, not a budget.
Example:
import { freemem, totalmem } from '@tundralibs/compat/runtime';
const totalGiB = (totalmem() / 1024 ** 3).toFixed(1);
const freeGiB = (freemem() / 1024 ** 3).toFixed(1);
console.log(`mem: ${freeGiB} / ${totalGiB} GiB free`);Host machine uptime in seconds. (For process uptime use
performance.now() / 1000.)
function uptime(): number;Current process memory snapshot. Wraps Deno.memoryUsage() /
process.memoryUsage() with a normalized shape.
type MemoryUsage = {
rss: number; // resident set size
heapTotal: number; // V8 heap committed
heapUsed: number; // V8 heap in use
external: number; // C++ objects bound to JS
arrayBuffers: number; // ArrayBuffer-backed memory (0 on Deno)
};
function memoryUsage(): MemoryUsage;Deno's
Deno.memoryUsage()does not expose anarrayBuffersfield; the normalized output reports0for it on Deno.
Example:
import { memoryUsage } from '@tundralibs/compat/runtime';
setInterval(() => {
const m = memoryUsage();
console.log(
`heap: ${(m.heapUsed / 1024 ** 2).toFixed(1)} MiB / ` +
`${(m.heapTotal / 1024 ** 2).toFixed(1)} MiB, ` +
`rss: ${(m.rss / 1024 ** 2).toFixed(1)} MiB`,
);
}, 30_000);import { isBun, isDeno, isNode } from '@tundralibs/compat/runtime';
// `Bun` is typed by `@types/bun` in Bun projects.
declare const Bun: { file(path: string): { text(): Promise<string> } };
async function readFile(path: string): Promise<string> {
if (isDeno) {
return await Deno.readTextFile(path);
}
if (isBun) {
const file = Bun.file(path);
return await file.text();
}
if (isNode) {
const fs = await import('node:fs/promises');
return await fs.readFile(path, 'utf-8');
}
throw new Error('Unsupported runtime');
}import { RUNTIME } from '@tundralibs/compat/runtime';
// `Bun` is typed by `@types/bun` in Bun projects.
declare const Bun: {
serve(options: { port: number; fetch: (req: Request) => Response }): unknown;
};
async function createServer(port: number) {
switch (RUNTIME) {
case 'DENO':
return Deno.serve({ port }, (req) => {
return new Response('Hello from Deno!');
});
case 'BUN':
return Bun.serve({
port,
fetch: (req) => new Response('Hello from Bun!'),
});
case 'NODE':
const http = await import('node:http');
const server = http.createServer((req, res) => {
res.end('Hello from Node.js!');
});
server.listen(port);
return server;
default:
throw new Error('Unsupported runtime');
}
}import { getEnv, OS } from '@tundralibs/compat/runtime';
import { join } from '@tundralibs/compat/path';
function getConfigPath(): string {
const env = getEnv();
switch (OS) {
case 'WINDOWS':
return join(env.APPDATA as string, 'MyApp', 'config.json');
case 'DARWIN':
return join(
env.HOME as string,
'Library',
'Application Support',
'MyApp',
'config.json',
);
case 'LINUX':
return join(
env.HOME as string,
'.config',
'myapp',
'config.json',
);
default:
return './config.json';
}
}import { cwd, getEnv } from '@tundralibs/compat/runtime';
import { join } from '@tundralibs/compat/path';
interface AppConfig {
port: number;
host: string;
debug: boolean;
dataDir: string;
}
function loadConfig(): AppConfig {
const env = getEnv();
return {
port: parseInt(env.PORT as string || '3000'),
host: env.HOST as string || 'localhost',
debug: env.DEBUG === 'true',
dataDir: env.DATA_DIR as string || join(cwd(), 'data'),
};
}import { OS, PID, RUNTIME } from '@tundralibs/compat/runtime';
import { hostname } from '@tundralibs/compat/net';
async function logSystemInfo() {
const host = hostname();
console.log('System Information:');
console.log(` Runtime: ${RUNTIME}`);
console.log(` OS: ${OS}`);
console.log(` Hostname: ${host}`);
console.log(` Process ID: ${PID}`);
}
// Usage
await logSystemInfo();
// System Information:
// Runtime: DENO
// OS: DARWIN
// Hostname: macbook-pro.local
// Process ID: 12345import { isBun, isDeno, isNode } from '@tundralibs/compat/runtime';
interface RuntimeCapabilities {
hasNativeTypescript: boolean;
hasBunAPIs: boolean;
hasDenoAPIs: boolean;
hasNodeAPIs: boolean;
fastStartup: boolean;
}
function detectCapabilities(): RuntimeCapabilities {
return {
hasNativeTypescript: isDeno || isBun,
hasBunAPIs: isBun,
hasDenoAPIs: isDeno,
hasNodeAPIs: isNode || isBun,
fastStartup: isBun || isDeno,
};
}import { isNode } from '@tundralibs/compat/runtime';
// Dynamic imports based on runtime
async function getFileSystem() {
if (isNode) {
// Use Node.js built-in fs
return await import('node:fs/promises');
} else {
// Use polyfill or alternative
return await import('./fs-polyfill.ts');
}
}import { describe, it } from '@tundralibs/compat/test';
import { cwd, getEnv, isDeno, PID, RUNTIME } from '@tundralibs/compat/runtime';
import { hostname } from '@tundralibs/compat/net';
// Bring your own assertions (e.g. `@std/assert` on Deno).
declare function assert(expr: unknown, msg?: string): asserts expr;
declare function assertMatch(actual: string, expected: RegExp): void;
describe(`Runtime: ${RUNTIME}`, () => {
it('should detect correct runtime', () => {
assertMatch(RUNTIME, /^(DENO|BUN|NODE)$/);
});
it('should have process ID', () => {
assert(PID !== undefined && PID > 0);
});
it('should have working directory', () => {
const dir = cwd();
assert(dir.length > 0);
});
it('should access environment', () => {
const env = getEnv();
assert(env !== undefined);
});
it({
name: 'should get hostname',
// Only test in Deno
ignore: !isDeno,
fn() {
const host = hostname();
assert(host.length > 0);
},
});
});import { getEnv, OS } from '@tundralibs/compat/runtime';
function getLineEnding(): string {
return OS === 'WINDOWS' ? '\r\n' : '\n';
}
function getPathSeparator(): string {
return OS === 'WINDOWS' ? ';' : ':';
}
function getTempDir(): string {
const env = getEnv();
if (OS === 'WINDOWS') {
return env.TEMP as string || env.TMP as string || 'C:\\temp';
} else {
return env.TMPDIR as string || '/tmp';
}
}-
Use constants over functions -
isDenois faster thangetRuntime() === 'DENO' -
Cache environment -
getEnv()caches in Deno, so call once and store -
Handle UNKNOWN - Always have fallback for
RUNTIME === 'UNKNOWN' - Test all runtimes - Verify behavior in Deno, Bun, and Node.js
- Prefer feature detection - When possible, detect features not runtimes
Example:
import { getRuntime, isDeno } from '@tundralibs/compat/runtime';
// ✅ Good - Direct constant check
if (isDeno) {
// Deno code
}
// ❌ Less efficient - Function call
if (getRuntime() === 'DENO') {
// Deno code
}
// ✅ Good - Feature detection
if (typeof Deno !== 'undefined' && 'permissions' in Deno) {
// Use Deno permissions API
}