380 lines
16 KiB
TypeScript
380 lines
16 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;
|
|
|
|
// Cache for activity stats (heavy aggregation)
|
|
let activityPromise: Promise<import("@shared/modules/dashboard/dashboard.types").ActivityData[]> | null = null;
|
|
let lastActivityFetch: number = 0;
|
|
const ACTIVITY_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
|
|
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 }
|
|
);
|
|
}
|
|
}
|
|
|
|
if (url.pathname === "/api/stats/activity") {
|
|
try {
|
|
// Security Check: Token-based authentication
|
|
const { env } = await import("@shared/lib/env");
|
|
const authHeader = req.headers.get("Authorization");
|
|
if (authHeader !== `Bearer ${env.ADMIN_TOKEN}`) {
|
|
console.warn(`⚠️ [API] Unauthorized activity analytics access attempt from ${req.headers.get("x-forwarded-for") || "unknown"}`);
|
|
return new Response("Unauthorized", { status: 401 });
|
|
}
|
|
|
|
const now = Date.now();
|
|
|
|
// If we have a valid cache, return it
|
|
if (activityPromise && (now - lastActivityFetch < ACTIVITY_CACHE_TTL)) {
|
|
const data = await activityPromise;
|
|
return Response.json(data);
|
|
}
|
|
|
|
// Otherwise, trigger a new fetch (deduplicated by the promise)
|
|
if (!activityPromise || (now - lastActivityFetch >= ACTIVITY_CACHE_TTL)) {
|
|
activityPromise = (async () => {
|
|
const { dashboardService } = await import("@shared/modules/dashboard/dashboard.service");
|
|
return await dashboardService.getActivityAggregation();
|
|
})();
|
|
lastActivityFetch = now;
|
|
}
|
|
|
|
const activity = await activityPromise;
|
|
return Response.json(activity);
|
|
} catch (error) {
|
|
console.error("Error fetching activity stats:", error);
|
|
return Response.json(
|
|
{ error: "Failed to fetch activity statistics" },
|
|
{ status: 500 }
|
|
);
|
|
}
|
|
}
|
|
|
|
// Administrative Actions
|
|
if (url.pathname.startsWith("/api/actions/") && req.method === "POST") {
|
|
try {
|
|
// Security Check: Token-based authentication
|
|
const { env } = await import("@shared/lib/env");
|
|
const authHeader = req.headers.get("Authorization");
|
|
if (authHeader !== `Bearer ${env.ADMIN_TOKEN}`) {
|
|
console.warn(`⚠️ [API] Unauthorized administrative action attempt from ${req.headers.get("x-forwarded-for") || "unknown"}`);
|
|
return new Response("Unauthorized", { status: 401 });
|
|
}
|
|
|
|
const { actionService } = await import("@shared/modules/admin/action.service");
|
|
const { MaintenanceModeSchema } = await import("@shared/modules/dashboard/dashboard.types");
|
|
|
|
if (url.pathname === "/api/actions/reload-commands") {
|
|
const result = await actionService.reloadCommands();
|
|
return Response.json(result);
|
|
}
|
|
|
|
if (url.pathname === "/api/actions/clear-cache") {
|
|
const result = await actionService.clearCache();
|
|
return Response.json(result);
|
|
}
|
|
|
|
if (url.pathname === "/api/actions/maintenance-mode") {
|
|
const rawBody = await req.json();
|
|
const parsed = MaintenanceModeSchema.safeParse(rawBody);
|
|
|
|
if (!parsed.success) {
|
|
return Response.json({ error: "Invalid payload", issues: parsed.error.issues }, { status: 400 });
|
|
}
|
|
|
|
const result = await actionService.toggleMaintenanceMode(parsed.data.enabled, parsed.data.reason);
|
|
return Response.json(result);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error executing administrative action:", error);
|
|
return Response.json(
|
|
{ error: "Failed to execute administrative action" },
|
|
{ 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()) {
|
|
// If serving index.html, inject env vars for frontend
|
|
if (pathName === "/index.html") {
|
|
let html = await fileRef.text();
|
|
const { env } = await import("@shared/lib/env");
|
|
const envScript = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${env.ADMIN_TOKEN}" };</script>`;
|
|
html = html.replace("</head>", `${envScript}</head>`);
|
|
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
}
|
|
return new Response(fileRef);
|
|
}
|
|
|
|
// SPA Fallback: Serve index.html for unknown non-file routes
|
|
const parts = pathName.split("/");
|
|
const lastPart = parts[parts.length - 1];
|
|
|
|
// If it's a direct request for a missing file (has dot), return 404
|
|
// EXCEPT for index.html which is our fallback entry point
|
|
if (lastPart?.includes(".") && lastPart !== "index.html") {
|
|
return new Response("Not Found", { status: 404 });
|
|
}
|
|
|
|
const indexFile = Bun.file(join(distDir, "index.html"));
|
|
if (!(await indexFile.exists())) {
|
|
if (isDev) {
|
|
return new Response("<html><body><h1>🛠️ Dashboard is building...</h1><p>Please refresh in a few seconds. The bundler is currently generating the static assets.</p><script>setTimeout(() => location.reload(), 2000);</script></body></html>", {
|
|
status: 503,
|
|
headers: { "Content-Type": "text/html" }
|
|
});
|
|
}
|
|
return new Response("Dashboard Not Found", { status: 404 });
|
|
}
|
|
|
|
let indexHtml = await indexFile.text();
|
|
const { env: sharedEnv } = await import("@shared/lib/env");
|
|
const script = `<script>window.AURORA_ENV = { ADMIN_TOKEN: "${sharedEnv.ADMIN_TOKEN}" };</script>`;
|
|
indexHtml = indexHtml.replace("</head>", `${script}</head>`);
|
|
return new Response(indexHtml, { headers: { "Content-Type": "text/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,
|
|
maintenanceMode: (await import("../../bot/lib/BotClient")).AuroraClient.maintenanceMode,
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
}
|