Files
curriculum-project-hub/hub/src/admin/static.ts
T
hongjr03 4e2699d0a5 fix(admin): serve built SPA and include it in release builds
registerStaticSpa was never mounted, so production only exposed org-admin
APIs. Wire it after auth/API routes, fold admin:build into npm run build,
and install admin-web deps during deploy so admin-web/build ships with the
release for same-origin /admin/*.
2026-07-14 23:54:47 +08:00

83 lines
3.1 KiB
TypeScript

/**
* 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<string, string> = {
".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<void> {
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);
});
}