/** * 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web` * 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。 * * 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html * 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*。 * * 三类路由: * /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为 * `_filelib`,以避开 admin-web 在根上注册的 /_app/* * (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。 * /app, /app/* — 老师端 SPA 回退 * /database, /database/* — 管理后台 SPA 回退 * * 具体路由(/database/api/*、/database/dev-login、/app/dev-login-teacher 等)由 * databaseRoutes.ts / teacherApp.ts 先注册;Fastify 按具体度匹配,通配不遮蔽它们。 * * Override the UI directory with `CPH_FILELIB_UI_DIR` if needed. */ import { readFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve as resolvePath } from "node:path"; import fastifyStatic from "@fastify/static"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; function resolveUiDir(): string { const override = process.env["CPH_FILELIB_UI_DIR"]; if (override && override.trim() !== "") return resolvePath(override); const here = dirname(fileURLToPath(import.meta.url)); return resolvePath(join(here, "..", "..", "filelib-web", "build")); } /** 构建产物根目录下的顶层静态文件(SvelteKit 把 static/ 原样拷到这里)。 */ const TOP_LEVEL_FILES = ["favicon.svg", "favicon.ico", "robots.txt"] as const; export async function registerDatabaseSpa(app: FastifyInstance): Promise { const uiDir = resolveUiDir(); if (!existsSync(join(uiDir, "index.html"))) { app.log.warn( { uiDir }, "filelib-web/build not found; /app and /database SPA shells disabled. Run `npm run build --prefix filelib-web` to enable. JSON APIs remain fully functional.", ); return; } const indexHtml = await readFile(join(uiDir, "index.html"), "utf8"); // 构建资源。@fastify/static 负责 MIME、ETag/Last-Modified 与目录穿越防护。 // 只注册一次 —— /app 与 /database 下的页面引用的都是这同一组绝对路径 // (filelib-web 的 paths.relative=false 保证了这点)。 await app.register(fastifyStatic, { root: join(uiDir, "_filelib"), prefix: "/_filelib/", decorateReply: true, }); for (const name of TOP_LEVEL_FILES) { if (!existsSync(join(uiDir, name))) continue; app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir)); } // SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以 // 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。 const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise => reply.type("text/html; charset=utf-8").send(indexHtml); app.get("/app", sendIndex); app.get("/app/*", sendIndex); app.get("/database", sendIndex); app.get("/database/*", sendIndex); }