web-font/backend/app.ts
崮生(子虚) 0693521716 feat: SDK 抽取为 webfont-sdk npm 包 + leafer-x-webfont 插件(浏览器验证通过)
- packages/webfont-sdk:TS 重写,IncrementalEngine 核心(去重/并发池/失败字符记忆/retryFailed)
  - CSS 模式(WebFont)API 与旧 public/webfont-sdk.js 完全兼容
  - FontFace 模式(WebFontCanvas)供 Canvas 场景,unicodeRange 注册 + onChunk 回调
  - tsdown 双产物:dist/(ESM+dts)+ dist-iife/(script 直引)
  - scripts/sync-public.mjs 构建后同步到 public/webfont-sdk.js
  - 修复 banner 内嵌套块注释导致的语法错误(/* 重绘 */ 提前闭合 banner)
- packages/leafer-x-webfont:LeaferJS 插件,依赖 webfont-sdk
  - 监听 property.change + layout.end 防抖扫描 Text 节点聚合字符
  - 新旧 API 兼容(on_/on__、off_/off__)
  - 注册后自动改写 fontFamily 为合法 CSS 名(去 .ttf 后缀)
  - demo/index.html:海报场景(标题/副标题/字体切换/导出 PNG)本地 vendor 验证通过
- backend/app.ts:fs 适配层改为 top-level await import(修复 dev 模式 mkdir 崩溃)
- doc/:商业化思路一/二评估 + 主动拓客文档
2026-08-15 13:07:32 +08:00

229 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** 首先加载 fs 适配层,必须在 main() 之前完成按运行时选择实现top-level await 保证注册先于一切使用) */
if (typeof __RUNTIME__ !== "undefined" && __RUNTIME__ === "llrt") {
await import("./server/llrt");
} else {
/** Node.jstsx 开发 / tsdown define "node" 构建)走 node 适配器 */
await import("./server/node");
}
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";
import { SimpleHttpServer } from "./server/server";
import { path_join, readFile, stat, mkdir } from "./interface";
import { parseUrl, jsonResponse, stats, initStats, markStatsDirty } from "./shared";
import { flushStatsSyncSafe } from "./stats_store";
import { enableTempUpload, adminApiKey } from "./config";
import { handleListFonts } from "./routes/fonts";
import { handleGetConfig } from "./routes/config";
import { handleStats, handleStatsEvent } from "./routes/stats";
import { handleUpload } from "./routes/upload";
import { handleFontSubset } from "./routes/subset";
import { handleFontDetail } from "./routes/font_detail";
import { handleFontMeta } from "./routes/font_meta";
import { startTempCleaner } from "./temp_cleaner";
import { initMemoryGate } from "./subset_queue";
import { subsetConcurrency } from "./config";
const ROOT_DIR = "dist";
/** 启动时确保必要目录存在 */
async function ensureDirectories() {
for (const dir of ["font/temp", "font/admin"]) {
try {
await stat(dir);
} catch {
await mkdir(dir);
}
}
}
const logMiddleware: cMiddleware = async (req, res, next) => {
stats.totalRequests++;
markStatsDirty();
const t1 = Date.now();
const r = await next(req, res);
const t2 = Date.now();
const url = parseUrl(req);
console.log(`[${t2 - t1}ms] ${req.method} ${url.pathname}`);
return r;
};
const staticFileMiddleware: cMiddleware = async function (req, _res, next) {
let newRes: Response;
if (req.method === "GET") {
const url = parseUrl(req);
const pathname = url.pathname;
const filePath = path_join(ROOT_DIR, pathname === "/" ? "index.html" : pathname);
/** 防止路径穿越:规范化后必须仍在 dist 目录内 */
if (!filePath.startsWith(ROOT_DIR + "/") && filePath !== ROOT_DIR) {
newRes = new Response("403 Forbidden", {
status: 403,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
return next(req, newRes);
}
try {
/**
* 解析静态文件路径,处理三种情况:
* 1. 精确文件(如 /assets/app.js→ 直接返回
* 2. 目录(如 /demo→ 尝试 目录/index.htmlSSG 子路由预渲染产物)
* 3. 不存在(如 SPA 动态路由 /blog/123→ fallback 到根 index.html
*/
let resolvedPath = filePath;
const fileStat = await stat(filePath);
if (fileStat.isDirectory()) {
resolvedPath = path_join(filePath, "index.html");
await stat(resolvedPath);
}
const fileContent = await readFile(resolvedPath);
const extname = resolvedPath.split(".").pop() ?? "";
newRes = new Response(fileContent as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": mimeTypes[extname] || "application/octet-stream",
"Content-Length": `${fileContent.byteLength}`,
},
});
} catch {
/**
* 字体详情页 SSR/fonts/xxx 路径无预渲染文件时,
* 读取 SSG 模板替换占位符后返回完整 HTML。
* handleFontDetail 返回 null 时继续走 SPA fallback。
*/
if (pathname.startsWith("/fonts/")) {
const ssrResponse = await handleFontDetail(pathname);
if (ssrResponse) {
newRes = ssrResponse;
return next(req, newRes);
}
}
/**
* 文件/目录不存在 → SPA fallback返回根 index.html
* 交给前端 vue-router 接管路由(支持未预渲染的动态路由)。
* 带后缀的请求(.js/.css 等静态资源)不 fallback直接 404。
*/
const hasExt = /\.[^/]+$/.test(pathname);
if (hasExt) {
newRes = new Response("404 Not Found", {
status: 404,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
} else {
const fallbackContent = await readFile(path_join(ROOT_DIR, "index.html"));
newRes = new Response(fallbackContent as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Length": `${fallbackContent.byteLength}`,
},
});
}
}
} else {
newRes = new Response("Method Not Allowed", {
status: 405,
headers: {
"Content-Type": "text/plain; charset=utf-8",
},
});
}
return next(req, newRes);
};
const corsMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "OPTIONS") {
return {
req,
res: new Response("", {
status: 204,
headers: {
"Content-Length": "0",
},
}),
};
} else {
const newRes = await next(req, res);
newRes.res.headers.append("Access-Control-Allow-Origin", "*");
newRes.res.headers.append("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
newRes.res.headers.append("Access-Control-Allow-Headers", "Content-Type, Authorization");
return newRes;
}
};
/** 统一的 API 路由中间件 */
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
const url = parseUrl(req);
if (!url.pathname.startsWith("/api")) return next(req, res);
if (url.pathname === "/api/fonts" && req.method === "GET") {
return handleListFonts(req, res);
}
if (url.pathname === "/api/config" && req.method === "GET") {
return handleGetConfig(req, res);
}
if (url.pathname === "/api/stats" && req.method === "GET") {
return handleStats(req, res);
}
if (url.pathname === "/api/stats/event" && req.method === "POST") {
return handleStatsEvent(req, res);
}
if (url.pathname === "/api/upload" && req.method === "POST") {
return handleUpload(req, res);
}
if (url.pathname === "/api/font-meta" && req.method === "GET") {
return handleFontMeta(req, res);
}
if (url.pathname === "/api" && req.method === "GET") {
return handleFontSubset(req, res);
}
return next(req, res);
};
/** 上传文件大小限制 50MB */
const MAX_UPLOAD_SIZE = 50 * 1024 * 1024;
const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
if (req.method === "POST" && parseUrl(req).pathname === "/api/upload") {
const contentLength = parseInt(req.headers.get("Content-Length") ?? "0", 10);
if (contentLength > MAX_UPLOAD_SIZE) {
return {
req,
res: jsonResponse({ success: false, error: "文件过大,最大 50MB" }, 413),
};
}
}
return next(req, res);
};
async function main() {
/** 最早期恢复累计计数:之后的请求计数会累加在历史值之上 */
await initStats();
/** 优雅退出时尽力落盘最后一次增量SIGKILL 时由定时器兜底) */
globalThis.process?.on?.("beforeExit", () => {
markStatsDirty();
flushStatsSyncSafe();
});
await ensureDirectories();
const server = new SimpleHttpServer({ port: 8087 });
server.use(
logMiddleware,
corsMiddleware,
uploadSizeMiddleware,
fontApiMiddleware,
staticFileMiddleware,
);
console.log("[config] temp upload:", enableTempUpload);
console.log("[config] admin upload:", !!adminApiKey);
/** 初始化子集化并发队列(含字体分组调度) */
initMemoryGate(subsetConcurrency);
/** 启动临时字体定时清理器 */
startTempCleaner();
}
main();