src/server/spa-catchall.ts
Rama main · réplica de lectura del repositorio original.
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);
}