import path from 'path'; import { existsSync } from 'fs'; import type express from 'express'; import { getSpaShell } from './spa-shell.ts'; // Non-page prefixes that must fall through to a real 404 (JSON/API/transport), never the SPA shell. function isNonPagePath(p: string): boolean { return p.startsWith('/api/') || p.startsWith('/mcp') || p.startsWith('/uploads') || p.startsWith('/internal/') || p.startsWith('/.well-known'); } /** * Register the SPA catch-all (`app.get('*')`) as the LAST GET route so it serves the built * index.html for client routes/deep links (/oauth/authorize, /cli-auth, /session/x, …) while * letting unmatched API/MCP/upload/internal/oauth-metadata paths fall through to a real 404. * * Idempotent + re-placeable: each call splices out any prior catch-all layer, so it can be called * again after a later mountFeatures() (passive→active promotion) and still sit behind those routes. * In dev (no built dist) it is a no-op — Vite serves the SPA and a catch-all would 404-shadow it. * * EXPRESS 4 COUPLING: uses the bare `app.get('*')` route and Express's private `_router.stack`. * A future express@5 bump breaks this LOUDLY (path-to-regexp v8 rejects `'*'`), not silently — * re-verify the promotion-path ordering if express is ever upgraded. */ export function registerSpaCatchAll(app: express.Express, distPath: string): void { if (!existsSync(distPath)) return; const indexHtml = path.join(distPath, 'index.html'); // For app.get(), the layer's `handle` is the Route's dispatcher — our tagged handler lives inside // `layer.route.stack[*].handle`. Match on that so re-registration removes the stale catch-all. const stack = (app as any)._router?.stack as Array<{ route?: { stack?: Array<{ handle?: { __spaCatchAll?: boolean } }> } }> | undefined; if (stack) { for (let i = stack.length - 1; i >= 0; i--) { if (stack[i]?.route?.stack?.some((h) => h?.handle?.__spaCatchAll)) stack.splice(i, 1); } } const handler: express.RequestHandler = (req, res, next) => { // A request carrying Authorization is a machine client (browsers never send it on a navigation), // so an unknown path must 404 for it — not answer 200 with HTML that a fetcher stores as the file // it asked for (live 2026-09-16: the media pod ingested the shell as a .png). if (isNonPagePath(req.path) || req.headers.authorization) return next(); // Serve the shell with the runtime web-config injected (cached). This is the SINGLE HTML-page // path — `/` falls through here too (express.static is mounted with `index: false`). res.type('html').send(getSpaShell(indexHtml)); }; (handler as { __spaCatchAll?: boolean }).__spaCatchAll = true; app.get('*', handler); }