import { existsSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import fastifyStatic from "@fastify/static"; import type { FastifyInstance } from "fastify"; // Serve the built React SPA (apps/web/dist) from the Fastify server, so ONE container // serves both the API and the operator UI — matching the offline-first single-appliance // model (the booth has no separate web host). This is a NO-OP in dev (the Vite dev server // serves the SPA on its own port and no dist exists), so it never changes local behavior. // // Registration order matters: this is registered LAST, after every API route, and its // catch-all is GET-only and explicitly excludes /api, /health, and the WS path — so it // can never shadow the backend. See wiki/decisions/container-deployment.md. /** Where the built SPA lives. Override with WEB_DIST_DIR (the container sets it). Default * resolves relative to this file's dist location: apps/server/dist → ../../web/dist, the * layout the image lays down (/app/dist + /app/web/dist → ../web/dist from dist). */ function resolveWebDist(): string { const fromEnv = process.env.WEB_DIST_DIR; if (fromEnv) return resolve(fromEnv); const here = dirname(fileURLToPath(import.meta.url)); // In the image the server runs from /app/dist and the SPA sits at /app/web/dist. return resolve(here, "../web/dist"); } /** * Register SPA static serving if a build is present. Returns true when wired, false when * skipped (dev / no build). Serves assets from the dist dir and falls back to index.html * for any non-API GET so client-side routing (TanStack Router) works on deep links/reload. */ export async function registerSpa(app: FastifyInstance): Promise { const root = resolveWebDist(); const indexHtml = resolve(root, "index.html"); if (!existsSync(indexHtml)) { app.log.info(`SPA static serving disabled (no build at ${root})`); return false; } await app.register(fastifyStatic, { root, wildcard: false }); // SPA fallback: any GET that didn't match an API route or a real static file returns // index.html (client routing). EXCLUDE the backend surfaces so a missing /api route // still 404s as JSON rather than silently returning the HTML shell. WS upgrades and // non-GET methods are never touched (this is a GET-only notFound handler path). app.setNotFoundHandler((req, reply) => { const url = req.raw.url ?? "/"; if (req.method !== "GET" || url.startsWith("/api") || url.startsWith("/health")) { return reply.code(404).send({ error: "not found" }); } return reply.sendFile("index.html"); }); app.log.info(`SPA static serving enabled from ${root}`); return true; }