@ws-kit/bun
Version:
Bun platform adapter for WS-Kit leveraging native WebSocket API with built-in pub/sub and low-latency message routing
284 lines • 13.9 kB
JavaScript
// SPDX-FileCopyrightText: 2025-present Kriasoft
// SPDX-License-Identifier: MIT
import {} from "@ws-kit/core";
/**
* Internal helper to perform WebSocket upgrade with precomputed connection data.
*
* Per ADR-035: Separated from auth to isolate concerns.
* Auth happens in fetch() → data passed here → upgradeConnection() only wires Bun.
*
* Returns true if upgrade succeeded (Bun sent 101), false otherwise.
*
* @internal
*/
function upgradeConnection(req, server, clientId, initialData, clientIdHeader) {
// Merge auth data with automatic fields (clientId, connectedAt).
// Rationale: Opaque transport pattern (ADR-033) — ws.data is immutable,
// so we construct it once here with all context.
const data = {
clientId,
connectedAt: Date.now(),
...(initialData ?? {}),
};
return server.upgrade(req, {
data: data,
headers: { [clientIdHeader]: clientId },
});
}
/**
* Create Bun WebSocket handlers for use with Bun.serve.
*
* Returns a `{ fetch, websocket }` object that can be passed directly to Bun.serve.
* Accepts both typed routers and core routers. Per ADR-035, this adapter is a mechanical
* bridge between Bun's WebSocket API and the router's internal protocol.
*
* **Usage**:
* ```typescript
* import { createRouter } from "@ws-kit/zod";
* import { createBunHandler } from "@ws-kit/bun";
*
* const router = createRouter<TContext>();
* const { fetch, websocket } = createBunHandler(router);
*
* Bun.serve({ fetch, websocket, port: 3000 });
* ```
*
* **Connection Flow**:
* 1. HTTP request arrives at Bun.serve fetch handler
* 2. Your code calls `router.upgrade(req, { server })` or similar
* 3. Authentication is performed (if configured)
* 4. Bun upgrades the connection to WebSocket
* 5. `websocket.open(ws)` → router handles the connection
* 6. `websocket.message(ws, msg)` → router routes the message
* 7. `websocket.close(ws, code, reason)` → router handles cleanup
*
* @param router - TypedRouter or WebSocketRouter instance
* @param options - Optional handler configuration
* @returns Object with `fetch` and `websocket` handlers for Bun.serve
*/
export function createBunHandler(router, options) {
// Per ADR-035: Unwrap typed routers (e.g., from @ws-kit/zod) to access core.
// Rationale: Ensures low-level API behaves identically to high-level serve().
// Mirrors serve.ts logic for consistency. Fallback for direct core routers.
const coreRouter = router[Symbol.for("ws-kit.core")] ?? router;
return {
/**
* Fetch handler for HTTP upgrade requests.
*
* This handler is called for every HTTP request. Your application code should:
* 1. Check if the request is a WebSocket upgrade (path, method, headers)
* 2. Call this fetch handler or delegate to it
* 3. Return the result (undefined on successful upgrade, Response on failure/error)
*
* **Bun Semantics**: After server.upgrade() returns true, Bun has already sent the
* "101 Switching Protocols" response. Returning undefined signals that the request
* is fully handled. Returning a Response only on failure is the correct pattern.
*
* **Authentication**: If `authenticate` is configured and returns `undefined`,
* the connection is rejected with a 401 (or configured status). To accept a
* connection with minimal data, return an empty object `{}` instead.
*
* For a simple example:
* ```typescript
* const { fetch, websocket } = createBunHandler(router);
*
* Bun.serve({
* fetch(req, server) {
* const url = new URL(req.url);
*
* // Route WebSocket requests
* if (url.pathname === "/ws") {
* return fetch(req, server);
* }
*
* // Handle other HTTP requests
* return new Response("Not Found", { status: 404 });
* },
* websocket,
* });
* ```
*
* **Pub/Sub Note**: If the router is passed to `serve()`, Pub/Sub is auto-initialized
* with BunPubSub. For custom Pub/Sub backends, use the `withPubSub()` plugin before
* creating the handler.
*/
fetch: async (req, server) => {
try {
const clientIdHeader = options?.clientIdHeader ?? "x-client-id";
// Per ADR-035: Use native crypto.randomUUID() — no external deps.
// Rationale: Bun has built-in crypto; removes uuid package.
const clientId = crypto.randomUUID();
// Per ADR-035: Auth happens here, before upgrade, as a gatekeeper.
// Rationale: Single call, predictable semantics, security-critical.
const customData = options?.authenticate
? await Promise.resolve(options.authenticate(req))
: undefined;
// Per ADR-035: undefined = reject with configured status (default 401).
// Rationale: Aligns implementation with documented behavior.
// Return {} or object to accept with minimal or custom data.
if (options?.authenticate && customData === undefined) {
const rejection = options.authRejection ?? {
status: 401,
message: "Unauthorized",
};
const status = rejection.status ?? 401;
return new Response(rejection.message, { status });
}
// Per ADR-034: Upgrade with precomputed data; don't return Response after success.
// Rationale: After server.upgrade() returns true, Bun sent 101.
// Returning undefined signals Bun that request is fully handled.
const upgraded = upgradeConnection(req, server, clientId, customData, clientIdHeader);
if (upgraded)
return; // Success: no Response needed
// Upgrade failed (not a WebSocket request, missing headers, etc.).
// Call onError for observability before returning 400.
options?.onError?.(new Error("WebSocket upgrade failed"), {
type: "upgrade",
req,
});
return new Response("Upgrade failed", { status: 400 });
}
catch (error) {
// Unexpected error in fetch handler (auth threw, server.upgrade threw, etc.).
// Log and notify onError hook, then return 500.
console.error("[ws] Error in fetch handler:", error);
const errorObj = error instanceof Error ? error : new Error(String(error));
options?.onError?.(errorObj, {
type: "upgrade",
req,
});
return new Response("Internal server error", { status: 500 });
}
},
/**
* WebSocket handler for Bun.serve.
*
* Bun calls these methods as WebSocket lifecycle events occur.
* This handler binds those events to the core router's internal message processing.
*/
websocket: {
/**
* Called when a WebSocket connection is successfully established.
*/
async open(bunWs) {
try {
// Sanity check: clientId must be set by fetch/upgradeConnection.
// Rationale: Guards against misconfigured server or missing data flow.
if (!bunWs.data?.clientId) {
console.error("[ws] WebSocket missing clientId in data, closing");
bunWs.close(1008, "Missing client ID");
return;
}
// Per ADR-033 (opaque transport): Set initialData so router can merge into ctx.data.
// Rationale: Router uses initialData to populate ctx.data during handleOpen.
// This is the canonical connection data flow.
const ws = bunWs;
ws.initialData = bunWs.data;
// Call core router's handler using call() to preserve 'this' binding.
// Rationale: Supports routers with instance methods (not just arrow functions).
await coreRouter.websocket.open(ws);
// Per ADR-035: Call onOpen hook after successful open (sync, for side effects).
// Rationale: Separate from error hook — success and error are distinct flows.
// Note: Pass both ws and data per BunOpenCloseContext; types now match runtime.
try {
options?.onOpen?.({ ws: bunWs, data: bunWs.data });
}
catch (error) {
console.error("[ws] Error in onOpen hook:", error);
}
}
catch (error) {
// Per ADR-035: Call onError hook with 'open' phase context.
// Rationale: Sync-only hook for logging. Don't suppress error.
console.error("[ws] Error in open handler:", error);
const errorObj = error instanceof Error ? error : new Error(String(error));
options?.onError?.(errorObj, {
type: "open",
clientId: bunWs.data?.clientId,
data: bunWs.data,
});
try {
bunWs.close(1011, "Internal server error");
}
catch {
// Already closed — safe to ignore
}
}
},
/**
* Called when a message is received from the client.
*/
async message(bunWs, data) {
try {
const ws = bunWs;
// Convert Buffer to ArrayBuffer for router compatibility.
// Rationale: Router expects string | ArrayBuffer; Bun may send Buffer.
// Preserve zero-copy semantics by converting only when needed.
const payload = data instanceof Buffer
? new Uint8Array(data).buffer
: data;
// Per ADR-035: Call coreRouter to delegate to router message handling.
// Rationale: Router is responsible for validation, routing, etc.
// Adapter just bridges Bun events to router interface.
await coreRouter.websocket.message(ws, payload);
}
catch (error) {
// Per ADR-035: Log error and call onError hook; don't close connection.
// Rationale: Single message error shouldn't kill the connection.
// User's error handler decides if closure is needed.
console.error("[ws] Error in message handler:", error);
const errorObj = error instanceof Error ? error : new Error(String(error));
options?.onError?.(errorObj, {
type: "message",
clientId: bunWs.data?.clientId,
data: bunWs.data,
});
}
},
/**
* Called when the WebSocket connection is closed.
*/
async close(bunWs, code, reason) {
try {
const ws = bunWs;
// Per ADR-035: Call coreRouter.websocket.close for cleanup.
// Rationale: Router handles unsubscription, connection cleanup, etc.
// Code and reason let router log close reason for debugging.
await coreRouter.websocket.close(ws, code, reason);
// Call onClose hook after router cleanup (not in catch block).
// Rationale: Distinguish success (onClose) from error (onError).
// Note: Pass both ws and data per BunOpenCloseContext; types now match runtime.
try {
options?.onClose?.({ ws: bunWs, data: bunWs.data });
}
catch (error) {
console.error("[ws] Error in onClose hook:", error);
}
}
catch (error) {
// Per ADR-035: Log and notify onError on close handler failure.
// Rationale: Close failures (cleanup errors) are worth observing.
console.error("[ws] Error in close handler:", error);
const errorObj = error instanceof Error ? error : new Error(String(error));
options?.onError?.(errorObj, {
type: "close",
clientId: bunWs.data?.clientId,
data: bunWs.data,
});
}
},
/**
* Optional: Called when the socket's write buffer has drained.
*
* Per ADR-035: Not implemented; router manages backpressure internally.
* Rationale: Adapter is mechanical. Backpressure (flow control) is
* router responsibility, not adapter responsibility.
* Can be extended by users if needed for advanced scenarios.
*/
drain(bunWs) {
void bunWs; // Unused; reserved for future backpressure handling
},
},
};
}
//# sourceMappingURL=handler.js.map