forked from syntaxbullet/aurorabot
refactor: rename web/ to api/ to better reflect its purpose
The web/ folder contains the REST API, WebSocket server, and OAuth routes — not a web frontend. Renaming to api/ clarifies this distinction since the actual web frontend lives in panel/. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
243
api/src/server.ts
Normal file
243
api/src/server.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* @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 { logger } from "@shared/lib/logger";
|
||||
import { handleRequest } from "./routes";
|
||||
import { getFullDashboardStats } from "./routes/stats.helper";
|
||||
import { join } 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 API server.
|
||||
*
|
||||
* @param config - Server configuration options
|
||||
* @param config.port - Port to listen on (default: 3000)
|
||||
* @param config.hostname - Hostname to bind to (default: "localhost")
|
||||
* @returns Promise resolving to server instance with stop() method
|
||||
*
|
||||
* @example
|
||||
* const server = await createWebServer({ port: 3000, hostname: "0.0.0.0" });
|
||||
* console.log(`Server running at ${server.url}`);
|
||||
*
|
||||
* // To stop the server:
|
||||
* await server.stop();
|
||||
*/
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// 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);
|
||||
|
||||
// WebSocket upgrade handling
|
||||
if (url.pathname === "/ws") {
|
||||
const currentConnections = server.pendingWebSockets;
|
||||
if (currentConnections >= MAX_CONNECTIONS) {
|
||||
logger.warn("web", `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 });
|
||||
}
|
||||
|
||||
// Delegate to modular route handlers
|
||||
const response = await handleRequest(req, url);
|
||||
if (response) return response;
|
||||
|
||||
// Serve panel static files (production)
|
||||
const panelDistDir = join(import.meta.dir, "../../panel/dist");
|
||||
const staticResponse = await servePanelStatic(url.pathname, panelDistDir);
|
||||
if (staticResponse) return staticResponse;
|
||||
|
||||
// No matching route found
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
|
||||
websocket: {
|
||||
/**
|
||||
* Called when a WebSocket client connects.
|
||||
* Subscribes the client to the dashboard channel and sends initial stats.
|
||||
*/
|
||||
open(ws) {
|
||||
ws.subscribe("dashboard");
|
||||
logger.debug("web", `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) {
|
||||
logger.error("web", "Error in stats broadcast", error);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when a WebSocket message is received.
|
||||
* Handles PING/PONG heartbeat messages.
|
||||
*/
|
||||
async message(ws, message) {
|
||||
try {
|
||||
const messageStr = message.toString();
|
||||
|
||||
// Defense-in-depth: redundant length check before parsing
|
||||
if (messageStr.length > MAX_PAYLOAD_BYTES) {
|
||||
logger.error("web", "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) {
|
||||
logger.error("web", "Invalid message format", parsed.error.issues);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.data.type === "PING") {
|
||||
ws.send(JSON.stringify({ type: "PONG" }));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error("web", "Failed to handle message", e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when a WebSocket client disconnects.
|
||||
* Stops the broadcast interval if no clients remain.
|
||||
*/
|
||||
close(ws) {
|
||||
ws.unsubscribe("dashboard");
|
||||
logger.debug("web", `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,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 (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);
|
||||
}
|
||||
Reference in New Issue
Block a user