/** * Serves the org-admin SPA (built by SvelteKit via `admin-web/build/`) and * the SPA index fallback for client-side routes under `/admin/*`. * * The SvelteKit project lives in `hub/admin-web/`. Run `npm run build` there * to produce the static output in `admin-web/build/`. In development, use * `npm run dev` in `admin-web/` which proxies `/api` and `/auth` to the Hub. * * Override the UI directory with `CPH_ADMIN_UI_DIR` env if needed. */ import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, extname, join, resolve as resolvePath } from "node:path"; import type { FastifyInstance } from "fastify"; const MIME: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".png": "image/png", ".jpg": "image/jpeg", ".woff": "font/woff", ".woff2": "font/woff2", ".json": "application/json; charset=utf-8", ".txt": "text/plain; charset=utf-8", }; function resolveUiDir(): string { const override = process.env["CPH_ADMIN_UI_DIR"]; if (override && override.trim() !== "") return resolvePath(override); const here = dirname(fileURLToPath(import.meta.url)); return resolvePath(join(here, "..", "..", "admin-web", "build")); } export async function registerStaticSpa(app: FastifyInstance): Promise { const uiDir = resolveUiDir(); if (!existsSync(join(uiDir, "index.html"))) { app.log.warn( { uiDir }, "admin-web/build not found; SPA shell disabled. Run `npm run build` in admin-web/ to enable. Org admin APIs remain fully functional.", ); return; } const indexHtml = await readFile(join(uiDir, "index.html"), "utf8"); // SvelteKit static assets (_app/*, favicon, etc.) app.get("/_app/*", async (request, reply) => { const rel = (request.params as { "*": string })["*"]; const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/"); try { const buf = await readFile(join(uiDir, "_app", safe)); const mime = MIME[extname(safe)] ?? "application/octet-stream"; return reply.type(mime).send(buf); } catch { return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } }); } }); // Other top-level static assets (favicon.svg, robots.txt, etc.) app.get("/favicon.svg", async (_request, reply) => { try { const buf = await readFile(join(uiDir, "favicon.svg")); return reply.type("image/svg+xml").send(buf); } catch { return reply.status(404).send(); } }); // SPA client-side route fallback. Auth registers `/admin/login` first so it // takes precedence; everything else under /admin/* serves the index so // SvelteKit's client-side router can resolve the view. app.get("/admin", async (_request, reply) => { return reply.type("text/html; charset=utf-8").send(indexHtml); }); app.get("/admin/*", async (_request, reply) => { return reply.type("text/html; charset=utf-8").send(indexHtml); }); }