P5: SPA cutover — retire Next.js, gateway serves the Vite bundle (#1444) (#1453)
ci/woodpecker/push/publish Pipeline was successful

This commit was merged in pull request #1453.
This commit is contained in:
2026-08-27 13:06:49 +00:00
parent bf8bc2128d
commit b5ee692843
65 changed files with 288 additions and 4011 deletions
+1
View File
@@ -28,6 +28,7 @@
"dependencies": {
"@anthropic-ai/sdk": "^0.80.0",
"@fastify/helmet": "^13.0.2",
"@fastify/static": "^8.3.0",
"@mariozechner/pi-ai": "^0.65.0",
"@mariozechner/pi-coding-agent": "^0.65.0",
"@modelcontextprotocol/sdk": "^1.27.1",
+2
View File
@@ -12,6 +12,7 @@ import { AppModule } from './app.module.js';
import { mountAuthHandler } from './auth/auth.controller.js';
import { mountMcpHandler } from './mcp/mcp.controller.js';
import { McpService } from './mcp/mcp.service.js';
import { mountSpaStatic } from './spa/serve-spa.js';
import { detectAndAssertTier, TierDetectionError } from '@mosaicstack/storage';
import { resolveGatewayConfigPath } from './env.js';
import { assertValidationPipeSeesDtoDecorators } from './validation-pipe-check.js';
@@ -68,6 +69,7 @@ async function bootstrap(): Promise<void> {
mountAuthHandler(app);
mountMcpHandler(app, app.get(McpService));
await mountSpaStatic(app);
const port = Number(process.env['GATEWAY_PORT'] ?? 14242);
await app.listen(port, '0.0.0.0');
+75
View File
@@ -0,0 +1,75 @@
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}`);
}