Files
aurorabot/web/src/server.ts

268 lines
9.9 KiB
TypeScript

/**
* 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<typeof serve>;
stop: () => Promise<void>;
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<WebServerInstance> {
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);
}
}
// 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);
// Upgrade to WebSocket
if (url.pathname === "/ws") {
// Security Check: limit concurrent connections
const currentConnections = server.pendingWebSockets;
if (currentConnections >= MAX_CONNECTIONS) {
console.warn(`⚠️ [WS] 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 });
}
// API routes
if (url.pathname === "/api/health") {
return Response.json({ status: "ok", timestamp: Date.now() });
}
if (url.pathname === "/api/stats") {
try {
const stats = await getFullDashboardStats();
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")));
},
websocket: {
open(ws) {
ws.subscribe("dashboard");
console.log(`🔌 [WS] 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) {
console.error("Error in stats broadcast:", error);
}
}, 5000);
}
},
async message(ws, message) {
try {
const messageStr = message.toString();
// Defense-in-depth: redundant length check before parsing
if (messageStr.length > MAX_PAYLOAD_BYTES) {
console.error("❌ [WS] 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) {
console.error("❌ [WS] Invalid message format:", parsed.error.issues);
return;
}
if (parsed.data.type === "PING") {
ws.send(JSON.stringify({ type: "PONG" }));
}
} catch (e) {
console.error("❌ [WS] Failed to handle message:", e instanceof Error ? e.message : "Malformed JSON");
}
},
close(ws) {
ws.unsubscribe("dashboard");
console.log(`🔌 [WS] 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,
},
development: isDev,
});
/**
* Helper to fetch full dashboard stats object.
* Unified for both HTTP API and WebSocket broadcasts.
*/
async function getFullDashboardStats() {
// 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),
]);
return {
bot: clientStats.bot,
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 instanceof Date ? event.timestamp.toISOString() : event.timestamp,
})),
uptime: clientStats.uptime,
lastCommandTimestamp: clientStats.lastCommandTimestamp,
};
}
// 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 (buildProcess) {
buildProcess.kill();
}
if (statsBroadcastInterval) {
clearInterval(statsBroadcastInterval);
}
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<WebServerInstance> {
// Current implementation doesn't need CWD switching thanks to absolute path resolution
return createWebServer(config);
}