fix: LLRT readdir 不兼容 withFileTypes 导致临时字体清理失效

根因:LLRT 的 fs.readdir 返回字符串数组而非 Dirent 对象,
entry.isFile() 抛 TypeError 被 catch 静默吞掉,
导致 evictIfNeeded 和 temp_cleaner 从未执行(线上堆积 108 个文件 992M)。

修复:
- node.ts: readdir 改为先拿文件名再 stat 判断
- temp_cleaner.ts: catch 块加日志不再静默吞错误

同时包含:
- font-config.json homepage 链接修复(删除 3 个 404,修正 2 个)
- FontDetail 字符预览改为可实时编辑
- 添加 scripts/bench.mjs 压测脚本
This commit is contained in:
崮生(子虚) 2026-08-01 08:03:33 +08:00
parent 1979d7c81e
commit 5a7c6a9913
5 changed files with 195 additions and 23 deletions

View File

@ -1,8 +1,9 @@
import { implInterface } from "../interface"; import { implInterface } from "../interface";
import { stat, readFile, writeFile, readdir, mkdir, unlink } from "fs/promises"; import { stat as fsStat, readFile, writeFile, readdir as fsReaddir, mkdir, unlink } from "fs/promises";
implInterface({ implInterface({
async stat(path) { async stat(path) {
const r = await stat(path); const r = await fsStat(path);
return r; return r;
}, },
readFile(path) { readFile(path) {
@ -11,12 +12,25 @@ implInterface({
writeFile(path, data) { writeFile(path, data) {
return writeFile(path, data); return writeFile(path, data);
}, },
/**
* readdir { name, isFile }
*
* withFileTypesLLRT fs.readdir
* Dirent entry.isFile() TypeError
* stat Node LLRT
*/
async readdir(path) { async readdir(path) {
const entries = await readdir(path, { withFileTypes: true }); const names = await fsReaddir(path);
return entries.map((entry) => ({ const results: { isFile: () => boolean; name: string }[] = [];
isFile: () => entry.isFile(), for (const name of names) {
name: entry.name, try {
})); const s = await fsStat(path + "/" + name);
results.push({ name, isFile: () => s.isFile() });
} catch {
/** stat 失败(符号链接断裂等)跳过 */
}
}
return results;
}, },
async mkdir(path) { async mkdir(path) {
await mkdir(path, { recursive: true }); await mkdir(path, { recursive: true });

View File

@ -38,10 +38,12 @@ async function cleanOnce(): Promise<void> {
let entries: Array<{ name: string; isFile: () => boolean }>; let entries: Array<{ name: string; isFile: () => boolean }>;
try { try {
entries = await readdir(TEMP_DIR); entries = await readdir(TEMP_DIR);
} catch { } catch (err) {
console.log("[temp-cleaner] readdir 失败:", err);
return; return;
} }
let cleaned = 0;
for (const entry of entries) { for (const entry of entries) {
if (!entry.isFile()) continue; if (!entry.isFile()) continue;
if (!/\.(ttf|otf|woff|woff2)$/i.test(entry.name)) continue; if (!/\.(ttf|otf|woff|woff2)$/i.test(entry.name)) continue;
@ -55,12 +57,15 @@ async function cleanOnce(): Promise<void> {
if (now - lastActive > retentionMs) { if (now - lastActive > retentionMs) {
await unlink(filePath); await unlink(filePath);
lastUsedMap.delete(entry.name); lastUsedMap.delete(entry.name);
console.log(`[temp-cleaner] 删除过期临时字体: ${entry.name}`); cleaned++;
} }
} catch { } catch (err) {
/** 文件可能在扫描过程中被删除,忽略 */ console.log("[temp-cleaner] 处理文件失败:", entry.name, err);
} }
} }
if (cleaned > 0) {
console.log(`[temp-cleaner] 本次清理 ${cleaned} 个过期临时字体`);
}
} }
/** 清理周期:保留时限的一半,最少 5 分钟 */ /** 清理周期:保留时限的一半,最少 5 分钟 */

View File

@ -1,7 +1,7 @@
{ {
"name": "webfont", "name": "webfont",
"private": true, "private": true,
"version": "1.11.1", "version": "1.12.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "pnpx tsx scripts/dev-all.ts", "dev": "pnpx tsx scripts/dev-all.ts",

109
scripts/bench.mjs Normal file
View File

@ -0,0 +1,109 @@
/**
* 线上高并发压测脚本 每次请求随机字体+随机文字不命中缓存
*
* 用法
* node scripts/bench.mjs [并发数] [总请求数]
* node scripts/bench.mjs # 默认 20 并发 100 请求
* node scripts/bench.mjs 50 200 # 50 并发 200 请求
* node scripts/bench.mjs 100 200 # 100 并发极限测试
*/
const FONTS = [
"令东齐伋复刻体.ttf",
"霞鹜文楷.ttf",
"得意黑.ttf",
"龙藏体.ttf",
"马善政楷体.ttf",
"钟齐志莽行书.ttf",
"悠哉字体.ttf",
"马克笔哥特体.ttf",
];
const CHARS =
"天地玄黄宇宙洪荒阴阳变化春夏秋冬风花雪月山川河海诗词歌赋琴棋书画梅兰竹菊龙凤呈祥福禄寿喜吉祥如意清风明月高山流水";
const ORIGIN = "https://webfont.shenzilong.cn";
/** 随机生成一条 /api 子集请求 URL字体随机、文字随机 */
function randUrl(): string {
const font = FONTS[Math.floor(Math.random() * FONTS.length)];
/** 随机 2~8 个字 */
const len = 2 + Math.floor(Math.random() * 7);
let text = "";
for (let i = 0; i < len; i++) {
text += CHARS[Math.floor(Math.random() * CHARS.length)];
}
return (
ORIGIN +
"/api?font=" +
encodeURIComponent(font) +
"&text=" +
encodeURIComponent(text) +
"&outType=woff2"
);
}
const concurrency = parseInt(process.argv[2] || "20");
const total = parseInt(process.argv[3] || "100");
/** 单次请求:返回状态码+耗时(ms) */
async function fetchOne(
url: string,
): Promise<{ status: number; ms: number }> {
const t0 = performance.now();
try {
const resp = await fetch(url);
await resp.arrayBuffer();
return { status: resp.status, ms: performance.now() - t0 };
} catch {
return { status: 0, ms: performance.now() - t0 };
}
}
async function main() {
console.log(
`=== ${concurrency} 并发, ${total} 请求, 每次随机字体+文字 ===`,
);
const results: { status: number; ms: number }[] = [];
let nextIdx = 0;
/** worker 线程:循环抢任务执行 */
async function worker() {
while (nextIdx < total) {
nextIdx++;
results.push(await fetchOne(randUrl()));
}
}
const t0 = performance.now();
await Promise.all(
Array.from({ length: concurrency }, () => worker()),
);
const elapsed = (performance.now() - t0) / 1000;
const ok = results.filter((r) => r.status === 200);
const fail = results.filter((r) => r.status !== 200);
const times = ok.map((r) => r.ms).sort((a, b) => a - b);
console.log(`总耗时: ${elapsed.toFixed(1)}s | QPS: ${(total / elapsed).toFixed(1)}`);
console.log(
`成功: ${ok.length}/${total} (${((ok.length / total) * 100).toFixed(0)}%) | 失败: ${fail.length}`,
);
if (times.length) {
const p50 = times[Math.floor(times.length * 0.5)];
const p95 = times[Math.floor(times.length * 0.95)];
const avg = times.reduce((a, b) => a + b, 0) / times.length;
console.log(
`延迟 avg=${avg.toFixed(0)}ms p50=${p50.toFixed(0)}ms p95=${p95.toFixed(0)}ms max=${times[times.length - 1].toFixed(0)}ms`,
);
}
if (fail.length) {
const codes: Record<number, number> = {};
fail.forEach((r) => {
codes[r.status] = (codes[r.status] || 0) + 1;
});
console.log("失败状态码:", codes);
}
}
main();

View File

@ -90,8 +90,21 @@ const previewLines = computed(() => {
}; };
}); });
/** 字符预览行:来自 config.charsetPreview */ /** 字符预览:用户可实时编辑,初始值来自 config.charsetPreview */
const charsetPreview = computed(() => meta.value?.config?.charsetPreview ?? ""); const charsetPreviewText = ref("");
/** charsetPreview 初始值就绪后同步到可编辑 ref */
watchEffect(() => {
const val = meta.value?.config?.charsetPreview ?? "";
if (val && !charsetPreviewText.value) charsetPreviewText.value = val;
});
/**
* 实时编辑用的 textLoader 通过 WebFont SDK 动态更新子集
*
* 用户在字符预览 textarea 中输入文字时
* textLoader.update() 会请求新子集并自动注入 @font-face
*/
let charsetLoader: { update: (text: string) => void; dispose: () => void } | null = null;
/** 所有需要预览的文字合并,用于字体子集加载 */ /** 所有需要预览的文字合并,用于字体子集加载 */
const allPreviewText = computed(() => { const allPreviewText = computed(() => {
@ -124,6 +137,29 @@ watchEffect(() => {
onUnmounted(() => { onUnmounted(() => {
injectedStyle?.remove(); injectedStyle?.remove();
injectedStyle = null; injectedStyle = null;
charsetLoader?.dispose();
charsetLoader = null;
});
/**
* 字符预览实时编辑meta 就绪后初始化 textLoader
* 后续用户编辑时仅调 update增量请求子集
*/
watchEffect(() => {
if (typeof document === "undefined") return;
const font = fontName.value;
const text = charsetPreviewText.value;
if (!font || !text) return;
if (!charsetLoader) {
charsetLoader = (globalThis as any).WebFont?.loadText?.({
fontName: font,
text,
family: font,
outType: "woff2",
}) ?? null;
} else {
charsetLoader.update(text);
}
}); });
/** 使用方法示例代码(动态拼接 origin + fontName */ /** 使用方法示例代码(动态拼接 origin + fontName */
@ -235,9 +271,9 @@ const usageCode = computed(() =>
</p> </p>
</div> </div>
<!-- 字符预览 --> <!-- 字符预览可实时编辑 -->
<div <div
v-if="charsetPreview" v-if="charsetPreviewText || meta"
style=" style="
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
@ -247,19 +283,27 @@ const usageCode = computed(() =>
" "
> >
<div style="font-size: 13px; font-weight: 600; color: #999; margin-bottom: 16px"> <div style="font-size: 13px; font-weight: 600; color: #999; margin-bottom: 16px">
字符预览 字符预览可编辑
</div> </div>
<div <textarea
v-model="charsetPreviewText"
:rows="Math.max(2, Math.min(charsetPreviewText.split('\n').length, 8))"
placeholder="输入文字实时预览字体效果"
:style="{ :style="{
width: '100%',
padding: '8px 12px',
fontFamily: `'${fontName}', monospace`, fontFamily: `'${fontName}', monospace`,
fontSize: '18px', fontSize: '18px',
color: '#555', color: '#555',
lineHeight: 2, lineHeight: '2',
wordBreak: 'break-all', border: '1px solid #e8e8e8',
borderRadius: '8px',
boxSizing: 'border-box',
outline: 'none',
resize: 'vertical',
background: '#fafafa',
}" }"
> />
{{ charsetPreview }}
</div>
</div> </div>
<!-- 字符覆盖率 --> <!-- 字符覆盖率 -->