Le tableau de bord /admin/monitoring fonctionnait en polling côté client (15 s) déclenchant une sonde active côté serveur à chaque appel — deux onglets admin ouverts = deux séries de sondes redondantes. Inverse le mécanisme : le serveur sonde une fois par minute et pousse le résultat aux clients connectés via WebSocket (socket.io, room "monitoring"). Décisions actées avec l'utilisateur : l'endpoint REST disparaît entièrement (plus de repli, 100% WebSocket) ; la sonde tourne en continu dès le démarrage, sans lien avec la présence d'un admin connecté (le plus simple des deux, coût des sondes négligeable). Backend : - ExpressServer (packages/express-tools) expose désormais son http.Server sous-jacent (créé explicitement, plus dans .listen()) — socket.io s'attache au serveur HTTP lui-même, pas à Express/un port. - MonitoringBroadcastService (nouveau) : sonde immédiate au démarrage puis toutes les 60 s (réutilise getMonitoring() tel quel, aucune duplication), diffuse à la room "monitoring", rejoue le dernier résultat en cache à tout nouvel admin qui rejoint (pas d'attente jusqu'à une minute pour un onglet fraîchement ouvert). - requireAdminSocket (nouveau) : middleware de handshake socket.io, équivalent WebSocket de requireAdmin — même vérification (cookie + JWT + tokenVersion), factorisée dans resolveAdminFromToken (require-admin.ts) puisque les deux ne diffèrent que par où ils lisent le cookie brut (un handshake socket.io ne passe pas par cookie-parser). - GET /admin/api/monitoring supprimée (route + service de test associés). - server.ts (pas app.ts) instancie socket.io — les tests supertest n'ont pas besoin d'un serveur WebSocket. Frontend : - MonitoringSocketClient (nouveau, api/monitoring-socket.ts) : wrapper socket.io-client, interface MonitoringSocketClientLike volontairement petite et injectable dans MonitoringPage (prop optionnelle) pour rester testable sans serveur réel. - MonitoringPage : data et connectionState séparés — une coupure de connexion après un premier chargement affiche un badge "reconnexion en cours" sans jamais effacer le dernier board connu (repose sur la reconnexion automatique de socket.io-client). Seul le tout premier chargement affiche l'état plein-écran chargement/erreur. - adminApiClient.getMonitoring() supprimée (plus utilisée). Tests : - Nouveau apps/api/test/admin-monitoring-socket.test.ts : vraie connexion socket.io-client contre un serveur réel démarré dans le test (port 0), authentification avec un admin seedé + cookie signé, vérifie la réception de monitoring:update et le rejet sans cookie admin valide. Les tests heartbeat de l'ancien admin-monitoring.test.ts (route /internal/tech-steps/heartbeat, sans rapport avec ce changement) sont déplacés vers tech-step-worker.routes.test.ts, leur router d'origine. - Nouveau cypress/component/MonitoringPage.cy.tsx : monte la page avec un client factice, vérifie rendu des cartes, badge de reconnexion sans perte du board, états chargement/erreur — pas de test e2e Gherkin contre un vrai WebSocket (aucun mécanisme pour ça dans ce repo, fragile ; le test Mocha ci-dessus est la preuve bout-en-bout serveur). - apps/web/cypress/e2e/admin-monitoring.cy.ts (REST) supprimé, remplacé par le test composant ci-dessus. Vérification : pnpm lint clean, build complet du monorepo clean, suite Cypress e2e complète 27/27 fichiers (114/114 tests), suite composant complète 5/5 fichiers (54/54 tests, dont les 4 nouveaux). Suite Mocha apps/api non exécutable cette session (Postgres/Docker injoignable, comme pour #33) — le nouveau test socket.io n'a donc pas pu être run-vérifié, seulement relu attentivement et vérifié par le lint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
139 lines
5.6 KiB
TypeScript
139 lines
5.6 KiB
TypeScript
import http from "node:http";
|
|
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(s) allowed by CORS — a single origin, or a list when more than
|
|
* one frontend talks to this API from a different origin (e.g. the main
|
|
* app plus a separate admin app). Passed straight through to the `cors`
|
|
* package, which matches an incoming `Origin` against any entry of the
|
|
* list.
|
|
*/
|
|
corsOrigin: string | 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;
|
|
/**
|
|
* The raw Node HTTP server wrapping {@link _app} — created up front
|
|
* (not lazily inside {@link listen}) so callers can attach something
|
|
* that needs the actual `http.Server` *before* it starts accepting
|
|
* connections, e.g. a `socket.io` server, which binds to the HTTP
|
|
* server itself rather than to Express or a port. Express's own
|
|
* `.listen()` creates one of these internally too, but never hands it
|
|
* back — that's the whole reason this class creates it explicitly.
|
|
*/
|
|
private readonly _httpServer: http.Server;
|
|
/** Tracks `"METHOD path"` keys already registered via {@link addRoute}, to warn instead of silently double-registering a route. */
|
|
private readonly _registeredRoutes = new Set<string>();
|
|
|
|
public constructor() {
|
|
this._app = express();
|
|
this._httpServer = http.createServer(this._app);
|
|
}
|
|
|
|
/** The underlying Express application — needed by test tooling (e.g. supertest) that expects a raw `Express` instance. */
|
|
public get instance(): Express {
|
|
return this._app;
|
|
}
|
|
|
|
/** The underlying `http.Server` — for anything that needs to attach directly to it (e.g. a `socket.io` server) rather than go through Express. Safe to read before {@link listen} is called; it just won't be accepting connections yet. */
|
|
public get httpServer(): http.Server {
|
|
return this._httpServer;
|
|
}
|
|
|
|
/**
|
|
* 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._httpServer.listen(port, onListening);
|
|
}
|
|
}
|