/** * Web server factory module. * Exports a function to create and start the web server. * This allows the server to be started in-process from the main application. */ import { serve, spawn, type Subprocess } from "bun"; import { join, resolve, dirname } from "path"; export interface WebServerConfig { port?: number; hostname?: string; } export interface WebServerInstance { server: ReturnType; stop: () => Promise; url: string; } /** * Creates and starts the web server. * * Automatically handles building the frontend: * - In development: Spawns 'bun run build.ts --watch' * - In production: Assumes 'dist' is already built (or builds once) */ export async function createWebServer(config: WebServerConfig = {}): Promise { const { port = 3000, hostname = "localhost" } = config; // Resolve directories // server.ts is in web/src/, so we go up one level to get web/ const currentDir = dirname(new URL(import.meta.url).pathname); const webRoot = resolve(currentDir, ".."); const distDir = join(webRoot, "dist"); // Manage build process in development let buildProcess: Subprocess | undefined; const isDev = process.env.NODE_ENV !== "production"; if (isDev) { console.log("🛠️ Starting Web Bundler in Watch Mode..."); try { buildProcess = spawn(["bun", "run", "build.ts", "--watch"], { cwd: webRoot, stdout: "inherit", stderr: "inherit", }); } catch (error) { console.error("Failed to start build process:", error); } } const server = serve({ port, hostname, async fetch(req) { const url = new URL(req.url); // API routes if (url.pathname === "/api/health") { return Response.json({ status: "ok", timestamp: Date.now() }); } if (url.pathname === "/api/stats") { try { // Import services (dynamic to avoid circular deps) const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service"); const { getClientStats } = await import("../../bot/lib/clientStats"); // Fetch all data in parallel const [clientStats, activeUsers, totalUsers, economyStats, recentEvents] = await Promise.all([ Promise.resolve(getClientStats()), dashboardService.getActiveUserCount(), dashboardService.getTotalUserCount(), dashboardService.getEconomyStats(), dashboardService.getRecentEvents(10), ]); const stats = { guilds: { count: clientStats.guilds, }, users: { active: activeUsers, total: totalUsers, }, commands: { total: clientStats.commandsRegistered, }, ping: { avg: clientStats.ping, }, economy: { totalWealth: economyStats.totalWealth.toString(), avgLevel: economyStats.avgLevel, topStreak: economyStats.topStreak, }, recentEvents: recentEvents.map(event => ({ ...event, timestamp: event.timestamp.toISOString(), })), uptime: clientStats.uptime, lastCommandTimestamp: clientStats.lastCommandTimestamp, }; return Response.json(stats); } catch (error) { console.error("Error fetching dashboard stats:", error); return Response.json( { error: "Failed to fetch dashboard statistics" }, { status: 500 } ); } } // Static File Serving let pathName = url.pathname; if (pathName === "/") pathName = "/index.html"; // Construct safe file path // Remove leading slash to join correctly const safePath = join(distDir, pathName.replace(/^\//, "")); // Security check: ensure path is within distDir if (!safePath.startsWith(distDir)) { return new Response("Forbidden", { status: 403 }); } const fileRef = Bun.file(safePath); if (await fileRef.exists()) { return new Response(fileRef); } // SPA Fallback: Serve index.html for unknown non-file routes // If the path looks like a file (has extension), return 404 // Otherwise serve index.html const parts = pathName.split("/"); const lastPart = parts[parts.length - 1]; if (lastPart?.includes(".")) { return new Response("Not Found", { status: 404 }); } return new Response(Bun.file(join(distDir, "index.html"))); }, development: isDev, }); const url = `http://${hostname}:${port}`; return { server, url, stop: async () => { if (buildProcess) { buildProcess.kill(); } server.stop(true); }, }; } /** * Starts the web server from the main application root. * Kept for backward compatibility, but assumes webProjectPath is handled internally or ignored * in favor of relative path resolution from this file. */ export async function startWebServerFromRoot( webProjectPath: string, config: WebServerConfig = {} ): Promise { // Current implementation doesn't need CWD switching thanks to absolute path resolution return createWebServer(config); }