Files
aurorabot/api/src/server.ts
syntaxbullet 56db5bc998 refactor(games): rework room lifecycle events and remove chess plugin
Consolidate room leave/delete event handling into RoomManager emitter,
remove redundant PLAYER_LEFT publishes from GameServer, and delete the
chess game plugin (board, types, tests) in favor of the new plugin
architecture. Add per-module CLAUDE.md files for leveling, guild-settings,
feature-flags, db, api, and panel to improve agent navigability.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 15:19:51 +02:00

249 lines
9.0 KiB
TypeScript

/**
* @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<string, string> = {
".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<typeof serve>;
stop: () => Promise<void>;
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<Response | null> {
// 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<WebServerInstance> {
const { port = 3000, hostname = "localhost" } = config;
let activeConnections = 0;
let statsBroadcastInterval: Timer | undefined;
const server = serve<WsConnectionData>({
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<string>(),
},
});
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<WsConnectionData>) {
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<WsConnectionData>, 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<WsConnectionData>) {
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<WebServerInstance> {
return createWebServer(config);
}