/** * Routen-Inventar für Express 5. * * Express 5 (Paket `router` v2) speichert an gemounteten Routern den Mount-Pfad nicht * mehr. Ein Test, der nur `app.router.stack` liest, findet deshalb die Routen unter * `app.use("/api", apiRouter)` nicht mit vollem Pfad – und läuft grün, ohne zu prüfen. * Darum wird `Router.prototype.use` VOR dem Import der App instrumentiert und jeder * Mount-Pfad mitgeschrieben. * * Reihenfolge ist Pflicht: `installRouteRecorder()` aufrufen, DANACH die App importieren. */ import { createRequire } from "node:module"; import path from "node:path"; type Layer = { route?: { path: string | string[]; methods: Record }; handle: unknown }; type RouterLike = { stack: Layer[] }; const mountPaths = new WeakMap(); let RouterProto: Record | undefined; function loadRouterPrototype(): Record { const fromApp = createRequire(path.join(process.cwd(), "package.json")); const expressEntry = fromApp.resolve("express"); // Dasselbe `router`-Modul, das Express selbst lädt (gleiche Datei = gleicher Cache-Eintrag). const Router = createRequire(expressEntry)("router"); return Router.prototype; } export function installRouteRecorder(): void { if (RouterProto) return; RouterProto = loadRouterPrototype(); const originalUse = RouterProto.use as (...args: unknown[]) => unknown; RouterProto.use = function patchedUse(this: RouterLike, ...args: unknown[]) { const mount = typeof args[0] === "string" ? (args[0] as string) : Array.isArray(args[0]) ? null : "/"; const before = this.stack.length; const result = originalUse.apply(this, args); for (const layer of this.stack.slice(before)) { const existing = mountPaths.get(layer) ?? []; // Arrays/RegExp als Mount-Pfad werden als "?" markiert und fallen im Test auf. mountPaths.set(layer, [...existing, mount ?? "?"]); } return result; }; } function isRouter(handle: unknown): handle is RouterLike { return typeof handle === "function" && Array.isArray((handle as Partial).stack); } function join(prefix: string, suffix: string): string { const joined = `${prefix}/${suffix}`.replace(/\/+/g, "/"); return joined.length > 1 ? joined.replace(/\/$/, "") : joined; } export type Route = { method: string; path: string }; /** Alle Routen der App mit vollem Pfad, z. B. { method: "GET", path: "/api/projects/:id" }. */ export function collectRoutes(app: { router: RouterLike }): Route[] { if (!RouterProto) throw new Error("installRouteRecorder() wurde nicht vor dem App-Import aufgerufen"); const routes: Route[] = []; const walk = (router: RouterLike, prefix: string) => { for (const layer of router.stack) { if (layer.route) { const paths = Array.isArray(layer.route.path) ? layer.route.path : [layer.route.path]; for (const routePath of paths) { for (const [method, enabled] of Object.entries(layer.route.methods)) { if (enabled && method !== "_all") routes.push({ method: method.toUpperCase(), path: join(prefix, routePath) }); } } } else if (isRouter(layer.handle)) { for (const mount of mountPaths.get(layer) ?? ["?"]) walk(layer.handle, join(prefix, mount)); } } }; walk(app.router, "/"); return routes.sort((a, b) => `${a.path} ${a.method}`.localeCompare(`${b.path} ${b.method}`)); } /** Ersetzt :param und *wildcard durch einen Beispielwert. */ export function fillPath(routePath: string, value = "1"): string { return routePath.replace(/:[A-Za-z0-9_]+/g, value).replace(/\*[A-Za-z0-9_]*/g, value); }