mirror of
https://github.com/2234839/web-font.git
synced 2026-05-09 05:58:11 +08:00
chore: 升级至 v1.7.0,新增内存缓存与运行时统计
- 新增通用 LruCache 类,支持按条目数/字节容量两种淘汰策略 - 字体裁剪结果 LRU 内存缓存(默认 10MB,X-Cache 响应头标识命中) - 新增 GET /api/stats 运行时统计接口 - 前端统计面板(10s 轮询,页面不可见时暂停) - API handler 拆分到 routes/ 目录,提取 shared.ts 共享模块 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
6e5b2c5fb7
commit
c18dc44e72
@ -58,6 +58,7 @@ services:
|
||||
- TEMP_MAX_FILES=10 # 临时上传最大文件数(默认 10)
|
||||
- TEMP_MAX_TOTAL_SIZE=209715200 # 临时上传目录总体积上限,单位字节(默认 209715200,即 200MB)
|
||||
- ADMIN_API_KEY=你的管理员密钥 # 设置后开启管理员上传,不设置则不可用
|
||||
- SUBSET_CACHE_MAX_SIZE=10485760 # 字体裁剪结果内存缓存容量上限,单位字节(默认 10MB)
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
246
backend/app.ts
246
backend/app.ts
@ -1,17 +1,14 @@
|
||||
/** 解析请求 URL(req.url 只有路径,需要补全协议和主机才能用 URL API) */
|
||||
function parseUrl(req: Request): URL {
|
||||
return new URL(req.url, "http://localhost");
|
||||
}
|
||||
|
||||
import { fontSubset } from "./font_util/font";
|
||||
import type { FontEditor } from "../vendor/fonteditor-core/lib/ttf/font.js";
|
||||
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 { enableTempUpload, adminApiKey, fontDirs } from "./config";
|
||||
import { parseMultipart } from "./multipart";
|
||||
import { handleTempUpload, handleAdminUpload } from "./upload";
|
||||
import { parseUrl, jsonResponse, stats } from "./shared";
|
||||
import { enableTempUpload, adminApiKey } from "./config";
|
||||
import { handleListFonts } from "./routes/fonts";
|
||||
import { handleGetConfig } from "./routes/config";
|
||||
import { handleStats } from "./routes/stats";
|
||||
import { handleUpload } from "./routes/upload";
|
||||
import { handleFontSubset } from "./routes/subset";
|
||||
|
||||
let release_name = globalThis?.process?.release?.name;
|
||||
|
||||
@ -37,65 +34,8 @@ async function ensureDirectories() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在所有字体目录中查找字体文件
|
||||
* 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配
|
||||
* @returns 找到的字体完整路径,未找到则返回 null
|
||||
*/
|
||||
async function findFontPath(filename: string): Promise<string | null> {
|
||||
// 先尝试精确匹配
|
||||
for (const dir of fontDirs) {
|
||||
const filePath = path_join(dir, filename);
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
if (s.isFile()) return filePath;
|
||||
} catch {
|
||||
// 继续搜索
|
||||
}
|
||||
}
|
||||
|
||||
// 收集所有字体文件名(不含扩展名)和完整路径
|
||||
const allFonts: Array<{ basename: string; path: string }> = [];
|
||||
for (const dir of fontDirs) {
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||
allFonts.push({
|
||||
basename: entry.name.replace(/\.[^.]+$/, ""),
|
||||
path: path_join(dir, entry.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不存在,跳过
|
||||
}
|
||||
}
|
||||
|
||||
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
|
||||
|
||||
// 前缀匹配
|
||||
for (const f of allFonts) {
|
||||
if (f.basename.toLowerCase().startsWith(query)) return f.path;
|
||||
}
|
||||
|
||||
// 包含匹配
|
||||
for (const f of allFonts) {
|
||||
if (f.basename.toLowerCase().includes(query)) return f.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** JSON 响应工具 */
|
||||
function jsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const logMiddleware: cMiddleware = async (req, res, next) => {
|
||||
stats.totalRequests++;
|
||||
const t1 = Date.now();
|
||||
const r = await next(req, res);
|
||||
const t2 = Date.now();
|
||||
@ -118,16 +58,16 @@ const staticFileMiddleware: cMiddleware = async function (req, res, next) {
|
||||
return next(req, newRes);
|
||||
}
|
||||
try {
|
||||
const stats = await stat(filePath);
|
||||
const fileStat = await stat(filePath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
if (fileStat.isFile()) {
|
||||
const fileContent = await readFile(filePath);
|
||||
const extname = filePath.split(".").pop() ?? "";
|
||||
newRes = new Response(fileContent, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": mimeTypes[extname] || "application/octet-stream",
|
||||
"Content-Length": `${stats.size}`,
|
||||
"Content-Length": `${fileStat.size}`,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@ -177,167 +117,6 @@ const corsMiddleware: cMiddleware = async (req, res, next) => {
|
||||
}
|
||||
};
|
||||
|
||||
/** GET /api/fonts — 列出所有可用字体 */
|
||||
async function handleListFonts(req: Request, res: Response) {
|
||||
const allFonts: Array<{ name: string; dir: string }> = [];
|
||||
|
||||
for (const dir of fontDirs) {
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||
allFonts.push({ name: entry.name, dir });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 目录不存在,跳过
|
||||
}
|
||||
}
|
||||
|
||||
return { req, res: jsonResponse(allFonts) };
|
||||
}
|
||||
|
||||
/** GET /api/config — 返回公开配置 */
|
||||
async function handleGetConfig(req: Request, res: Response) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({
|
||||
enableTempUpload,
|
||||
adminUploadEnabled: !!adminApiKey,
|
||||
supportedOutTypes: ["woff2", "ttf"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /api/upload?mode=temp|admin — 上传字体 */
|
||||
async function handleUpload(req: Request, res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const mode = url.searchParams.get("mode") ?? "temp";
|
||||
|
||||
const contentType = req.headers.get("Content-Type") ?? "";
|
||||
console.log("[upload] mode:", mode, "contentType:", contentType);
|
||||
|
||||
const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
|
||||
if (!body || body.byteLength === 0) {
|
||||
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
|
||||
}
|
||||
console.log("[upload] body size:", body.byteLength);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseMultipart(contentType, body);
|
||||
console.log("[upload] parsed files:", parsed.files.length);
|
||||
} catch (err) {
|
||||
console.log("[upload] parse error:", err);
|
||||
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
|
||||
}
|
||||
|
||||
if (!parsed.files || parsed.files.length === 0) {
|
||||
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
|
||||
}
|
||||
|
||||
const file = parsed.files[0];
|
||||
console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length);
|
||||
|
||||
let result;
|
||||
if (mode === "admin") {
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const apiKey = authHeader.replace("Bearer ", "");
|
||||
result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
|
||||
console.log("[upload] admin result:", result);
|
||||
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
|
||||
}
|
||||
|
||||
// 默认:临时上传
|
||||
result = await handleTempUpload({ data: file.data, filename: file.filename });
|
||||
console.log("[upload] temp result:", result);
|
||||
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
|
||||
}
|
||||
|
||||
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer */
|
||||
const fontBufferCache = new Map<string, ArrayBuffer>();
|
||||
const FONT_CACHE_MAX = 3;
|
||||
|
||||
/** 从缓存或磁盘读取字体 buffer */
|
||||
async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
|
||||
const cached = fontBufferCache.get(fontPath);
|
||||
if (cached) {
|
||||
/** LRU:命中时移到末尾(最近使用) */
|
||||
fontBufferCache.delete(fontPath);
|
||||
fontBufferCache.set(fontPath, cached);
|
||||
return cached;
|
||||
}
|
||||
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
|
||||
if (fontBufferCache.size >= FONT_CACHE_MAX) {
|
||||
/** 淘汰最久未使用的条目 */
|
||||
const oldest = fontBufferCache.keys().next().value!;
|
||||
fontBufferCache.delete(oldest);
|
||||
}
|
||||
fontBufferCache.set(fontPath, buffer);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
/** GET /api?font=...&text=... — 字体裁剪 */
|
||||
async function handleFontSubset(req: Request, res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const font = params.get("font") || "";
|
||||
const text = params.get("text") || "";
|
||||
if (text.length === 0) {
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
const fontPath = await findFontPath(font);
|
||||
if (!fontPath) {
|
||||
return {
|
||||
req,
|
||||
res: new Response(`Font not found: ${font}`, {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const fontType = fontPath.split(".").pop() as FontEditor.FontType;
|
||||
let oldFontBuffer: ArrayBuffer;
|
||||
try {
|
||||
oldFontBuffer = await readFontBuffer(fontPath);
|
||||
} catch {
|
||||
return {
|
||||
req,
|
||||
res: new Response(`Font read error: ${font}`, {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 默认 ttf(兼容性最好) */
|
||||
const outTypeParam = params.get("outType") || "";
|
||||
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
|
||||
|
||||
const newFont = await fontSubset(oldFontBuffer, text, {
|
||||
outType: outType,
|
||||
sourceType: fontType,
|
||||
});
|
||||
|
||||
const contentTypes: Record<string, string> = {
|
||||
ttf: "font/ttf",
|
||||
woff2: "font/woff2",
|
||||
};
|
||||
|
||||
return {
|
||||
req,
|
||||
res: new Response(newFont, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentTypes[outType] || "font/ttf",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 统一的 API 路由中间件 */
|
||||
const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
||||
const url = parseUrl(req);
|
||||
@ -349,6 +128,9 @@ const fontApiMiddleware: cMiddleware = async (req, res, next) => {
|
||||
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/upload" && req.method === "POST") {
|
||||
return handleUpload(req, res);
|
||||
}
|
||||
|
||||
@ -15,5 +15,8 @@ export const tempMaxFiles = parseInt(env.TEMP_MAX_FILES ?? "10", 10) || 10;
|
||||
/** 临时上传目录总体积上限(字节),默认 200MB */
|
||||
export const tempMaxTotalSize = parseInt(env.TEMP_MAX_TOTAL_SIZE ?? `${200 * 1024 * 1024}`, 10) || 200 * 1024 * 1024;
|
||||
|
||||
/** 字体裁剪结果内存缓存容量上限(字节),默认 10MB */
|
||||
export const subsetCacheMaxSize = parseInt(env.SUBSET_CACHE_MAX_SIZE ?? `${10 * 1024 * 1024}`, 10) || 10 * 1024 * 1024;
|
||||
|
||||
/** 字体搜索目录(按优先级排序:admin > 普通 > 临时) */
|
||||
export const fontDirs = ["font/admin", "font", "font/temp"] as const;
|
||||
|
||||
80
backend/lru_cache.ts
Normal file
80
backend/lru_cache.ts
Normal file
@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 基于 Map 插入顺序的通用 LRU 缓存
|
||||
* 支持两种淘汰策略:按条目数、按字节容量
|
||||
*/
|
||||
export class LruCache<V> {
|
||||
private cache = new Map<string, V>();
|
||||
|
||||
/** 计算条目字节大小(仅按容量淘汰时需要) */
|
||||
private readonly sizeFn?: (value: V) => number;
|
||||
|
||||
/** 最大条目数(按条目淘汰时使用) */
|
||||
private readonly maxSize?: number;
|
||||
|
||||
/** 最大字节容量(按容量淘汰时使用) */
|
||||
private readonly maxBytes?: number;
|
||||
|
||||
/** 当前已用字节 */
|
||||
private usedBytes = 0;
|
||||
|
||||
/** 当前缓存条目数 */
|
||||
get size() {
|
||||
return this.cache.size;
|
||||
}
|
||||
|
||||
constructor(options: { maxSize: number } | { maxBytes: number; sizeFn: (value: V) => number }) {
|
||||
if ("maxSize" in options) {
|
||||
this.maxSize = options.maxSize;
|
||||
} else {
|
||||
this.maxBytes = options.maxBytes;
|
||||
this.sizeFn = options.sizeFn;
|
||||
}
|
||||
}
|
||||
|
||||
/** 获取缓存值,命中时移到末尾(LRU) */
|
||||
get(key: string): V | undefined {
|
||||
const value = this.cache.get(key);
|
||||
if (value === undefined) return undefined;
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 写入缓存,超限时自动淘汰最久未使用的条目 */
|
||||
set(key: string, value: V): void {
|
||||
/** 如果 key 已存在,先移除旧值(更新大小) */
|
||||
const existing = this.cache.get(key);
|
||||
if (existing !== undefined) {
|
||||
this.cache.delete(key);
|
||||
if (this.sizeFn) {
|
||||
this.usedBytes -= this.sizeFn(existing);
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, value);
|
||||
if (this.sizeFn) {
|
||||
this.usedBytes += this.sizeFn(value);
|
||||
this.evictByBytes();
|
||||
} else if (this.maxSize !== undefined) {
|
||||
this.evictByCount();
|
||||
}
|
||||
}
|
||||
|
||||
/** 按条目数淘汰 */
|
||||
private evictByCount() {
|
||||
while (this.cache.size > this.maxSize!) {
|
||||
const oldest = this.cache.keys().next().value!;
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
/** 按字节容量淘汰 */
|
||||
private evictByBytes() {
|
||||
while (this.usedBytes > this.maxBytes! && this.cache.size > 0) {
|
||||
const oldest = this.cache.keys().next().value!;
|
||||
const entry = this.cache.get(oldest)!;
|
||||
this.usedBytes -= this.sizeFn!(entry);
|
||||
this.cache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
backend/routes/config.ts
Normal file
14
backend/routes/config.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { jsonResponse } from "../shared";
|
||||
import { enableTempUpload, adminApiKey } from "../config";
|
||||
|
||||
/** GET /api/config — 返回公开配置 */
|
||||
export async function handleGetConfig(req: Request, res: Response) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({
|
||||
enableTempUpload,
|
||||
adminUploadEnabled: !!adminApiKey,
|
||||
supportedOutTypes: ["woff2", "ttf"],
|
||||
}),
|
||||
};
|
||||
}
|
||||
21
backend/routes/fonts.ts
Normal file
21
backend/routes/fonts.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { jsonResponse, parseUrl } from "../shared";
|
||||
import { readdir, stat } from "../interface";
|
||||
import { fontDirs } from "../config";
|
||||
|
||||
/** GET /api/fonts — 列出所有可用字体 */
|
||||
export async function handleListFonts(req: Request, res: Response) {
|
||||
const allFonts: Array<{ name: string; dir: string }> = [];
|
||||
|
||||
for (const dir of fontDirs) {
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||
allFonts.push({ name: entry.name, dir });
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { req, res: jsonResponse(allFonts) };
|
||||
}
|
||||
17
backend/routes/stats.ts
Normal file
17
backend/routes/stats.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { jsonResponse, stats, subsetCache, fontBufferCache } from "../shared";
|
||||
|
||||
/** GET /api/stats — 返回运行时统计 */
|
||||
export async function handleStats(req: Request, res: Response) {
|
||||
return {
|
||||
req,
|
||||
res: jsonResponse({
|
||||
uptime: Math.floor((Date.now() - stats.startTime) / 1000),
|
||||
totalRequests: stats.totalRequests,
|
||||
subsetRequests: stats.subsetRequests,
|
||||
subsetCacheHits: stats.subsetCacheHits,
|
||||
totalChars: stats.totalChars,
|
||||
subsetCacheEntries: subsetCache.size,
|
||||
fontBufferCacheEntries: fontBufferCache.size,
|
||||
}),
|
||||
};
|
||||
}
|
||||
86
backend/routes/subset.ts
Normal file
86
backend/routes/subset.ts
Normal file
@ -0,0 +1,86 @@
|
||||
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";
|
||||
|
||||
/** GET /api?font=...&text=... — 字体裁剪 */
|
||||
export async function handleFontSubset(req: Request, res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const params = new URLSearchParams(url.search);
|
||||
const font = params.get("font") || "";
|
||||
const text = params.get("text") || "";
|
||||
if (text.length === 0) {
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
const fontPath = await findFontPath(font);
|
||||
if (!fontPath) {
|
||||
return {
|
||||
req,
|
||||
res: new Response(`Font not found: ${font}`, {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 默认 ttf(兼容性最好) */
|
||||
const outTypeParam = params.get("outType") || "";
|
||||
const outType = (outTypeParam === "woff2" || outTypeParam === "ttf") ? outTypeParam : "ttf";
|
||||
|
||||
/** 查询裁剪结果缓存 */
|
||||
const cacheKey = `${fontPath}:${outType}:${text}`;
|
||||
stats.subsetRequests++;
|
||||
stats.totalChars += text.length;
|
||||
const cached = subsetCache.get(cacheKey);
|
||||
if (cached) {
|
||||
stats.subsetCacheHits++;
|
||||
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
|
||||
return {
|
||||
req,
|
||||
res: new Response(cached, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentTypes[outType] || "font/ttf",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Cache": "HIT",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const fontType = fontPath.split(".").pop() as FontEditor.FontType;
|
||||
let oldFontBuffer: ArrayBuffer;
|
||||
try {
|
||||
oldFontBuffer = await readFontBuffer(fontPath);
|
||||
} catch {
|
||||
return {
|
||||
req,
|
||||
res: new Response(`Font read error: ${font}`, {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const newFont = await fontSubset(oldFontBuffer, text, {
|
||||
outType: outType,
|
||||
sourceType: fontType,
|
||||
});
|
||||
|
||||
/** 写入裁剪结果缓存 */
|
||||
subsetCache.set(cacheKey, newFont as ArrayBuffer);
|
||||
|
||||
const contentTypes: Record<string, string> = { ttf: "font/ttf", woff2: "font/woff2" };
|
||||
|
||||
return {
|
||||
req,
|
||||
res: new Response(newFont, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": contentTypes[outType] || "font/ttf",
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
"X-Cache": "MISS",
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
47
backend/routes/upload.ts
Normal file
47
backend/routes/upload.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import { jsonResponse, parseUrl } from "../shared";
|
||||
import { parseMultipart } from "../multipart";
|
||||
import { handleTempUpload, handleAdminUpload } from "../upload";
|
||||
|
||||
/** POST /api/upload?mode=temp|admin — 上传字体 */
|
||||
export async function handleUpload(req: Request, res: Response) {
|
||||
const url = parseUrl(req);
|
||||
const mode = url.searchParams.get("mode") ?? "temp";
|
||||
|
||||
const contentType = req.headers.get("Content-Type") ?? "";
|
||||
console.log("[upload] mode:", mode, "contentType:", contentType);
|
||||
|
||||
const body = (req as Request & { _bodyBuffer?: ArrayBuffer })._bodyBuffer;
|
||||
if (!body || body.byteLength === 0) {
|
||||
return { req, res: jsonResponse({ success: false, error: "请求体为空" }, 400) };
|
||||
}
|
||||
console.log("[upload] body size:", body.byteLength);
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseMultipart(contentType, body);
|
||||
console.log("[upload] parsed files:", parsed.files.length);
|
||||
} catch (err) {
|
||||
console.log("[upload] parse error:", err);
|
||||
return { req, res: jsonResponse({ success: false, error: "无效的 multipart 数据" }, 400) };
|
||||
}
|
||||
|
||||
if (!parsed.files || parsed.files.length === 0) {
|
||||
return { req, res: jsonResponse({ success: false, error: "未提供文件" }, 400) };
|
||||
}
|
||||
|
||||
const file = parsed.files[0];
|
||||
console.log("[upload] file:", file.name, "filename:", file.filename, "data size:", file.data.length);
|
||||
|
||||
let result;
|
||||
if (mode === "admin") {
|
||||
const authHeader = req.headers.get("Authorization") ?? "";
|
||||
const apiKey = authHeader.replace("Bearer ", "");
|
||||
result = await handleAdminUpload({ data: file.data, filename: file.filename }, apiKey);
|
||||
console.log("[upload] admin result:", result);
|
||||
return { req, res: jsonResponse(result, result.success ? 200 : 403) };
|
||||
}
|
||||
|
||||
result = await handleTempUpload({ data: file.data, filename: file.filename });
|
||||
console.log("[upload] temp result:", result);
|
||||
return { req, res: jsonResponse(result, result.success ? 200 : 400) };
|
||||
}
|
||||
86
backend/shared.ts
Normal file
86
backend/shared.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { fontDirs, subsetCacheMaxSize } from "./config";
|
||||
import { LruCache } from "./lru_cache";
|
||||
import { path_join, readFile, stat, readdir } from "./interface";
|
||||
|
||||
/** 解析请求 URL(req.url 只有路径,需要补全协议和主机才能用 URL API) */
|
||||
export function parseUrl(req: Request): URL {
|
||||
return new URL(req.url, "http://localhost");
|
||||
}
|
||||
|
||||
/** JSON 响应工具 */
|
||||
export function jsonResponse(data: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/** 运行时统计 */
|
||||
export const stats = {
|
||||
/** 服务启动时间戳 */
|
||||
startTime: Date.now(),
|
||||
/** 总请求数 */
|
||||
totalRequests: 0,
|
||||
/** 字体裁剪请求次数(含缓存命中) */
|
||||
subsetRequests: 0,
|
||||
/** 字体裁剪缓存命中次数 */
|
||||
subsetCacheHits: 0,
|
||||
/** 累计裁剪文字字符数 */
|
||||
totalChars: 0,
|
||||
};
|
||||
|
||||
/** 字体文件 LRU 缓存,最多保留 3 个最近使用的字体 buffer(按条目数淘汰) */
|
||||
export const fontBufferCache = new LruCache<ArrayBuffer>({ maxSize: 3 });
|
||||
|
||||
/** 字体裁剪结果 LRU 缓存(按字节容量淘汰) */
|
||||
export const subsetCache = new LruCache<ArrayBuffer>({ maxBytes: subsetCacheMaxSize, sizeFn: (v) => v.byteLength });
|
||||
|
||||
/**
|
||||
* 在所有字体目录中查找字体文件
|
||||
* 匹配优先级:精确匹配 > 前缀匹配 > 包含匹配
|
||||
*/
|
||||
export async function findFontPath(filename: string): Promise<string | null> {
|
||||
for (const dir of fontDirs) {
|
||||
const filePath = path_join(dir, filename);
|
||||
try {
|
||||
const s = await stat(filePath);
|
||||
if (s.isFile()) return filePath;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const allFonts: Array<{ basename: string; path: string }> = [];
|
||||
for (const dir of fontDirs) {
|
||||
try {
|
||||
const entries = await readdir(dir);
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && /\.(ttf|otf|woff|woff2)$/i.test(entry.name)) {
|
||||
allFonts.push({
|
||||
basename: entry.name.replace(/\.[^.]+$/, ""),
|
||||
path: path_join(dir, entry.name),
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const query = filename.replace(/\.[^.]+$/, "").toLowerCase();
|
||||
|
||||
for (const f of allFonts) {
|
||||
if (f.basename.toLowerCase().startsWith(query)) return f.path;
|
||||
}
|
||||
|
||||
for (const f of allFonts) {
|
||||
if (f.basename.toLowerCase().includes(query)) return f.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从缓存或磁盘读取字体 buffer */
|
||||
export async function readFontBuffer(fontPath: string): Promise<ArrayBuffer> {
|
||||
const cached = fontBufferCache.get(fontPath);
|
||||
if (cached) return cached;
|
||||
const buffer = new Uint8Array(await readFile(fontPath)).buffer;
|
||||
fontBufferCache.set(fontPath, buffer);
|
||||
return buffer;
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "webfont",
|
||||
"private": true,
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "pnpx tsx scripts/dev-all.ts",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { createMemo, createSignal, createEffect, onMount, Show } from "solid-js";
|
||||
import { fetchFonts, fetchConfig, type FontInfo, type ServerConfig } from "./api";
|
||||
import UploadSection from "./UploadSection";
|
||||
import StatsPanel from "./StatsPanel";
|
||||
import { SelectorRow } from "./FontSelector";
|
||||
import FontDebugPreview from "./FontDebugPreview";
|
||||
|
||||
@ -283,6 +284,8 @@ function App() {
|
||||
|
||||
<UploadSection config={serverConfig()} onUploaded={refreshFonts} />
|
||||
|
||||
<StatsPanel />
|
||||
|
||||
<section style={{ ...s.section, "font-size": "12px", color: "#aaa", "line-height": "1.8" }}>
|
||||
<p><b>原理:</b>服务端根据 text 参数裁剪字体,只返回所需字符的子集。相同 URL 的请求会被浏览器自动缓存。</p>
|
||||
<p><b>基础用法:</b>将 CSS 复制到你的页面,修改 text 参数中的文字即可:</p>
|
||||
|
||||
72
src/StatsPanel.tsx
Normal file
72
src/StatsPanel.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||
import { fetchStats, type ServerStats } from "./api";
|
||||
|
||||
/** 将秒数格式化为可读时长 */
|
||||
function formatUptime(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}分${seconds % 60}秒`;
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = seconds % 60;
|
||||
if (h < 24) return `${h}时${m}分${s}秒`;
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}天${h % 24}时${m}分`;
|
||||
}
|
||||
|
||||
export default function StatsPanel() {
|
||||
const [data, setData] = createSignal<ServerStats | null>(null);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const load = async () => {
|
||||
const s = await fetchStats().catch(() => null);
|
||||
if (s) setData(s);
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
if (timer) return;
|
||||
load();
|
||||
timer = setInterval(load, 10_000);
|
||||
};
|
||||
|
||||
const stopPolling = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
startPolling();
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
stopPolling();
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
});
|
||||
|
||||
const s = data();
|
||||
if (!s) return null;
|
||||
|
||||
const hitRate = s.subsetRequests > 0 ? ((s.subsetCacheHits / s.subsetRequests) * 100).toFixed(1) : "0.0";
|
||||
|
||||
return (
|
||||
<section style={{ "font-size": "12px", color: "#999", "line-height": "1.8", "margin-top": "24px" }}>
|
||||
<div style={{ display: "flex", gap: "16px", flex-wrap: "wrap" }}>
|
||||
<span>运行 {formatUptime(s.uptime)}</span>
|
||||
<span>请求 {s.totalRequests} 次</span>
|
||||
<span>裁剪 {s.subsetRequests} 次</span>
|
||||
<span>文字 {s.totalChars} 字</span>
|
||||
<span>缓存命中 {hitRate}%</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
15
src/api.ts
15
src/api.ts
@ -14,6 +14,16 @@ export interface UploadResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
uptime: number;
|
||||
totalRequests: number;
|
||||
subsetRequests: number;
|
||||
subsetCacheHits: number;
|
||||
totalChars: number;
|
||||
subsetCacheEntries: number;
|
||||
fontBufferCacheEntries: number;
|
||||
}
|
||||
|
||||
export async function fetchFonts(): Promise<FontInfo[]> {
|
||||
const res = await fetch("/api/fonts");
|
||||
return res.json();
|
||||
@ -44,3 +54,8 @@ export async function uploadFont(
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchStats(): Promise<ServerStats> {
|
||||
const res = await fetch("/api/stats");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user