import { existsSync } from 'node:fs'; import path from 'node:path'; import { Logger } from '@nestjs/common'; import fastifyStatic from '@fastify/static'; import type { NestFastifyApplication } from '@nestjs/platform-fastify'; /** Request paths that belong to the backend, never to the SPA fallback. */ const BACKEND_PREFIXES = ['/api', '/mcp', '/socket.io'] as const; function isBackendPath(url: string): boolean { // Match on the path only: `/api?x=1` is a backend request, and the query // string must never turn it into an SPA fallback. const pathOnly = url.split('?', 1)[0] ?? url; return BACKEND_PREFIXES.some( (prefix) => pathOnly === prefix || pathOnly.startsWith(`${prefix}/`), ); } /** * Serve the built web SPA bundle (Phase P5 cutover, #1444). * * WEB_DIST_DIR unset: SPA serving is disabled — dev runs the Vite dev server, * which proxies /api and /socket.io here. WEB_DIST_DIR set but not holding a * built bundle: fail at boot, because a gateway configured to serve the UI * silently serving 404s is an outage, not a degraded mode. * * Static files get exact routes (wildcard: false, so nothing shadows the API * routes); every other GET/HEAD outside the backend prefixes falls back to * index.html so client-side routes deep-link correctly. */ export async function mountSpaStatic(app: NestFastifyApplication): Promise { const logger = new Logger('SpaStatic'); const distDir = process.env['WEB_DIST_DIR']; if (!distDir) { logger.log('WEB_DIST_DIR not set; SPA serving disabled (dev mode uses the Vite dev server)'); return; } const root = path.resolve(distDir); const indexFile = path.join(root, 'index.html'); if (!existsSync(indexFile)) { throw new Error(`WEB_DIST_DIR is '${distDir}' but '${indexFile}' does not exist`); } // Default cache semantics: public, max-age=0 with ETag/Last-Modified, so // every response revalidates (304 when unchanged). Always correct, including // for index.html after a deploy. await app.register( fastifyStatic as never, { root, wildcard: false, index: false, } as never, ); const fastify = app.getHttpAdapter().getInstance(); // Files under /assets/ carry a content hash in their name (Vite emits them // that way), so they get long-lived immutable caching: a changed file is a // new URL, never a stale cache hit. An onSend hook rather than the plugin's // `setHeaders` option, because @fastify/static applies its own computed // cache-control (reply.headers) after calling setHeaders, overriding it. fastify.addHook('onSend', (req, reply, payload, done) => { const pathOnly = (req.raw.url ?? '').split('?', 1)[0] ?? ''; if (reply.statusCode === 200 && pathOnly.startsWith('/assets/')) { void reply.header('cache-control', 'public, max-age=31536000, immutable'); } done(null, payload); }); // A wildcard route, not setNotFoundHandler: Nest installs its own not-found // handler during init and Fastify allows only one. find-my-way matches // most-specific-first, so every declared route (API, static files) wins over // this catch-all; non-GET unmatched requests keep Fastify's stock 404. fastify.get('/*', (req, reply) => { const url = req.raw.url ?? ''; const pathOnly = url.split('?', 1)[0] ?? url; if (isBackendPath(url)) { // An unknown backend path is an API 404, never the SPA page. void reply.code(404).send({ message: `Route ${req.raw.method ?? 'GET'}:${url} not found`, error: 'Not Found', statusCode: 404, }); return; } if (pathOnly === '/assets' || pathOnly.startsWith('/assets/')) { // A missing hashed asset — typically a browser holding a stale // index.html after a deploy — must 404. Falling through to the SPA // fallback would return index.html as the asset body, and the onSend // hook above would stamp it with a year-long immutable cache-control. void reply.code(404).send({ message: `Asset ${pathOnly} not found`, error: 'Not Found', statusCode: 404, }); return; } // sendFile is decorated by @fastify/static; its type augmentation targets // a different fastify copy in the pnpm tree than the Nest adapter's. (reply as unknown as { sendFile: (file: string) => unknown }).sendFile('index.html'); }); logger.log(`Serving SPA bundle from ${root}`); }