forked from bai/curriculum-project-hub
refactor(database)!: 后端不再渲染任何 HTML,只出 JSON
删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。
新增两个端点承接原先在 page handler 里 inline 算的东西:
GET /database/config 免鉴权 bootstrap(org slug + dev 开关);
注册位置刻意早于 silo org 的提前返回,
org 未就绪时登录页仍要能渲染。
GET /database/api/stats 概览统计,要求 silo org OWNER/ADMIN ——
它聚合的是 org 级计数与审计流,不是
单节点权限视图。
静态托管收敛到 static.ts:一份 filelib-web 构建产物挂 /app 与
/database 两个前缀,资源路由只注册一次。并发症是路由顺序成了硬约束
—— 具体页面路由必须先于 SPA 通配注册,否则重演 /database/dashboard
盖住 SPA 的老 bug(ADR-0029)。
/database/library 改为 302 到 /database/dashboard/library。
BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
This commit is contained in:
+43
-70
@@ -1,98 +1,71 @@
|
||||
/**
|
||||
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
|
||||
* and the SPA index fallback for client-side routes under `/database/*`.
|
||||
* 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web`
|
||||
* 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。
|
||||
*
|
||||
* The SvelteKit project lives in `hub/database-admin/` and is built with
|
||||
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*`
|
||||
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
|
||||
* colliding). On disk the files still live at `build/_app/*`; this handler maps
|
||||
* the `/database`-prefixed URLs back to those files.
|
||||
* 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html
|
||||
* 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*。
|
||||
*
|
||||
* Run `npm run build` in database-admin/ to produce the static output. In
|
||||
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to
|
||||
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed.
|
||||
* 三类路由:
|
||||
* /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为
|
||||
* `_filelib`,以避开 admin-web 在根上注册的 /_app/*
|
||||
* (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。
|
||||
* /app, /app/* — 老师端 SPA 回退
|
||||
* /database, /database/* — 管理后台 SPA 回退
|
||||
*
|
||||
* Mirrors src/admin/static.ts.
|
||||
* 具体路由(/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, 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",
|
||||
};
|
||||
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_DATABASE_UI_DIR"];
|
||||
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, "..", "..", "database-admin", "build"));
|
||||
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<void> {
|
||||
const uiDir = resolveUiDir();
|
||||
if (!existsSync(join(uiDir, "index.html"))) {
|
||||
app.log.warn(
|
||||
{ uiDir },
|
||||
"database-admin/build not found; /database SPA shell disabled. Run `npm run build` in database-admin/ to enable. /database/config and /database/dev-login remain functional.",
|
||||
"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");
|
||||
|
||||
// SvelteKit build assets. base='/database' emits them at /database/_app/*,
|
||||
// but on disk they're under build/_app/*.
|
||||
app.get("/database/_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" } });
|
||||
}
|
||||
// 构建资源。@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,
|
||||
});
|
||||
|
||||
// Top-level static files emitted under the base path (favicon.svg, robots.txt).
|
||||
app.get("/database/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();
|
||||
}
|
||||
});
|
||||
app.get("/database/robots.txt", async (_request, reply) => {
|
||||
try {
|
||||
const buf = await readFile(join(uiDir, "robots.txt"));
|
||||
return reply.type("text/plain; charset=utf-8").send(buf);
|
||||
} catch {
|
||||
return reply.status(404).send();
|
||||
}
|
||||
});
|
||||
for (const name of TOP_LEVEL_FILES) {
|
||||
if (!existsSync(join(uiDir, name))) continue;
|
||||
app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir));
|
||||
}
|
||||
|
||||
// SPA client-side route fallback. Concrete /database/* routes (/database/config,
|
||||
// /database/dev-login, and the asset routes above) are more specific, so
|
||||
// Fastify's router matches them before this wildcard. Everything else under
|
||||
// /database serves index.html so SvelteKit's client router can resolve the view.
|
||||
app.get("/database", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
app.get("/database/*", async (_request, reply) => {
|
||||
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||
});
|
||||
// SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以
|
||||
// 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。
|
||||
const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user