/** * @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 type { ServerWebSocket } from "bun"; import { logger } from "@shared/lib/logger"; import { handleRequest } from "./routes"; import { getFullDashboardStats } from "./routes/stats.helper"; import { join } from "path"; import { gameServer } from "./games/GameServer"; import type { WsConnectionData } from "./games/GameServer"; import { getSession } from "./routes/auth.routes"; import { GameWsClientSchema } from "./games/types"; const WS_CONFIG = { MAX_CONNECTIONS: 200, MAX_PAYLOAD_BYTES: 16384, IDLE_TIMEOUT_SECONDS: 60, STATS_BROADCAST_INTERVAL_MS: 5000, } as const; 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", }; export interface WebServerConfig { port?: number; hostname?: string; } export interface WebServerInstance { server: ReturnType; stop: () => Promise; url: string; } /** * 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; let activeConnections = 0; let statsBroadcastInterval: Timer | undefined; const server = serve({ port, hostname, async fetch(req, server) { const url = new URL(req.url); if (url.pathname === "/ws") { if (activeConnections >= WS_CONFIG.MAX_CONNECTIONS) { logger.warn("web", `Connection rejected: limit reached (${activeConnections}/${WS_CONFIG.MAX_CONNECTIONS})`); return new Response("Connection limit reached", { status: 429 }); } const session = getSession(req); if (!session) { return new Response("Unauthorized", { status: 401 }); } const success = server.upgrade(req, { data: { session: { discordId: session.discordId, username: session.username, role: session.role, }, rooms: new Set(), }, }); if (success) return undefined; return new Response("WebSocket upgrade failed", { status: 400 }); } const response = await handleRequest(req, url); if (response) return response; const panelDistDir = join(import.meta.dir, "../../panel/dist"); const staticResponse = await servePanelStatic(url.pathname, panelDistDir); if (staticResponse) return staticResponse; return new Response("Not Found", { status: 404 }); }, websocket: { open(ws: ServerWebSocket) { activeConnections++; ws.subscribe("dashboard"); ws.subscribe("lobby"); logger.debug("web", `Client connected: ${ws.data.session.discordId}. Total: ${activeConnections}`); getFullDashboardStats().then(stats => { ws.send(JSON.stringify({ type: "STATS_UPDATE", data: stats })); }); gameServer.handleOpen(ws); 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); } }, WS_CONFIG.STATS_BROADCAST_INTERVAL_MS); } }, async message(ws: ServerWebSocket, message) { try { const messageStr = message.toString(); if (messageStr.length > WS_CONFIG.MAX_PAYLOAD_BYTES) { logger.error("web", "Payload exceeded maximum limit"); return; } const rawData = JSON.parse(messageStr); // Handle dashboard-level messages (PING, etc.) if (rawData && typeof rawData === "object" && rawData.type === "PING") { ws.send(JSON.stringify({ type: "PONG" })); return; } // Route game messages — try to parse as a game client message const gameCheck = GameWsClientSchema.safeParse(rawData); if (gameCheck.success) { gameServer.handleMessage(ws, rawData); return; } // Fall back to full WsMessageSchema for dashboard messages that aren't PING 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); } // Nothing else to do for PONG/STATS_UPDATE/NEW_EVENT from clients } catch (e) { logger.error("web", "Failed to handle message", e); } }, close(ws: ServerWebSocket) { activeConnections--; ws.unsubscribe("dashboard"); ws.unsubscribe("lobby"); logger.debug("web", `Client disconnected: ${ws.data.session.discordId}. Total remaining: ${activeConnections}`); gameServer.handleClose(ws); if (activeConnections === 0 && statsBroadcastInterval) { clearInterval(statsBroadcastInterval); statsBroadcastInterval = undefined; } }, maxPayloadLength: WS_CONFIG.MAX_PAYLOAD_BYTES, idleTimeout: WS_CONFIG.IDLE_TIMEOUT_SECONDS, }, }); // Wire gameServer to Bun server for pub/sub publishing gameServer.setServer(server); 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); }