batchCooking/packages/express-tools/src/express-server.ts
Nicolas c2cc773c7b Fusionner web+api en une image, séparer la CI, ajouter la release
- express-tools: ExpressServer.serveStaticFrontend() sert le build du
  frontend (assets + fallback SPA), monté après les routes API et avant
  le 404 JSON. Opt-in via FRONTEND_DIST_DIR (uniquement défini dans
  l'image Docker) — le dev natif (dev:api/dev:web) est inchangé.
- apps/api/Dockerfile: build aussi apps/web, embarque son dist dans le
  runtime ; corrige au passage l'oubli de packages/date-tools. Supprime
  apps/web/Dockerfile et nginx.conf (plus de conteneur nginx séparé).
- docker-compose.yml: un seul service "app" (postgres + app), un seul
  port APP_PORT, plus de WEB_PORT/CORS_ORIGIN à coordonner entre deux
  origines. Garde `build:` (pas de registre — Portainer build depuis le
  repo Git).
- ci.yml: éclate le job unique lint-and-test+e2e en 4 jobs indépendants
  (lint/test/build/e2e), sans chaînage, déclenchés sur chaque push
  (toute branche) + PR vers main.
- release.yml (nouveau): sur tag vX.Y.Z, sanity-build de l'image Docker,
  GitHub Release avec changelog auto-généré, puis notification best-effort
  du webhook Portainer (secret PORTAINER_WEBHOOK_URL).
- README: documente le conteneur unique et le pipeline de release.
2026-08-17 23:11:26 +02:00

116 lines
4.4 KiB
TypeScript

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<string>();
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);
}
}