Files
stack/apps/gateway/src/spa/serve-spa.ts
T
fred 385b2b6886 gateway: serve the SPA bundle same-origin (#1444)
New spa/serve-spa.ts: @fastify/static with wildcard:false plus a GET /*
catch-all that returns index.html for non-backend paths and a JSON 404
for unknown /api, /mcp, /socket.io paths. WEB_DIST_DIR unset disables
serving (dev uses the Vite dev server, which proxies to the gateway);
set but invalid fails loud at boot.

A wildcard route, not setNotFoundHandler: Nest installs its own
not-found handler at init and Fastify allows only one. find-my-way
matches most-specific-first, so declared routes win over the catch-all.
Cache semantics stay the library default (max-age=0 + ETag
revalidation); immutable hashed-asset caching is deferred to P6.

gateway.Dockerfile builds the web bundle and ships it at /app/web-dist
with WEB_DIST_DIR set.
2026-08-27 07:32:27 -05:00

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}`);
}