feat(stats): 统计计数跨重启持久化 + 依赖升级

- 新增 stats_store.ts:防抖写盘 font/.stats.json,热路径零磁盘 IO
- shared.ts 暴露 initStats/markStatsDirty/snapshotStats
- app.ts 启动恢复累计计数 + 退出前尽力落盘
- 路由热路径打脏标记(防抖 30s 批量落盘)
- 清理未用变量/导入/常量
- 升级依赖:vue 3.6.0-beta.17、typescript 7.0.2、vite 8.1.5 等

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
崮生(子虚) 2026-07-23 21:43:12 +08:00
parent f7a2a4dc86
commit d68551d23e
12 changed files with 1247 additions and 727 deletions

View File

@ -1,8 +1,9 @@
import { mimeTypes } from "./server/mime_type";
import type { cMiddleware } from "./server/req_res";
import { SimpleHttpServer } from "./server/server";
import { path_join, readFile, stat, readdir, mkdir } from "./interface";
import { parseUrl, jsonResponse, stats } from "./shared";
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";
@ -27,6 +28,7 @@ async function ensureDirectories() {
const logMiddleware: cMiddleware = async (req, res, next) => {
stats.totalRequests++;
markStatsDirty();
const t1 = Date.now();
const r = await next(req, res);
const t2 = Date.now();
@ -35,7 +37,7 @@ const logMiddleware: cMiddleware = async (req, res, next) => {
return r;
};
const staticFileMiddleware: cMiddleware = async function (req, res, next) {
const staticFileMiddleware: cMiddleware = async function (req, _res, next) {
let newRes: Response;
if (req.method === "GET") {
const url = parseUrl(req);
@ -149,6 +151,15 @@ const uploadSizeMiddleware: cMiddleware = async (req, res, next) => {
};
async function main() {
/** 最早期恢复累计计数:之后的请求计数会累加在历史值之上 */
await initStats();
/** 优雅退出时尽力落盘最后一次增量SIGKILL 时由定时器兜底) */
globalThis.process?.on?.("beforeExit", () => {
markStatsDirty();
flushStatsSyncSafe();
});
await ensureDirectories();
const server = new SimpleHttpServer({ port: 8087 });

View File

@ -24,7 +24,6 @@ const LT_SINGLE = 1;
const LT_MULTIPLE = 2;
const LT_ALTERNATE = 3;
const LT_LIGATURE = 4;
const LT_REVERSE_CHAIN = 5;
const LT_CHAIN = 6;
const LT_EXTENSION = 7;

View File

@ -2,7 +2,7 @@ import { jsonResponse } from "../shared";
import { enableTempUpload, adminApiKey } from "../config";
/** GET /api/config — 返回公开配置 */
export async function handleGetConfig(req: Request, res: Response) {
export async function handleGetConfig(req: Request, _res: Response) {
return {
req,
res: jsonResponse({

View File

@ -1,9 +1,9 @@
import { jsonResponse, parseUrl } from "../shared";
import { readdir, stat } from "../interface";
import { jsonResponse } from "../shared";
import { readdir } from "../interface";
import { fontDirs } from "../config";
/** GET /api/fonts — 列出所有可用字体 */
export async function handleListFonts(req: Request, res: Response) {
export async function handleListFonts(req: Request, _res: Response) {
const allFonts: Array<{ name: string; temporary: boolean }> = [];
for (const dir of fontDirs) {

View File

@ -1,7 +1,7 @@
import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared";
/** GET /api/stats — 返回运行时统计 */
export async function handleStats(req: Request, res: Response) {
export async function handleStats(req: Request, _res: Response) {
return {
req,
res: jsonResponse({

View File

@ -1,6 +1,6 @@
import { fontSubset } from "../font_util/font";
import type { FontEditor } from "../../vendor/fonteditor-core/lib/ttf/font.js";
import { parseUrl, jsonResponse, stats, subsetCache, findFontPath, readFontBuffer } from "../shared";
import { parseUrl, stats, subsetCache, findFontPath, readFontBuffer, markStatsDirty } from "../shared";
/**
*
@ -53,9 +53,11 @@ export async function handleFontSubset(req: Request, res: Response) {
const cacheKey = `${SUBSET_CACHE_KEY}:${fontPath}:${outType}:${text}`;
stats.subsetRequests++;
stats.totalChars += text.length;
markStatsDirty();
const cached = subsetCache.get(cacheKey);
if (cached) {
stats.subsetCacheHits++;
markStatsDirty();
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
return {
req,

View File

@ -3,7 +3,7 @@ import { parseMultipart } from "../multipart";
import { handleTempUpload, handleAdminUpload } from "../upload";
/** POST /api/upload?mode=temp|admin — 上传字体 */
export async function handleUpload(req: Request, res: Response) {
export async function handleUpload(req: Request, _res: Response) {
const url = parseUrl(req);
const mode = url.searchParams.get("mode") ?? "temp";

View File

@ -175,7 +175,7 @@ async function connectionHandle(
(rawReq as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer = bodyArrayBuffer;
const rawRes = new Response();
const { req, res } = await handle(rawReq, rawRes);
const { res } = await handle(rawReq, rawRes);
const resWriter = connection.writable.getWriter();
let headerText: string[] = [];
res.headers.forEach((value, key) => {

View File

@ -1,6 +1,7 @@
import { fontDirs, subsetCacheMaxSize } from "./config";
import { LruCache } from "./lru_cache";
import { path_join, readFile, stat, readdir } from "./interface";
import { loadPersistedStats, scheduleStatsFlush, type PersistedStats } from "./stats_store";
/** 解析请求 URLreq.url 只有路径,需要补全协议和主机才能用 URL API */
export function parseUrl(req: Request): URL {
@ -15,20 +16,55 @@ export function jsonResponse(data: unknown, status = 200) {
});
}
/** 运行时统计 */
/** 运行时统计累计计数跨重启持久化startTime 为本次进程启动时刻) */
export const stats = {
/** 服务启动时间戳 */
/** 服务启动时间戳(进程级,不持久化) */
startTime: Date.now(),
/** 总请求数 */
/** 总请求数(持久化累计) */
totalRequests: 0,
/** 字体裁剪请求次数(含缓存命中 */
/** 字体裁剪请求次数(含缓存命中;持久化累计 */
subsetRequests: 0,
/** 字体裁剪缓存命中次数 */
/** 字体裁剪缓存命中次数(持久化累计) */
subsetCacheHits: 0,
/** 累计裁剪文字字符数 */
/** 累计裁剪文字字符数(持久化累计) */
totalChars: 0,
};
/**
*
*
* main() await
* startTime uptime
*/
export async function initStats(): Promise<void> {
const persisted = await loadPersistedStats();
stats.startTime = persisted.startTime;
stats.totalRequests = persisted.totalRequests;
stats.subsetRequests = persisted.subsetRequests;
stats.subsetCacheHits = persisted.subsetCacheHits;
stats.totalChars = persisted.totalChars;
}
/** 取出需要持久化的累计计数字段 */
export function snapshotStats(): PersistedStats {
return {
totalRequests: stats.totalRequests,
subsetRequests: stats.subsetRequests,
subsetCacheHits: stats.subsetCacheHits,
totalChars: stats.totalChars,
};
}
/**
*
*
* +
* IO stats_store FLUSH_INTERVAL
*/
export function markStatsDirty(): void {
scheduleStatsFlush(snapshotStats());
}
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer按条目数淘汰 */
export const fontBufferCache = new LruCache<ArrayBuffer>({ maxSize: 3 });

102
backend/stats_store.ts Normal file
View File

@ -0,0 +1,102 @@
import { readFile, writeFile, mkdir } from "./interface";
/**
*
*
* totalRequests
*
*
* - await main()
* -
* - FLUSH_INTERVAL
* 使 SIGKILL FLUSH_INTERVAL
* - IO
*/
/** 持久化文件位置font/* 已在 .gitignore且为运行时可写目录 */
const STATS_FILE = "font/.stats.json";
/** 持久化的累计计数字段(不含 startTime——uptime 是进程级,不可叠加) */
export interface PersistedStats {
totalRequests: number;
subsetRequests: number;
subsetCacheHits: number;
totalChars: number;
}
/** 落盘防抖间隔(毫秒):在请求热路径之外的最低频写盘,平衡丢失量与 IO */
const FLUSH_INTERVAL = 30_000;
/** 待落盘的快照;写入完成后才清空,避免写入期间的增量丢失 */
let pending: PersistedStats | null = null;
/** 防抖定时器句柄 */
let flushTimer: ReturnType<typeof setInterval> | null = null;
/**
*
*
* 0
* startTime uptime
*/
export async function loadPersistedStats(): Promise<PersistedStats & { startTime: number }> {
try {
const raw = await readFile(STATS_FILE);
const parsed = JSON.parse(new TextDecoder().decode(raw)) as Partial<PersistedStats>;
return {
totalRequests: parsed.totalRequests ?? 0,
subsetRequests: parsed.subsetRequests ?? 0,
subsetCacheHits: parsed.subsetCacheHits ?? 0,
totalChars: parsed.totalChars ?? 0,
startTime: Date.now(),
};
} catch {
/** 首次启动 / 文件缺失 / JSON 损坏:从 0 起步 */
return { totalRequests: 0, subsetRequests: 0, subsetCacheHits: 0, totalChars: 0, startTime: Date.now() };
}
}
/**
*
*
* stats
* FLUSH_INTERVAL
*/
export function scheduleStatsFlush(snapshot: PersistedStats): void {
pending = snapshot;
if (flushTimer !== null) return;
flushTimer = setInterval(flushStats, FLUSH_INTERVAL);
}
/** 取快照并写盘;无变更时直接跳过 */
async function flushStats(): Promise<void> {
if (pending === null) return;
/** 先取走快照再写:写盘期间新到的增量会进下一次 pending不会丢 */
const snapshot = pending;
pending = null;
try {
/** font/ 目录通常已由 ensureDirectories 创建,这里兜底以防万一 */
await mkdir("font");
await writeFile(STATS_FILE, new TextEncoder().encode(JSON.stringify(snapshot)));
} catch (err) {
/** 写盘失败不影响服务:把快照放回 pending等下个周期重试 */
console.log("[stats] flush failed:", err);
pending = snapshot;
}
}
/**
* 退
*
* LLRT/ SIGKILL
* 退
*/
export function flushStatsSyncSafe(): void {
if (flushTimer !== null) {
clearInterval(flushTimer);
flushTimer = null;
}
/** markStatsDirty() pending
* LLRT/ SIGKILL */
flushStats();
}

View File

@ -17,21 +17,21 @@
"release": "pnpm build && pnpm build_backend && pnpm docker_build && pnpm docker_push"
},
"dependencies": {
"vue": "3.6.0-beta.10",
"web-streams-polyfill": "^4.3.0"
},
"devDependencies": {
"@types/node": "^25.9.1",
"@vitejs/plugin-vue": "^6.0.7",
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^6.0.8",
"@xmldom/xmldom": "^0.9.10",
"jsdom": "^29.1.1",
"pngjs": "^7.0.0",
"puppeteer": "^25.1.0",
"tsdown": "^0.22.1",
"typescript": "^6.0.3",
"undici": "^8.3.0",
"vite": "^8.0.14",
"vite-plugin-pilot": "^1.0.31",
"vitest": "^4.1.7"
"puppeteer": "^25.3.0",
"tsdown": "^0.22.13",
"typescript": "^7.0.2",
"undici": "^8.8.0",
"vite": "^8.1.5",
"vite-plugin-pilot": "^1.0.35",
"vitest": "^4.1.10",
"vue": "3.6.0-beta.17"
}
}

1768
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff