From d98bf1a5fe4b3dad64f6185376394172c2df8b11 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Tue, 19 May 2026 14:52:52 +0700 Subject: [PATCH] refactor: extract http app setup Co-Authored-By: Claude Opus 4.7 --- src/http/app.ts | 144 +++++++++++++++++++++++++++++++++++++++++ src/http/health.ts | 39 ++++++++++++ src/webserver.ts | 155 ++++----------------------------------------- 3 files changed, 197 insertions(+), 141 deletions(-) create mode 100644 src/http/app.ts create mode 100644 src/http/health.ts diff --git a/src/http/app.ts b/src/http/app.ts new file mode 100644 index 0000000..4bcd2b2 --- /dev/null +++ b/src/http/app.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Client } from "discord.js-selfbot-v13"; +import express, { type NextFunction, type Request, type Response } from "express"; +import helmet from "helmet"; +import { AppError } from "../errors"; +import type { createChildLogger } from "../logger"; +import type { MediaController } from "../media/mediaController"; +import type { ModerationBroadcaster } from "../moderation/types"; +import { createAnalysisRoutes } from "../routes/analysisRoutes"; +import { createMediaRoutes } from "../routes/mediaRoutes"; +import { createMessageRoutes } from "../routes/messageRoutes"; +import { createRecordingsRoutes } from "../routes/recordingsRoutes"; +import { createSyncRoutes } from "../routes/syncRoutes"; +import { createUIStateRoutes } from "../routes/uiStateRoutes"; +import { createVoiceRoutes } from "../routes/voiceRoutes"; +import type { SharedUIStatePatch } from "../state/uiState"; +import type { VoiceController } from "../voiceController"; +import { createHealthRoutes } from "./health"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +type Logger = ReturnType; + +export interface CreateHttpAppOptions { + client: Client; + voiceController: VoiceController; + mediaController: MediaController; + broadcaster: ModerationBroadcaster; + adminPassword: string; + getSharedUIState: () => any; + patchSharedUIState: (patch: SharedUIStatePatch) => any; + activeUserCount: () => number; + wsClientCount: () => number; + logger: Logger; +} + +export function createHttpApp(options: CreateHttpAppOptions) { + const app = express(); + + app.use( + helmet({ + contentSecurityPolicy: false, + }), + ); + + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith("/api/")) { + res.set("Cache-Control", "no-store"); + } + res.on("finish", () => { + if (req.originalUrl.startsWith("/.well-known/appspecific/")) return; + if (req.originalUrl === "/favicon.ico") return; + if (res.statusCode >= 400) { + options.logger.error( + { + method: req.method, + url: req.originalUrl, + statusCode: res.statusCode, + }, + "HTTP request failed", + ); + } + }); + next(); + }); + app.use(express.json()); + + app.use(express.static(path.join(__dirname, "../../public"))); + app.use(express.static(path.join(__dirname, "../../public/app"))); + + app.get("/", (_req: Request, res: Response) => { + const reactIndex = path.join(__dirname, "../../public/app/index.html"); + if (fs.existsSync(reactIndex)) { + res.sendFile(reactIndex); + return; + } + res + .status(503) + .send("React dashboard is not built. Run pnpm run build:web."); + }); + + // Health and auth routes + app.use(createHealthRoutes({ + adminPassword: options.adminPassword, + activeUserCount: options.activeUserCount, + wsClientCount: options.wsClientCount, + })); + + // Route modules + app.use( + "/api", + createUIStateRoutes({ + getSharedUIState: options.getSharedUIState, + patchSharedUIState: options.patchSharedUIState, + }), + ); + app.use( + "/api", + createVoiceRoutes({ + voiceController: options.voiceController, + patchSharedUIState: options.patchSharedUIState, + broadcaster: options.broadcaster, + adminPassword: options.adminPassword, + }), + ); + app.use("/api", createMessageRoutes()); + app.use("/api", createAnalysisRoutes()); + app.use("/api", createSyncRoutes(options.client)); + app.use("/api", createRecordingsRoutes()); + app.use( + "/api", + createMediaRoutes(options.mediaController, { + adminPassword: options.adminPassword, + }), + ); + + app.use( + ( + error: Error, + _req: Request, + res: Response, + _next: NextFunction, + ) => { + if (error instanceof AppError) { + res.status(error.statusCode).json({ + error: error.code, + message: error.message, + }); + return; + } + + options.logger.error({ error }, "Unhandled webserver error"); + res.status(500).json({ + error: "INTERNAL_SERVER_ERROR", + message: "Internal server error", + }); + }, + ); + + return app; +} diff --git a/src/http/health.ts b/src/http/health.ts new file mode 100644 index 0000000..acc25ef --- /dev/null +++ b/src/http/health.ts @@ -0,0 +1,39 @@ +import { Router } from "express"; +import { getMetrics, uptimeGauge } from "../metrics"; + +export interface HealthRoutesOptions { + adminPassword: string; + activeUserCount: () => number; + wsClientCount: () => number; +} + +export function createHealthRoutes(options: HealthRoutesOptions) { + const router = Router(); + + router.get("/health", (_req, res) => { + res.json({ + status: "ok", + timestamp: new Date().toISOString(), + uptime: process.uptime(), + activeUsers: options.activeUserCount(), + wsClients: options.wsClientCount(), + }); + }); + + router.get("/metrics", async (_req, res) => { + res.set("Content-Type", "text/plain"); + uptimeGauge.set(process.uptime()); + res.send(await getMetrics()); + }); + + router.post("/api/auth/login", (req, res) => { + const { password } = req.body; + if (password === options.adminPassword) { + res.json({ ok: true }); + return; + } + res.status(401).json({ error: "Invalid password" }); + }); + + return router; +} diff --git a/src/webserver.ts b/src/webserver.ts index 60aaeed..2c93850 100644 --- a/src/webserver.ts +++ b/src/webserver.ts @@ -1,20 +1,9 @@ -import fs from "node:fs"; import http from "node:http"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; import type { Client } from "discord.js-selfbot-v13"; -import express, { - type NextFunction, - type Request, - type Response, -} from "express"; -import helmet from "helmet"; import { config } from "./config"; -import { AppError } from "./errors"; -import { createChildLogger, logger } from "./logger"; +import { createChildLogger } from "./logger"; import { MediaController } from "./media/mediaController"; import { createScreenShareController } from "./media/screenShareController"; -import { getMetrics, uptimeGauge } from "./metrics"; import { createBroadcaster } from "./moderation/broadcaster"; import { createSharedUIStateStore } from "./state/uiState"; import { Streamer } from "./streaming"; @@ -23,13 +12,6 @@ import { initializeMediaSettings, persistMediaSettings, } from "./state/mediaSettings"; -import { createAnalysisRoutes } from "./routes/analysisRoutes"; -import { createMediaRoutes } from "./routes/mediaRoutes"; -import { createMessageRoutes } from "./routes/messageRoutes"; -import { createRecordingsRoutes } from "./routes/recordingsRoutes"; -import { createSyncRoutes } from "./routes/syncRoutes"; -import { createUIStateRoutes } from "./routes/uiStateRoutes"; -import { createVoiceRoutes } from "./routes/voiceRoutes"; import { exposeActiveUserGlobal, exposeModerationGlobals, @@ -37,9 +19,7 @@ import { exposeVideoBroadcastGlobal, } from "./ws/broadcastGlobals"; import { startWebSocketServer } from "./ws/server"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); +import { createHttpApp } from "./http/app"; const wsLogger = createChildLogger("webserver"); @@ -56,9 +36,6 @@ export async function startWebserver( const { getSharedUIState, patchSharedUIState } = await createSharedUIStateStore(); let mediaSettings = await initializeMediaSettings(); - const app = express(); - const server = http.createServer(app); - const wsPath = "/ws"; // Create broadcaster instance @@ -94,101 +71,20 @@ export async function startWebserver( }, }); - // Security headers. CSP disabled because the current static UI uses inline scripts/styles. - app.use( - helmet({ - contentSecurityPolicy: false, - }), - ); - - app.use((req: Request, res: Response, next: NextFunction) => { - if (req.path.startsWith("/api/")) { - res.set("Cache-Control", "no-store"); - } - res.on("finish", () => { - if (req.originalUrl.startsWith("/.well-known/appspecific/")) return; - if (req.originalUrl === "/favicon.ico") return; - if (res.statusCode >= 400) { - logger.error( - { - method: req.method, - url: req.originalUrl, - statusCode: res.statusCode, - }, - "HTTP request failed", - ); - } - }); - next(); - }); - app.use(express.json()); - - app.use(express.static(path.join(__dirname, "../public"))); - app.use(express.static(path.join(__dirname, "../public/app"))); - - app.get("/", (_req: Request, res: Response) => { - const reactIndex = path.join(__dirname, "../public/app/index.html"); - if (fs.existsSync(reactIndex)) { - res.sendFile(reactIndex); - return; - } - res - .status(503) - .send("React dashboard is not built. Run pnpm run build:web."); + const app = createHttpApp({ + client: _client, + voiceController, + mediaController, + broadcaster, + adminPassword: config.ADMIN_PASSWORD, + getSharedUIState, + patchSharedUIState, + activeUserCount: () => activeUsers.size, + wsClientCount: () => broadcaster.clientCount(), + logger: wsLogger, }); - // Health check endpoint - app.get("/health", (_req: Request, res: Response) => { - res.json({ - status: "ok", - timestamp: new Date().toISOString(), - uptime: process.uptime(), - activeUsers: activeUsers.size, - wsClients: broadcaster.clientCount(), - }); - }); - - // Metrics endpoint - app.get("/metrics", async (_req: Request, res: Response) => { - res.set("Content-Type", "text/plain"); - uptimeGauge.set(process.uptime()); - res.send(await getMetrics()); - }); - - // Simple password-based auth - app.post("/api/auth/login", (req: Request, res: Response) => { - const { password } = req.body; - if (password === config.ADMIN_PASSWORD) { - res.json({ ok: true }); - } else { - res.status(401).json({ error: "Invalid password" }); - } - }); - - // Register route modules - app.use( - "/api", - createUIStateRoutes({ getSharedUIState, patchSharedUIState }), - ); - app.use( - "/api", - createVoiceRoutes({ - voiceController, - patchSharedUIState, - broadcaster, - adminPassword: config.ADMIN_PASSWORD, - }), - ); - app.use("/api", createMessageRoutes()); - app.use("/api", createAnalysisRoutes()); - app.use("/api", createSyncRoutes(_client)); - app.use("/api", createRecordingsRoutes()); - app.use( - "/api", - createMediaRoutes(mediaController, { - adminPassword: config.ADMIN_PASSWORD, - }), - ); + const server = http.createServer(app); function broadcastUserState() { const users = Array.from(activeUsers.entries()).map(([id, data]) => ({ @@ -213,29 +109,6 @@ export async function startWebserver( logger: wsLogger, }); - app.use( - ( - error: Error, - _req: express.Request, - res: express.Response, - _next: express.NextFunction, - ) => { - if (error instanceof AppError) { - res.status(error.statusCode).json({ - error: error.code, - message: error.message, - }); - return; - } - - wsLogger.error({ error }, "Unhandled webserver error"); - res.status(500).json({ - error: "INTERNAL_SERVER_ERROR", - message: "Internal server error", - }); - }, - ); - server.listen(port, "0.0.0.0", () => { wsLogger.info({ port }, "Web interface listening"); });