/** * @fileoverview API server factory module. * Exports a function to create and start the API server. * This allows the server to be started in-process from the main application. * * Routes are organized into modular files in the ./routes directory. * Each route module handles its own validation, business logic, and responses. */ import { serve } from "bun"; import { logger } from "@shared/lib/logger"; import { handleRequest } from "./routes"; import { getFullDashboardStats } from "./routes/stats.helper"; export interface WebServerConfig { port?: number; hostname?: string; } export interface WebServerInstance { server: ReturnType; stop: () => Promise; url: string; } /** * Creates and starts the API server. * * @param config - Server configuration options * @param config.port - Port to listen on (default: 3000) * @param config.hostname - Hostname to bind to (default: "localhost") * @returns Promise resolving to server instance with stop() method * * @example * const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" }); * console.log(`Server running at ${server.url}`); * * // To stop the server: * await server.stop(); */ export async function createWebServer(config: WebServerConfig = {}): Promise { const { port = 3000, hostname = "localhost" } = config; // Configuration constants const MAX_CONNECTIONS = 10; const MAX_PAYLOAD_BYTES = 16384; // 16KB const IDLE_TIMEOUT_SECONDS = 60; // Interval for broadcasting stats to all connected WS clients let statsBroadcastInterval: Timer | undefined; const server = serve({ port, hostname, async fetch(req, server) { const url = new URL(req.url); // WebSocket upgrade handling if (url.pathname === "/ws") { const currentConnections = server.pendingWebSockets; if (currentConnections >= MAX_CONNECTIONS) { logger.warn("web", `Connection rejected: limit reached (${currentConnections}/${MAX_CONNECTIONS})`); return new Response("Connection limit reached", { status: 429 }); } const success = server.upgrade(req); if (success) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); } // Delegate to modular route handlers const response = await handleRequest(req, url); if (response) return response; // No matching route found return new Response("Not Found", { status: 404 }); }, websocket: { /** * Called when a WebSocket client connects. * Subscribes the client to the dashboard channel and sends initial stats. */ open(ws) { ws.subscribe("dashboard"); logger.debug("web", `Client connected. Total: ${server.pendingWebSockets}`); // Send initial stats getFullDashboardStats().then(stats => { ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats })); }); // Start broadcast interval if this is the first client if (!statsBroadcastInterval) { statsBroadcastInterval = setInterval(async () => { try { const stats = await getFullDashboardStats(); server.publish("dashboard", JSON.stringify({ type: "STATS_UPDATE", data: stats })); } catch (error) { logger.error("web", "Error in stats broadcast", error); } }, 5000); } }, /** * Called when a WebSocket message is received. * Handles PING/PONG heartbeat messages. */ async message(ws, message) { try { const messageStr = message.toString(); // Defense-in-depth: redundant length check before parsing if (messageStr.length > MAX_PAYLOAD_BYTES) { logger.error("web", "Payload exceeded maximum limit"); return; } const rawData = JSON.parse(messageStr); const { WsMessageSchema } = await import("@shared/modules/dashboard/dashboard.types"); const parsed = WsMessageSchema.safeParse(rawData); if (!parsed.success) { logger.error("web", "Invalid message format", parsed.error.issues); return; } if (parsed.data.type === "PING") { ws.send(JSON.stringify({ type: "PONG" })); } } catch (e) { logger.error("web", "Failed to handle message", e); } }, /** * Called when a WebSocket client disconnects. * Stops the broadcast interval if no clients remain. */ close(ws) { ws.unsubscribe("dashboard"); logger.debug("web", `Client disconnected. Total remaining: ${server.pendingWebSockets}`); // Stop broadcast interval if no clients left if (server.pendingWebSockets === 0 && statsBroadcastInterval) { clearInterval(statsBroadcastInterval); statsBroadcastInterval = undefined; } }, maxPayloadLength: MAX_PAYLOAD_BYTES, idleTimeout: IDLE_TIMEOUT_SECONDS, }, }); // Listen for real-time events from the system bus const { systemEvents, EVENTS } = await import("@shared/lib/events"); systemEvents.on(EVENTS.DASHBOARD.NEW_EVENT, (event) => { server.publish("dashboard", JSON.stringify({ type: "NEW_EVENT", data: event })); }); const url = `http://${hostname}:${port}`; return { server, url, stop: async () => { if (statsBroadcastInterval) { clearInterval(statsBroadcastInterval); } server.stop(true); }, }; } /** * Starts the web server from the main application root. * Kept for backward compatibility. * * @param webProjectPath - Deprecated, no longer used * @param config - Server configuration options * @returns Promise resolving to server instance */ export async function startWebServerFromRoot( webProjectPath: string, config: WebServerConfig = {} ): Promise { return createWebServer(config); }