76 lines
3.0 KiB
TypeScript
76 lines
3.0 KiB
TypeScript
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<void> {
|
|
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}`);
|
|
}
|