/** * @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, file } from "bun"; import { logger } from "@shared/lib/logger"; import { handleRequest } from "./routes"; import { getFullDashboardStats } from "./routes/stats.helper"; import { join } from "path"; 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(); */ const MIME_TYPES: Record = { ".html": "text/html", ".js": "application/javascript", ".css": "text/css", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2", }; /** * Serve static files from the panel dist directory. * Falls back to index.html for SPA routing. */ async function servePanelStatic(pathname: string, distDir: string): Promise { // Don't serve panel for API/auth/ws/assets routes if (pathname.startsWith("/api/") || pathname.startsWith("/auth/") || pathname === "/ws" || pathname.startsWith("/assets/")) { return null; } // Try to serve the exact file const filePath = join(distDir, pathname); const bunFile = file(filePath); if (await bunFile.exists()) { const ext = pathname.substring(pathname.lastIndexOf(".")); const contentType = MIME_TYPES[ext] ?? "application/octet-stream"; return new Response(bunFile, { headers: { "Content-Type": contentType, "Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable", }, }); } // SPA fallback: serve index.html for all non-file routes const indexFile = file(join(distDir, "index.html")); if (await indexFile.exists()) { return new Response(indexFile, { headers: { "Content-Type": "text/html", "Cache-Control": "no-cache" }, }); } return null; } 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; // Serve panel static files (production) const panelDistDir = join(import.meta.dir, "../../panel/dist"); const staticResponse = await servePanelStatic(url.pathname, panelDistDir); if (staticResponse) return staticResponse; // 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); }