import path from "node:path"; import cookieParser from "cookie-parser"; import cors from "cors"; import express, { type ErrorRequestHandler, type Express, type RequestHandler, type Router, } from "express"; /** HTTP verbs {@link ExpressServer.addRoute} accepts. */ export type HttpMethod = "get" | "post" | "put" | "patch" | "delete"; /** Options for {@link ExpressServer.setupCore}. */ export interface ExpressServerCoreOptions { /** Origin allowed by CORS — must match wherever the frontend is served from. */ corsOrigin: string; } /** * Thin wrapper around an Express application: bundles the common * "set up the standard middleware stack, register routes without * duplicates, wire the error handler, start listening" concerns behind a * small typed API, instead of every service in the monorepo repeating the * same raw `express()` setup by hand. * * Framework-specific on purpose — unlike `ErrorHandlerService` (which has * no Express dependency at all), this class *is* the Express integration * layer. Business/domain code should never import `express` directly; * it goes through this instead. */ export class ExpressServer { /** The underlying Express application. */ private readonly _app: Express; /** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */ private readonly _registeredRoutes = new Set(); public constructor() { this._app = express(); } /** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */ public get instance(): Express { return this._app; } /** * Registers the standard middleware stack every service in this * monorepo needs: CORS (with credentials, for the session cookie), * JSON body parsing, and cookie parsing. Call once, before registering * any route. */ public setupCore(options: ExpressServerCoreOptions): void { this._app.use(cors({ origin: options.corsOrigin, credentials: true })); this._app.use(express.json()); this._app.use(cookieParser()); } /** Registers a middleware that runs on every request (e.g. logging, a catch-all 404 handler). */ public addMiddleware(middleware: RequestHandler): void { this._app.use(middleware); } /** * Registers the final Express error-handling middleware (the 4-argument * form). Must be added last — Express only treats a middleware as an * error handler by its arity, and only the last matching one runs. */ public setErrorHandler(middleware: ErrorRequestHandler): void { this._app.use(middleware); } /** Mounts a whole `express.Router` under a base path (e.g. `mountRouter("/auth", authRouter)`). */ public mountRouter(basePath: string, router: Router): void { this._app.use(basePath, router); } /** * Serves a built single-page app (static assets + SPA fallback) from * `distDir`. Call this **after** every `mountRouter`/`addRoute`, so API * routes always win, and **before** {@link addMiddleware}'s catch-all * 404 — any GET request that doesn't match an API route or a file in * `distDir` falls through to `index.html`, letting the client-side * router (e.g. react-router) handle it instead of a 404. * * Only meant for the production Docker image, where the frontend build * is copied alongside the API — native dev (`pnpm dev:api`) never calls * this, so `pnpm dev:web`'s own Vite dev server is unaffected. */ public serveStaticFrontend(distDir: string): void { this._app.use(express.static(distDir)); this._app.get("*", (_req, res) => { res.sendFile(path.join(distDir, "index.html")); }); } /** * Registers a single route with its handler(s). Warns and skips instead * of registering if the same method+path was already added — catches a * copy-paste mistake at startup instead of silently shadowing a route. */ public addRoute(method: HttpMethod, path: string, ...handlers: RequestHandler[]): void { const key = `${method.toUpperCase()} ${path}`; if (this._registeredRoutes.has(key)) { console.warn(`[ExpressServer] Route already registered, skipping: ${key}`); return; } this._registeredRoutes.add(key); this._app[method](path, ...handlers); } /** Starts listening on the given port. `onListening` is called once the server is up (e.g. to log the URL). */ public listen(port: number, onListening?: () => void): void { this._app.listen(port, onListening); } }