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 { return BACKEND_PREFIXES.some((prefix) => url === prefix || url.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; immutable caching for hashed /assets/ files // is a P6 optimization. await app.register( fastifyStatic as never, { root, wildcard: false, index: false, } as never, ); // 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. const fastify = app.getHttpAdapter().getInstance(); fastify.get('/*', (req, reply) => { const url = req.raw.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; } // 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}`); }