-
Notifications
You must be signed in to change notification settings - Fork 2
Compat WebServer
Cross-runtime HTTP/HTTPS server with WebSocket support for Bun, Deno, and Node.js.
- Features
- Installation
- Quick Start
- Configuration
- API Reference
- Events
- Metrics
- Examples
- Runtime Compatibility
- Related Documentation
| Feature | Bun | Deno | Node.js |
|---|---|---|---|
| HTTP/HTTPS | ✅ | ✅ | ✅ |
| UNIX sockets | ✅ | ✅ | ✅ |
| WebSocket | ✅ | ✅ | ✅* |
| WS upgrade hook (typed) | ✅ | ✅ | ✅ |
| WS subprotocol selection | ✅ | ✅ | ✅ |
| WS bufferedAmount | ✅ | ✅ | ✅ |
| WS ping / pong callbacks | ✅ | ❌\† | ✅ |
| WS drain callback | ✅ | ✅\‡ | ✅\¶ |
| WS error callback | ✅\§ | ✅ | ✅ |
| Backlog option | ❌ | ✅ | ✅ |
| ReusePort | ❌ | ✅ | ✅\‖ |
| Graceful shutdown | ✅ | ✅ | ✅ |
| Request metrics | ✅ | ✅ | ✅ |
| Abort signal | ✅ | ✅ | ✅ |
* Node.js WebSocket support is built on the ws npm
package (a normal dependency of @tundralibs/compat). Pure-JS, no
native deps.
\† Deno's WebSocket consumes ping/pong frames internally; user
callbacks are unreachable. Hard runtime limit.
\‡ Deno doesn't surface backpressure events; best-effort emulation by
polling bufferedAmount after each send() — it may not fire when the
OS flushes a small/loopback write synchronously (bufferedAmount never
rises).
\§ Bun's runtime has no error callback; we wrap user handlers in
try/catch and synthesize error events from caught throws.
\¶ Node fires drain from the underlying net.Socket's own drain
event (the ws package has no WebSocket-level equivalent), so — unlike
Deno's bufferedAmount polling — it reflects real socket backpressure:
the callback runs when a send buffer that had filled empties again.
\‖ Node passes reusePort through server.listen({ … }), but only
where the kernel exposes SO_REUSEPORT (Linux, FreeBSD, illumos/Solaris,
AIX) and on Node ≥ 22.12. On macOS and Windows Node would raise ENOTSUP,
so it is skipped there and becomes a no-op — matching Deno/Bun, which
tolerate it silently on unsupported platforms rather than erroring.
Deno:
deno add @tundralibs/compatBun:
bunx jsr add @tundralibs/compatNode.js:
npx jsr add @tundralibs/compatDirect import (Deno):
import { WebServer } from 'jsr:@tundralibs/compat/webserver';import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('MyAPI', {
mode: 'TCP',
port: 8080,
handler: (request, info) => {
return new Response(`Hello! Request ID: ${info.requestId}`);
},
});
server.on('onStart', (name, mode) => {
console.log(`${name} started on ${server.address}`);
});
server.start();Standard HTTP server on a TCP port:
import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('API', {
mode: 'TCP',
port: 3000, // Default: 8008
hostname: '0.0.0.0', // Default: 'localhost'
backlog: 511, // Connection queue size (Deno/Node only; ignored on Bun)
reusePort: true, // Allow port reuse (Deno + Node on SO_REUSEPORT platforms — see Features table)
handler: (req, info) => new Response('OK'),
});| Option | Type | Default | Description |
|---|---|---|---|
mode |
'TCP' |
Required | Server mode |
port |
number |
8008 |
Port number (0-65535) |
hostname |
string |
'localhost' |
Bind address |
backlog |
number |
- | Connection queue size |
reusePort |
boolean |
- | Allow multiple processes to bind (Deno + Node on SO_REUSEPORT platforms — see Features table) |
handler |
Function |
Required | Request handler |
tls |
TLSOptions |
- | TLS configuration |
websocket |
WebSocketHandler |
- | WebSocket handlers |
abortSignal |
AbortSignal |
- | Signal for graceful shutdown |
metrics |
boolean |
false |
Opt-in metrics collection |
Unix domain socket server (IPC):
import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('LocalAPI', {
mode: 'UNIX',
unixSocketPath: '/var/run/myapp.sock',
handler: (req, info) => new Response('OK'),
});| Option | Type | Description |
|---|---|---|
mode |
'UNIX' |
Server mode |
unixSocketPath |
string |
Path to socket file |
handler |
Function |
Request handler |
websocket |
WebSocketHandler |
WebSocket handlers |
abortSignal |
AbortSignal |
Signal for graceful shutdown |
Connect via curl:
curl --unix-socket /var/run/myapp.sock http://localhost/File-based certificates:
import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('SecureAPI', {
mode: 'TCP',
port: 443,
tls: {
certFile: '/etc/ssl/cert.pem',
keyFile: '/etc/ssl/key.pem',
caFile: '/etc/ssl/ca.pem', // Optional
},
handler: (req) => new Response('Secure!'),
});String-based certificates:
import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('SecureAPI', {
mode: 'TCP',
port: 443,
tls: {
cert: '-----BEGIN CERTIFICATE-----\n...',
key: '-----BEGIN PRIVATE KEY-----\n...',
ca: ['-----BEGIN CERTIFICATE-----\n...'],
},
handler: (req) => new Response('Secure!'),
});See WebSocket Documentation for complete details.
import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('WSServer', {
mode: 'TCP',
port: 8080,
handler: (req) => new Response('HTTP endpoint'),
websocket: {
open: (ws, ctx) => {
console.log('Client connected from', ctx.remoteAddress);
},
message: (ws, data) => {
ws.send(`Echo: ${data}`);
},
close: (ws, code, reason) => {
console.log(`Client disconnected: ${code}`);
},
error: (ws, error) => {
console.error('WebSocket error:', error);
},
idleTimeout: 120, // seconds
},
});Add an upgrade callback to authenticate, refuse, pick subprotocols,
or attach typed connection state — runs once per incoming upgrade,
before the handshake completes.
import { WebServer } from '@tundralibs/compat/webserver';
declare function verifyToken(token: string | null): string | null;
type ConnState = { userId: string };
const server = new WebServer<ConnState>('WSServer', {
mode: 'TCP',
port: 8080,
handler: () => new Response('OK'),
websocket: {
upgrade: (req, info) => {
// Authenticate
const token = req.headers.get('authorization');
const userId = verifyToken(token);
if (!userId) return false; // Refuse — falls through to HTTP handler
// Pick a subprotocol from the client's offer
const requested = req.headers.get('sec-websocket-protocol')
?.split(',').map((s) => s.trim()) ?? [];
const protocol = requested.includes('tundra-v1')
? 'tundra-v1'
: undefined;
return { data: { userId }, protocol };
},
open: (ws) => {
// ws.data is typed `ConnState` — populated by the upgrade hook
console.log('user joined:', ws.data.userId);
},
message: (ws, data) => {
// ws.bufferedAmount tells you how much is queued
if (ws.bufferedAmount > 1_000_000) {
ws.close(1008, 'slow consumer');
return;
}
ws.send(`Echo: ${data}`);
},
},
});Upgrade decisions:
| Return | Effect |
|---|---|
false |
Refuse — request flows to HTTP handler (return 401/403/etc) |
true |
Accept with default behavior (back-compat with no hook) |
{ data, … } |
Accept and attach data as the connection's typed state |
The decision object can also carry protocol (chosen subprotocol from
Sec-WebSocket-Protocol) and headers (extra headers in the 101
response).
When no upgrade hook is supplied, every WebSocket upgrade is
accepted and ws.data defaults to the upgrade context — preserves
behavior for code written before the hook existed.
The websocket option is opt-in. If you want full control over the
WebSocket handshake — non-standard frames, binary protocols beyond
what the compat shape allows, integration with libraries that expect
the runtime's native WebSocket type — you can skip it entirely and
handle the upgrade inside your HTTP handler. Below is the same echo
server written natively for each runtime, without WebServer at
all, for comparison.
Bun — Bun.serve() returns a WS-aware server:
Bun.serve({
port: 8080,
fetch(req, server) {
if (server.upgrade(req)) return; // hands the connection off to `websocket` below
return new Response('hello');
},
websocket: {
open(ws) {
ws.send('welcome');
},
message(ws, msg) {
ws.send(`echo: ${msg}`);
},
},
});Deno — Deno.upgradeWebSocket() inside any HTTP handler:
Deno.serve({ port: 8080 }, (req) => {
if (req.headers.get('upgrade') === 'websocket') {
const { socket, response } = Deno.upgradeWebSocket(req);
socket.onopen = () => socket.send('welcome');
socket.onmessage = (ev) => socket.send(`echo: ${ev.data}`);
return response;
}
return new Response('hello');
});Node.js — ws package directly on http.Server:
import { createServer } from 'node:http';
import { WebSocketServer as WsLib } from 'ws';
const server = createServer((_, res) => res.end('hello'));
const wss = new WsLib({ server });
wss.on('connection', (ws) => {
ws.send('welcome');
ws.on('message', (msg) => ws.send(`echo: ${msg}`));
});
server.listen(8080);Hybrid — keep WebServer for HTTP routes + non-WS features, but
have its upgrade hook decline so you can do the WS handshake natively
in a sibling server. Less common; usually the compat websocket
option (with the upgrade hook) is sufficient. The hybrid pattern
makes sense only when you need a third-party library that consumes
the runtime's native WebSocket type and won't accept the compat
ServerWebSocket<T> shape.
new WebServer<T = unknown>(name: string, options: ServerOptions<ServerMode, T>)Creates a new server instance. Validates all options during construction.
Type parameter:
-
T— type of the custom data attached to each WebSocket connection (set by theupgradehook's return value, surfaced asws.data). Defaults tounknownfor callers not using WebSocket or not customizing connection state.
Parameters:
-
name- Unique server name (used in events/logging) -
options- Server configuration
Throws:
-
ServerConfigurationError- Invalid configuration -
ServerPermissionError- Insufficient file permissions
| Property | Type | Description |
|---|---|---|
name |
string |
Server name (readonly) |
mode |
'TCP' | 'UNIX' |
Server mode (readonly) |
options |
ServerOptions |
Configuration (readonly) |
state |
ServerState |
Current lifecycle state |
address |
string | null |
Listening address or null if stopped |
metrics |
ServerMetrics |
Performance metrics (copy) |
Starts the server and begins accepting connections.
import type { WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer;
server.start();
console.log(`Listening on ${server.address}`);Throws:
-
ServerAlreadyRunningError- Server not in STOPPED state -
UnsupportedRuntimeError- Runtime cannot host a server (Workers, browsers, and any runtime other than Bun/Deno/Node) -
ServerError- Failed to bind
Stops the server.
import type { WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer;
// Graceful: wait for active requests
await server.stop();
// Force: close immediately
await server.stop(false);Throws:
-
ServerNotRunningError- Server not in RUNNING state -
UnsupportedRuntimeError- Runtime cannot host a server -
ServerError- The stop operation itself failed
Registers an event listener.
import type { WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer;
server.on('onResponse', (name, req, info, res) => {
console.log(`${req.method} ${req.url} → ${res.status}`);
});Removes event listener(s).
import type { ServerEvents, WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer;
declare const myHandler: ServerEvents['onError'];
// Remove specific listener
server.off('onError', myHandler);
// Remove all listeners for event
server.off('onError');Marks server as referenced (prevents process exit).
Throws:
-
ServerNotRunningError- Server is not in RUNNING state (also thrown if called before the firststart()) -
UnsupportedRuntimeError- Runtime cannot host a server -
ServerError- The underlyingref()call failed
Marks server as unreferenced (allows process exit).
Throws: same as ref() — ServerNotRunningError / UnsupportedRuntimeError / ServerError.
import type { WebServer } from '@tundralibs/compat/webserver';
declare const metricsServer: WebServer;
// Metrics server that won't block shutdown
metricsServer.start();
metricsServer.unref();Resets all metrics to initial values.
import type { WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer;
setInterval(() => {
console.log('Hourly stats:', server.metrics);
server.resetMetrics();
}, 3600000);| Event | Parameters | Description |
|---|---|---|
onStart |
(name, mode) |
Server started |
onClose |
(name, mode) |
Server stopped |
onResponse |
(name, request, info, response) |
Request completed |
onError |
(name, error, request?, info?) |
Error occurred |
onWarning |
(name, message) |
Warning (e.g., unsupported option) |
import type { WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer;
server.on('onStart', (name, mode) => {
console.log(`[${name}] Started in ${mode} mode`);
});
server.on('onResponse', (name, req, info, res) => {
console.log(`[${info.requestId}] ${req.method} ${req.url} → ${res.status}`);
});
server.on('onError', (name, error, req, info) => {
console.error(`[${name}] Error:`, error.message);
if (req) console.error(` Request: ${req.method} ${req.url}`);
});Collection is opt-in: pass metrics: true in the constructor
options. Without it every counter reads back zero (the collection work
is skipped entirely — consumers with their own observability stack
shouldn't pay for it per request).
import type { WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer; // constructed with `metrics: true`
const metrics = server.metrics;
// Request metrics
console.log(`Total: ${metrics.requests.total}`);
console.log(`Active: ${metrics.requests.active}`);
console.log(`Peak: ${metrics.requests.peakActive}`);
// Status codes
console.log(`Success: ${metrics.statusCodes['2xx']}`);
console.log(`Errors: ${metrics.statusCodes['5xx']}`);
// Response times (ms)
console.log(`Min: ${metrics.responseTime.min.toFixed(2)}ms`);
console.log(`Max: ${metrics.responseTime.max.toFixed(2)}ms`);
console.log(`Avg: ${metrics.responseTime.average.toFixed(2)}ms`);
// WebSocket metrics
console.log(`WS Connections: ${metrics.websocket.connections.active}`);
console.log(`WS Messages: ${metrics.websocket.messages.received}`);import { WebServer } from '@tundralibs/compat/webserver';
const server = new WebServer('RestAPI', {
mode: 'TCP',
port: 3000,
handler: async (req, info) => {
const url = new URL(req.url);
if (url.pathname === '/users' && req.method === 'GET') {
return Response.json({ users: [] });
}
if (url.pathname === '/users' && req.method === 'POST') {
const body = await req.json();
return Response.json({ id: 1, ...body }, { status: 201 });
}
return new Response('Not Found', { status: 404 });
},
});
server.start();import { WebServer } from '@tundralibs/compat/webserver';
const controller = new AbortController();
const server = new WebServer('API', {
mode: 'TCP',
port: 8080,
handler: (req) => new Response('OK'),
abortSignal: controller.signal,
});
server.start();
// Graceful shutdown on SIGINT
process.on('SIGINT', () => {
console.log('Shutting down...');
controller.abort();
});import { WebServer } from '@tundralibs/compat/webserver';
const server: WebServer = new WebServer('API', {
mode: 'TCP',
port: 8080,
metrics: true, // collection is opt-in
handler: (req, info): Response => {
const url = new URL(req.url);
if (url.pathname === '/metrics') {
return Response.json(server.metrics);
}
return new Response('OK');
},
});import type { RequestInfo, WebServer } from '@tundralibs/compat/webserver';
declare const server: WebServer;
declare const metricsCollector: {
record(req: Request, res: Response, info: RequestInfo): void;
};
declare const alertService: { warn(message: string): void };
server.on('onResponse', [
// Console logging
(name, req, info, res) => {
console.log(`${req.method} ${req.url} → ${res.status}`);
},
// Metrics collection
(name, req, info, res) => {
metricsCollector.record(req, res, info);
},
// Slow request alerting
(name, req, info, res) => {
const duration = Date.now() - info.requestTime.getTime();
if (duration > 5000) {
alertService.warn(`Slow request: ${req.url} took ${duration}ms`);
}
},
]);- Full HTTP/HTTPS support
- Native WebSocket (best performance)
-
errorcallback synthesized from caught throws in user handlers (Bun's runtime doesn't expose a native error callback) -
backlogoption ignored (warning emitted)
- Full HTTP/HTTPS support
- WebSocket via
Deno.upgradeWebSocket() -
ping/pongcallbacks unavailable — Deno consumes those frames internally (hard runtime limit) -
draincallback best-effort viabufferedAmountpolling (~50ms tick); may not fire on synchronous local flushes (see the Features table) - All other options supported
- Full HTTP/HTTPS support
- WebSocket via the
wsnpm package (taken as a normal dependency of@tundralibs/compat), loaded lazily onstart()so importing the module never pulls it in -
backlogsupported;reusePorthonored onSO_REUSEPORTplatforms (Node ≥ 22.12), a no-op on macOS/Windows (see the Features table) -
draincallback fires from the underlying socket'sdrainevent — it reflects real send backpressure (see the Features table) - Node's HTTP server hands the handler an
IncomingMessage, not a FetchRequest, soWebServerpasses a lightweightRequest-shaped view instead of building one eagerly (a measurable throughput win — seebench/OPTIMIZATION-NOTES.md). It is a faithfulRequest:instanceof Requestholds and every member (method/url/headers/body,text()/json()/arrayBuffer()/formData()/clone(),signal, …) behaves identically. The one edge:bodyUsedonly flipstrueonce a body-read method is called — reading the rawbodystream directly does not update it. Consume the body through one path (a read method or the stream, not both, and once), as Fetch already requires, and this never surfaces.
WebServer needs a port-listening HTTP server, which these runtimes do
not provide. Construction is safe — new WebServer(...) never throws on
them (the filesystem-touching option validation for UNIX sockets and
file-based TLS is skipped) — but start() rejects with
UnsupportedRuntimeError (operation: 'WebServer.start').
Gate on isWorkers / isBrowser from @tundralibs/compat/runtime, or
catch the error. On Cloudflare Workers, export a fetch handler instead
of starting a server.
- WebSocket Guide - WebSocket setup and authentication
- TLS Configuration - HTTPS and certificate setup
- Metrics Reference - Understanding server metrics
- Error Handling - Error types and handling