/** * WebSocket Server Core Library - Optimized * * High-performance WebSocket routing with: * - Zero boilerplate configuration * - 100% type inference from backend to frontend * - Singleton TextEncoder/Decoder for performance * - Pre-computed binary action detection * - Context management (user/project) * - TypeBox schema validation (bundled with Elysia) */ import type { Elysia } from 'elysia'; import { t, type TSchema, type Static } from 'elysia'; import { Value } from '@sinclair/typebox/value'; import { debug } from './logger'; // ============================================================================ // Singleton Encoders (Performance Optimization) // ============================================================================ /** Singleton TextEncoder - reused across all encode operations */ const textEncoder = new TextEncoder(); /** Singleton TextDecoder - reused across all decode operations */ const textDecoder = new TextDecoder(); // ============================================================================ // Pre-computed Binary Actions (Performance Optimization) // ============================================================================ /** Actions known to contain binary data - skip containsBinary() check */ const BINARY_ACTIONS = new Set([ 'preview:frame', 'file:upload', 'file:download', 'terminal:binary' ]); /** * Register an action as binary (for custom binary handlers) */ export function registerBinaryAction(action: string): void { BINARY_ACTIONS.add(action); } // ============================================================================ // WebSocket Connection Interface // ============================================================================ /** * WebSocket Connection with Context * Extends Elysia WebSocket with custom context properties */ export interface WSConnection { /** WebSocket ready state (0=CONNECTING, 1=OPEN, 2=CLOSING, 3=CLOSED) */ readyState: number; /** Send message to client */ send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void; /** Close connection */ close: (code?: number, reason?: string) => void; } // ============================================================================ // Route Configuration Types // ============================================================================ /** * WebSocket route configuration */ interface RouteConfig { /** Incoming data schema (validates client → server) */ data: TData; } /** * HTTP-like route configuration for request-response pattern */ interface HTTPRouteConfig< TData extends TSchema, TResponse extends TSchema > { /** Request data schema (optional) */ data?: TData; /** Response data schema (required) */ response: TResponse; } /** * HTTP route handler callback */ type HTTPHandler< TData extends TSchema, TResponse extends TSchema > = (params: { conn: WSConnection; data: TData extends TSchema ? Static : never; }) => Promise> | Static; /** * WebSocket route handler callback */ type RouteHandler = (params: { conn: WSConnection; data: Static; }) => void | Promise; /** * Internal route definition */ interface Route { action: string; dataSchema: TSchema; handler: (params: { conn: WSConnection; data: any }) => void | Promise; } /** * Internal HTTP route definition */ interface HTTPRoute { action: string; dataSchema?: TSchema; responseSchema: TSchema; handler: (params: { conn: WSConnection; data: any }) => Promise | any; } // ============================================================================ // Binary Message Utilities (Optimized) // ============================================================================ /** * Check if payload contains binary data (Uint8Array or ArrayBuffer) * Optimized with early return and iterative approach */ function containsBinary(obj: any): boolean { if (obj instanceof Uint8Array || obj instanceof ArrayBuffer) return true; if (typeof obj !== 'object' || obj === null) return false; // Use stack-based iteration instead of recursion for deep objects const stack = [obj]; while (stack.length > 0) { const current = stack.pop(); for (const value of Object.values(current)) { if (value instanceof Uint8Array || value instanceof ArrayBuffer) return true; if (typeof value === 'object' && value !== null) { stack.push(value); } } } return false; } /** * Fast check using pre-computed binary actions */ function isBinaryAction(action: string, payload: any): boolean { if (BINARY_ACTIONS.has(action)) return true; return containsBinary(payload); } /** * Extract binary field and metadata from payload */ function extractBinaryFields(payload: any): { binaryData: Uint8Array; metadata: Record } { const metadata: Record = {}; let binaryData: Uint8Array = new Uint8Array(0); for (const [key, value] of Object.entries(payload)) { if (value instanceof Uint8Array) { binaryData = value; } else if (value instanceof ArrayBuffer) { binaryData = new Uint8Array(value); } else { metadata[key] = value; } } return { binaryData, metadata }; } /** * Encode a binary message with action and metadata * * Binary Message Format: * ┌─────────────────┬────────────────┬─────────────────┬──────────────┬─────────────┐ * │ Action Length │ Action String │ Metadata Length │ Metadata JSON│ Binary Data │ * │ (1 byte) │ (N bytes) │ (4 bytes) │ (M bytes) │ (rest) │ * └─────────────────┴────────────────┴─────────────────┴──────────────┴─────────────┘ */ function encodeBinaryMessage(action: string, payload: any): ArrayBuffer { const { binaryData, metadata } = extractBinaryFields(payload); const actionBytes = textEncoder.encode(action); const metaBytes = textEncoder.encode(JSON.stringify(metadata)); // Calculate total length const totalLength = 1 + actionBytes.length + 4 + metaBytes.length + binaryData.length; const buffer = new ArrayBuffer(totalLength); const view = new DataView(buffer); const uint8 = new Uint8Array(buffer); let offset = 0; // Action length (1 byte, max 255 characters) view.setUint8(offset, actionBytes.length); offset += 1; // Action string uint8.set(actionBytes, offset); offset += actionBytes.length; // Metadata length (4 bytes, big-endian) view.setUint32(offset, metaBytes.length); offset += 4; // Metadata JSON uint8.set(metaBytes, offset); offset += metaBytes.length; // Binary data (rest of buffer) uint8.set(binaryData, offset); return buffer; } /** * Decode a binary message back to action and payload */ function decodeBinaryMessage(buffer: ArrayBuffer): { action: string; payload: any } { const view = new DataView(buffer); const uint8 = new Uint8Array(buffer); let offset = 0; // Read action length (1 byte) const actionLength = view.getUint8(offset); offset += 1; // Read action string const actionBytes = uint8.slice(offset, offset + actionLength); const action = textDecoder.decode(actionBytes); offset += actionLength; // Read metadata length (4 bytes) const metaLength = view.getUint32(offset); offset += 4; // Read metadata JSON const metaBytes = uint8.slice(offset, offset + metaLength); const metadata = JSON.parse(textDecoder.decode(metaBytes)); offset += metaLength; // Read binary data (rest of buffer) const binaryData = uint8.slice(offset); // Reconstruct payload with binary data const payload = { ...metadata, data: binaryData // Binary field always named 'data' }; return { action, payload }; } // ============================================================================ // WebSocket Router // ============================================================================ /** * WebSocket Router with Builder Pattern * * Includes built-in context management for user/project tracking. */ export class WSRouter< TClient extends Record = {}, TServer extends Record = {} > { private routes = new Map(); private httpRoutes = new Map(); private eventSchemas = new Map(); /** Optional auth middleware — called before every route handler */ private authMiddleware: ((conn: WSConnection, action: string) => Promise<{ allowed: boolean; error?: string }>) | null = null; /** * Optional rate limiter. `isRequest` tells it whether a caller is blocked * waiting on a reply, so throttling can shed fire-and-forget floods without * killing the low-volume calls panels depend on. */ private rateLimiter: ((conn: WSConnection, action: string, isRequest: boolean) => boolean) | null = null; constructor() { this.registerContextHandler(); } setAuthMiddleware(fn: (conn: WSConnection, action: string) => Promise<{ allowed: boolean; error?: string }>): void { this.authMiddleware = fn; } setRateLimiter(fn: (conn: WSConnection, action: string, isRequest: boolean) => boolean): void { this.rateLimiter = fn; } /** * Register built-in ws:set-context handler * Allows frontend to sync user/project context */ private registerContextHandler(): void { this.httpRoutes.set('ws:set-context', { action: 'ws:set-context', dataSchema: t.Object({ projectId: t.Optional(t.Union([t.String(), t.Null()])), worktreeId: t.Optional(t.Union([t.String(), t.Null()])) }), responseSchema: t.Object({ userId: t.Union([t.String(), t.Null()]), projectId: t.Union([t.String(), t.Null()]), worktreeId: t.Union([t.String(), t.Null()]) }), handler: async ({ conn, data }) => { // Import ws server to update context const { ws: wsServer } = await import('$backend/utils/ws'); const { projectQueries } = await import('$backend/database/queries'); // userId is set exclusively by auth handlers (auth:login, auth:setup, auth:accept-invite) // ws:set-context only handles projectId if (data.projectId !== undefined) { if (data.projectId !== null) { const state = wsServer.getConnectionState(conn); const userId = state?.userId; if (!userId) { throw new Error('Authentication required'); } const hasProjectAccess = projectQueries.userHasProject(userId, data.projectId); if (!hasProjectAccess) { throw new Error('Project not found'); } } wsServer.setProject(conn, data.projectId); } // Which tree this connection is viewing. Validated against the // project so a client cannot point itself at another one's worktree. if (data.worktreeId !== undefined) { let worktreeId = data.worktreeId; if (worktreeId !== null) { const { worktreeQueries } = await import('$backend/database/queries'); const activeProjectId = wsServer.getConnectionState(conn)?.projectId ?? null; const worktree = worktreeQueries.getById(worktreeId); if (!worktree || worktree.project_id !== activeProjectId) worktreeId = null; } wsServer.setWorktree(conn, worktreeId); } // Read back from connectionState (single source of truth) const state = wsServer.getConnectionState(conn); return { userId: state?.userId ?? null, projectId: state?.projectId ?? null, worktreeId: state?.worktreeId ?? null }; } }); } /** * Register a WebSocket route handler * * @param action - Action name (e.g., 'terminal:init', 'chat:send') * @param config - Route configuration with data schema * @param handler - Handler callback receiving { conn, data } */ on( action: TAction, config: RouteConfig, handler: RouteHandler ): WSRouter }, TServer> { // Validate action format if (!action || typeof action !== 'string') { throw new Error(`Invalid action name: ${action}`); } // Check for duplicate routes if (this.routes.has(action)) { throw new Error(`Route already exists: ${action}`); } // Store route definition this.routes.set(action, { action, dataSchema: config.data, handler: handler as any }); return this as any; } /** * Register an HTTP-like route handler for request-response pattern * * This method provides a simplified API for request-response interactions, * automatically wrapping responses in { success, data?, error? } format. * * The handler should: * - Return data directly (will be wrapped as { success: true, data }) * - Throw errors (will be wrapped as { success: false, error: message }) * * Response schema should ONLY define the data structure, not the wrapper. */ http< TAction extends string, TData extends TSchema = never, TResponse extends TSchema = any >( action: TAction, config: HTTPRouteConfig, handler: HTTPHandler ): WSRouter< TClient & { [K in TAction]: { data: TData extends TSchema ? Static : undefined; }; }, TServer & { [K in `${TAction}:response`]: { success: boolean; data?: Static; error?: string; }; } > { // Validate action format if (!action || typeof action !== 'string') { throw new Error(`Invalid action name: ${action}`); } // Check for duplicate routes if (this.httpRoutes.has(action)) { throw new Error(`HTTP route already exists: ${action}`); } // Store HTTP route definition this.httpRoutes.set(action, { action, dataSchema: config.data, responseSchema: config.response, handler: handler as any }); return this as any; } /** * Register SERVER → CLIENT event schema (independent) * * Declares event schemas that server can emit to clients. * Events are independent from actions and can be emitted from anywhere using ws.emit() */ emit( event: TEvent, schema: TEventSchema ): WSRouter< TClient, TServer & { [K in TEvent]: Static } > { // Validate event format if (!event || typeof event !== 'string') { throw new Error(`Invalid event name: ${event}`); } // Check for duplicate event schemas if (this.eventSchemas.has(event)) { throw new Error(`Event schema already exists: ${event}`); } // Store event schema for type inference this.eventSchemas.set(event, schema); return this as any; } /** * Merge another router into this one */ merge, TS extends Record>( router: WSRouter ): WSRouter { // Copy all routes from the other router for (const [action, route] of router.routes.entries()) { if (this.routes.has(action)) { throw new Error(`Route conflict during merge: ${action}`); } this.routes.set(action, route); } // Copy all HTTP routes from the other router (except built-in) for (const [action, route] of router.httpRoutes.entries()) { if (action === 'ws:set-context') continue; // Skip built-in if (this.httpRoutes.has(action)) { throw new Error(`HTTP route conflict during merge: ${action}`); } this.httpRoutes.set(action, route); } // Copy all event schemas from the other router for (const [event, schema] of router.eventSchemas.entries()) { if (this.eventSchemas.has(event)) { throw new Error(`Event schema conflict during merge: ${event}`); } this.eventSchemas.set(event, schema); } return this as any; } /** * Handle HTTP-like request-response message * @internal */ private async handleHTTPMessage(conn: any, action: string, payload: any, route: HTTPRoute) { const responseAction = `${action}:response`; // Extract requestId from payload to match request with response const requestId = payload?.requestId; try { // Extract data from payload const data = payload?.data || {}; // Validate request data schema (if exists) let validatedData = {}; if (route.dataSchema) { try { // Check for binary BEFORE validation const hasBinary = containsBinary(data); validatedData = Value.Decode(route.dataSchema, data); // Restore binary data if it was converted if (hasBinary && data.data instanceof Uint8Array) { (validatedData as any).data = data.data; } } catch (err) { throw new Error( `Request validation failed: ${err instanceof Error ? err.message : 'Unknown error'}` ); } } // Execute handler (can throw) const result = await route.handler({ conn: conn, data: validatedData }); // The response is NOT re-validated. // // `route.responseSchema` stays declared because it is what gives // `ws.http()` its return type on the client — but checking the value // against it at runtime bought nothing and cost the whole server. // A response is produced by our own handler, not by a peer: it is not // untrusted input, so the only thing validation could catch is a bug // we would rather surface as a wrong field than as a route that fails // outright for the user. // // The cost was not theoretical. `Value.Decode` walks the entire value // synchronously on the main thread, and the worst offender is exactly // the biggest payload: `files:list-tree` declares a RECURSIVE UNION // (backend/ws/files/read.ts), so TypeBox tries both branches at every // node of the tree. On a large project that locked the event loop for // hundreds of milliseconds per load — during which every other panel's // request, every other project's, and the preview watchdog all waited. // That is what made one slow Files load feel like the whole app hanging. // // Measured against what it protects: `Value.Decode` returns the very // same object reference and strips nothing (there is not one // `t.Transform` in the codebase), so removing it cannot change a // successful response by even one byte. const validatedResponse = result; // Check if response contains binary data if (isBinaryAction(responseAction, validatedResponse)) { // For binary responses, wrap in success envelope with requestId const wrappedResponse = { success: true, data: validatedResponse, requestId }; const binaryMessage = encodeBinaryMessage(responseAction, wrappedResponse); conn.send(Buffer.from(binaryMessage)); debug.log('websocket', 'HTTP success (binary):', action); } else { // For JSON responses, wrap in success envelope with requestId const wrappedResponse = { success: true, data: validatedResponse, requestId }; conn.send(JSON.stringify({ action: responseAction, payload: wrappedResponse })); debug.log('websocket', 'HTTP success (JSON):', action); } } catch (err) { // Catch ANY error and send wrapped in { success: false, error, requestId } const errorMessage = err instanceof Error ? err.message : String(err); const errorResponse = { success: false, error: errorMessage, requestId }; conn.send(JSON.stringify({ action: responseAction, payload: errorResponse })); debug.error('websocket', `HTTP error [${action}]:`, errorMessage); } } /** * Handle incoming WebSocket message (JSON or Binary) * @internal */ private async handleMessage(conn: any, message: any) { try { let action: string; let payload: any; // Check if message is binary (ArrayBuffer or Buffer) if (message instanceof ArrayBuffer) { // Decode binary message const decoded = decodeBinaryMessage(message); action = decoded.action; payload = decoded.payload; } else if (Buffer.isBuffer(message)) { // Node.js Buffer - convert to ArrayBuffer const arrayBuffer = new ArrayBuffer(message.byteLength); new Uint8Array(arrayBuffer).set(message); const decoded = decodeBinaryMessage(arrayBuffer); action = decoded.action; payload = decoded.payload; } else { // Parse JSON message const parsed = typeof message === 'string' ? JSON.parse(message) : message; action = parsed.action; payload = parsed.payload; } if (!action || typeof action !== 'string') { debug.error('websocket', 'Invalid message format'); return; } // ═══ AUTH GATE ═══ if (this.authMiddleware) { const authResult = await this.authMiddleware(conn, action); if (!authResult.allowed) { try { // If this is an HTTP route, send error as HTTP-style response // so ws.http() on the client receives it instead of timing out if (this.httpRoutes.has(action)) { const requestId = payload?.requestId; conn.send(JSON.stringify({ action: `${action}:response`, payload: { success: false, error: authResult.error, requestId } })); } else { conn.send(JSON.stringify({ action: 'auth:error', payload: { error: authResult.error, blockedAction: action } })); } } catch { // Connection may be closed } debug.warn('websocket', `Auth blocked: ${action} — ${authResult.error}`); return; } } // ═══ END AUTH GATE ═══ // ═══ RATE LIMIT GATE ═══ // A request-response route has a caller blocked on its reply; an event // does not. The limiter needs that distinction to shed the right half // of a flood, and the answer below needs it to know whether anyone is // listening for one. const isRequest = this.httpRoutes.has(action); if (this.rateLimiter && !this.rateLimiter(conn, action, isRequest)) { // A dropped request must still be ANSWERED. Returning silently here // left `ws.http()` waiting on a reply that was never coming, so the // caller hung for its full 30s timeout and then tore the whole // socket down to "heal" it — which re-ran every panel's reconnect // handler at once, producing the very burst that tripped the limiter // again. Telling the client no is what breaks that loop: the request // fails immediately, the caller retries on its own terms, and the // connection survives. // // The auth gate directly above has always done this; the two gates // only differed because this one was written to `return` on a path // where nothing was waiting for an answer. Fire-and-forget events // have no requestId and still just drop. if (isRequest) { try { conn.send(JSON.stringify({ action: `${action}:response`, payload: { success: false, error: 'Rate limit exceeded — request dropped', requestId: payload?.requestId } })); } catch { // Connection may already be closing. } } return; } // ═══ END RATE LIMIT GATE ═══ // Check if this is an HTTP route const httpRoute = this.httpRoutes.get(action); if (httpRoute) { await this.handleHTTPMessage(conn, action, payload, httpRoute); return; } // Find regular WebSocket route const route = this.routes.get(action); if (!route) { debug.warn('websocket', `Unknown action: ${action}`); return; } // Validate payload against schema try { // Use TypeBox Value.Decode (bundled with Elysia) const validatedData = Value.Decode(route.dataSchema, payload); // Execute handler await route.handler({ conn: conn, data: validatedData }); } catch (err) { debug.error('websocket', `Data validation failed for ${action}:`, err); } } catch (err) { debug.error('websocket', 'Message handling error:', err); } } /** * Convert router to Elysia plugin */ asPlugin(path: string = '/ws') { return (app: Elysia) => { return app.ws(path, { // Allow large binary frames (file uploads, zip extracts). // Bun/uWS default is 16MB which trips EPIPE on the dev proxy // long before the configurable per-file limit kicks in. maxPayloadLength: 1024 * 1024 * 1024, // 1 GiB // Disable inactivity disconnects during long binary transfers. idleTimeout: 960, // seconds (max allowed by uWS) message: (conn, message) => { this.handleMessage(conn, message); }, open: async (conn) => { debug.log('websocket', 'Client connected'); // Register connection with ws singleton try { const { ws: wsServer } = await import('$backend/utils/ws'); wsServer.register(conn); } catch (err) { debug.error('websocket', 'Failed to register connection:', err); } }, close: async (conn) => { debug.log('websocket', 'Client disconnected'); // Unregister connection from ws singleton // All registered cleanups are called automatically by unregister() try { const { ws: wsServer } = await import('$backend/utils/ws'); wsServer.unregister(conn); } catch (err) { debug.error('websocket', 'Failed to unregister connection:', err); } } }); }; } /** * Phantom getter for type inference */ get $api(): { client: TClient; server: TServer } { throw new Error('$api is a phantom getter for type inference only'); } } /** * Create a new WebSocket router */ export function createRouter(): WSRouter { return new WSRouter(); } // Export binary utilities for advanced use cases export { encodeBinaryMessage, decodeBinaryMessage, containsBinary, isBinaryAction };