whistle.figma-cache 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +466 -0
- package/LICENSE +21 -0
- package/README.md +145 -0
- package/index.js +40 -0
- package/lib/config.js +158 -0
- package/lib/policy.js +237 -0
- package/lib/resRulesServer.js +80 -0
- package/lib/revalidate.js +246 -0
- package/lib/rulesServer.js +157 -0
- package/lib/store.js +713 -0
- package/lib/uiServer.js +88 -0
- package/package.json +61 -0
- package/public/index.html +341 -0
- package/rules.txt +34 -0
package/lib/store.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 磁盘缓存存储层(规则版 + SWR)
|
|
5
|
+
*
|
|
6
|
+
* 配合 whistle 的 rulesServer / resRulesServer 钩子工作:
|
|
7
|
+
*
|
|
8
|
+
* rulesServer (REQ_RULES) 命中 → 返回 file://… 本地回放,零网络
|
|
9
|
+
* 未命中 → 返回 resWrite://… 回源时顺手落盘
|
|
10
|
+
* resRulesServer (RES_RULES) 确认这是一次合格的 200 响应,给意向盖「可转正」标记
|
|
11
|
+
*
|
|
12
|
+
* 为什么转正要等到「下一次请求」而不是在 RES_RULES 里立刻做:
|
|
13
|
+
* whistle 的 resWrite 是边收边写,RES_RULES 触发时 body 往往还没写完。
|
|
14
|
+
* 所以 RES_RULES 只标记,真正的转正由下一次 lookup() 在确认
|
|
15
|
+
* 「文件存在 + 非空 + mtime 已静默」之后完成。这样既避开时序问题,
|
|
16
|
+
* 也顺带保证不会把半截文件当成完整缓存发出去。
|
|
17
|
+
*
|
|
18
|
+
* body/<哈希前2位>/<sha1>.<nonce>.<ext> 响应体(whistle 解压后写入)
|
|
19
|
+
* meta/<哈希前2位>/<sha1>.json 正式缓存记录
|
|
20
|
+
* pending/<哈希前2位>/<sha1>.json 本次回源的意向(等待下一次请求转正)
|
|
21
|
+
*
|
|
22
|
+
* 全部同步 IO:这是请求路径上的钩子,必须立刻给出结论。
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
const policy = require('./policy');
|
|
30
|
+
|
|
31
|
+
const EXT_BY_URL = [
|
|
32
|
+
[/\.min\.js(?:\.br|\.gz)?$/i, '.js'],
|
|
33
|
+
[/\.min\.css(?:\.br|\.gz)?$/i, '.css'],
|
|
34
|
+
[/\.m?js(?:\.br|\.gz)?$/i, '.js'],
|
|
35
|
+
[/\.css(?:\.br|\.gz)?$/i, '.css'],
|
|
36
|
+
[/\.json(?:\.br|\.gz)?$/i, '.json'],
|
|
37
|
+
[/\.wasm(?:\.br|\.gz)?$/i, '.wasm'],
|
|
38
|
+
[/\.woff2(?:\.br|\.gz)?$/i, '.woff2'],
|
|
39
|
+
[/\.woff(?:\.br|\.gz)?$/i, '.woff'],
|
|
40
|
+
[/\.svg(?:\.br|\.gz)?$/i, '.svg'],
|
|
41
|
+
[/\.png(?:\.br|\.gz)?$/i, '.png']
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** 孤儿 body 宽限期:刚 resWrite 出来、还没转正的先别删 */
|
|
45
|
+
const ORPHAN_GRACE_MS = 10 * 60 * 1000;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 待转正(pending)记录的最长保留时间。
|
|
49
|
+
* 超过就当作废弃意向,连同 body 一起清掉,避免磁盘无界增长。
|
|
50
|
+
*/
|
|
51
|
+
const PENDING_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* body 写盘静默期的默认值。
|
|
55
|
+
* whistle 的 resWrite 是边收边写,所以「文件存在」不等于「写完了」。
|
|
56
|
+
* 用 mtime 距现在的时间做判断:超过这个阈值就认为写流已关闭。
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_BODY_SETTLE_MS = 500;
|
|
59
|
+
|
|
60
|
+
function ensureDirSync(dir) {
|
|
61
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readJsonSafe(file) {
|
|
65
|
+
try {
|
|
66
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeJsonSync(file, obj) {
|
|
73
|
+
ensureDirSync(path.dirname(file));
|
|
74
|
+
const tmp = file + '.tmp-' + process.pid + '-' + Date.now();
|
|
75
|
+
fs.writeFileSync(tmp, JSON.stringify(obj));
|
|
76
|
+
fs.renameSync(tmp, file);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
class Store {
|
|
80
|
+
constructor() {
|
|
81
|
+
this.baseDir = null;
|
|
82
|
+
/** Map<key, {size, atime, ctime, bodyFile}> */
|
|
83
|
+
this.index = new Map();
|
|
84
|
+
this.totalSize = 0;
|
|
85
|
+
this.counters = {
|
|
86
|
+
hit: 0,
|
|
87
|
+
miss: 0,
|
|
88
|
+
bypass: 0,
|
|
89
|
+
stored: 0,
|
|
90
|
+
// 后台静默校验:分开统计,便于看清它到底花了多少钱
|
|
91
|
+
revalidated304: 0, // 内容未变,只刷新校验时间(body 0 字节)
|
|
92
|
+
revalidated200: 0, // 内容真的变了,已原子替换
|
|
93
|
+
revalidateBytes: 0, // 后台校验累计下载字节(仅 body)
|
|
94
|
+
healed: 0,
|
|
95
|
+
evicted: 0,
|
|
96
|
+
bytesServed: 0
|
|
97
|
+
};
|
|
98
|
+
this._statsTimer = null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 路径 ────────────────────────────────────────────────────────────────
|
|
102
|
+
keyOf(url) {
|
|
103
|
+
return crypto.createHash('sha1').update(String(url)).digest('hex');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 从 URL 推导扩展名。带扩展名的直接用;
|
|
108
|
+
* static.figma.com/uploads/<hash> 这种没有扩展名的出现在 Figma 的
|
|
109
|
+
* CSP script-src 里,就是 JS。
|
|
110
|
+
*/
|
|
111
|
+
extFor(url) {
|
|
112
|
+
let pathname = '';
|
|
113
|
+
try {
|
|
114
|
+
pathname = new URL(url).pathname;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
return '.js';
|
|
117
|
+
}
|
|
118
|
+
for (const [re, ext] of EXT_BY_URL) {
|
|
119
|
+
if (re.test(pathname)) {
|
|
120
|
+
return ext;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return '.js';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
shard(key) {
|
|
127
|
+
return path.join(this.baseDir, String(key).slice(0, 2));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 每个 URL 一个独立目录。
|
|
132
|
+
*
|
|
133
|
+
* whistle 的 file:// / resWrite:// 会自动把「匹配模式之后的剩余路径」拼到
|
|
134
|
+
* 给定路径后面,所以必须传目录(以 / 结尾)让它自己拼,否则会出现
|
|
135
|
+
* .../<key>.js/uploads/0706b... 这种叠加出来的多层路径。
|
|
136
|
+
*/
|
|
137
|
+
bodyDirFor(key) {
|
|
138
|
+
return path.join(this.shard(key), 'body', key);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 落盘时把路径转成相对 baseDir 的形式。
|
|
143
|
+
*
|
|
144
|
+
* meta / pending 里存相对路径,整个 data/cache 目录就可以整体搬迁(换盘符、
|
|
145
|
+
* 挪插件目录)而不会让已有缓存失效。不在 baseDir 下的路径理论上不会出现,
|
|
146
|
+
* 一旦出现就保持原样,不做猜测。
|
|
147
|
+
*/
|
|
148
|
+
_toRel(p) {
|
|
149
|
+
if (!p || !path.isAbsolute(p)) {
|
|
150
|
+
return p; // 已经是相对路径
|
|
151
|
+
}
|
|
152
|
+
const rel = path.relative(this.baseDir, p);
|
|
153
|
+
return rel && rel.indexOf('..') !== 0 ? rel : p;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 相对路径还原为绝对路径;兼容历史遗留的绝对路径记录 */
|
|
157
|
+
_toAbs(p) {
|
|
158
|
+
if (!p) {
|
|
159
|
+
return p;
|
|
160
|
+
}
|
|
161
|
+
return path.isAbsolute(p) ? p : path.resolve(this.baseDir, p);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** URL 的剩余路径(去掉前导 /),也就是 whistle 自动拼接的那段 */
|
|
165
|
+
relPathFor(url) {
|
|
166
|
+
try {
|
|
167
|
+
return new URL(url).pathname.replace(/^\/+/, '');
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return this.keyOf(url);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
metaPath(key) {
|
|
174
|
+
return path.join(this.shard(key), 'meta', key + '.json');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
pendingPath(key) {
|
|
178
|
+
return path.join(this.shard(key), 'pending', key + '.json');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── 初始化 ──────────────────────────────────────────────────────────────
|
|
182
|
+
use(cfg) {
|
|
183
|
+
if (this.baseDir === cfg.dir) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
this.baseDir = cfg.dir;
|
|
187
|
+
this.index = new Map();
|
|
188
|
+
this.totalSize = 0;
|
|
189
|
+
ensureDirSync(this.baseDir);
|
|
190
|
+
this._buildIndexSync();
|
|
191
|
+
this._startStatsTimer();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
_buildIndexSync() {
|
|
195
|
+
let shards;
|
|
196
|
+
try {
|
|
197
|
+
shards = fs.readdirSync(this.baseDir);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
for (const shard of shards) {
|
|
202
|
+
const metaDir = path.join(this.baseDir, shard, 'meta');
|
|
203
|
+
let files;
|
|
204
|
+
try {
|
|
205
|
+
files = fs.readdirSync(metaDir);
|
|
206
|
+
} catch (e) {
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
for (const file of files) {
|
|
210
|
+
if (!file.endsWith('.json')) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const meta = readJsonSafe(path.join(metaDir, file));
|
|
214
|
+
if (!meta || !meta.bodyFile) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
let size = 0;
|
|
218
|
+
try {
|
|
219
|
+
size = fs.statSync(this._toAbs(meta.bodyFile)).size;
|
|
220
|
+
} catch (e) {
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const key = file.slice(0, -5);
|
|
224
|
+
this.index.set(key, {
|
|
225
|
+
size,
|
|
226
|
+
ctime: Number(meta.createdAt) || Date.now(),
|
|
227
|
+
atime: Number(meta.lastHitAt) || Date.now()
|
|
228
|
+
});
|
|
229
|
+
this.totalSize += size;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
_startStatsTimer() {
|
|
235
|
+
if (this._statsTimer) {
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this._statsTimer = setInterval(() => {
|
|
239
|
+
try {
|
|
240
|
+
this.persistStats();
|
|
241
|
+
} catch (e) {
|
|
242
|
+
/* ignore */
|
|
243
|
+
}
|
|
244
|
+
}, 60000);
|
|
245
|
+
if (this._statsTimer.unref) {
|
|
246
|
+
this._statsTimer.unref();
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── 命中判定 ────────────────────────────────────────────────────────────
|
|
251
|
+
/**
|
|
252
|
+
* @returns {{bodyFile:string, ext:string, meta:object, size:number}|null}
|
|
253
|
+
*/
|
|
254
|
+
lookup(key, url, cfg) {
|
|
255
|
+
// ① 已有正式记录
|
|
256
|
+
const meta = readJsonSafe(this.metaPath(key));
|
|
257
|
+
if (meta) {
|
|
258
|
+
const usable = meta.status === 200 && meta.bodyFile;
|
|
259
|
+
const okTtl =
|
|
260
|
+
cfg.ttl <= 0 || Date.now() - (Number(meta.createdAt) || 0) <= cfg.ttl * 1000;
|
|
261
|
+
let stat = null;
|
|
262
|
+
if (usable && okTtl) {
|
|
263
|
+
try {
|
|
264
|
+
stat = fs.statSync(this._toAbs(meta.bodyFile));
|
|
265
|
+
} catch (e) {
|
|
266
|
+
stat = null;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (stat && stat.isFile() && stat.size > 0) {
|
|
270
|
+
meta.lastHitAt = Date.now();
|
|
271
|
+
const entry = this.index.get(key);
|
|
272
|
+
if (entry) {
|
|
273
|
+
entry.atime = Date.now();
|
|
274
|
+
if (entry.size !== stat.size) {
|
|
275
|
+
this.totalSize -= entry.size;
|
|
276
|
+
entry.size = stat.size;
|
|
277
|
+
this.totalSize += stat.size;
|
|
278
|
+
this.counters.healed += 1; // 大小被外部改过 → 已修正
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
writeJsonSync(this.metaPath(key), meta);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
/* 统计信息写失败不影响命中 */
|
|
285
|
+
}
|
|
286
|
+
this.counters.hit += 1;
|
|
287
|
+
this.counters.bytesServed += stat.size;
|
|
288
|
+
return {
|
|
289
|
+
dir: this._toAbs(meta.dir),
|
|
290
|
+
bodyFile: this._toAbs(meta.bodyFile),
|
|
291
|
+
ext: meta.ext || this.extFor(url),
|
|
292
|
+
meta,
|
|
293
|
+
size: stat.size
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
this.forget(key);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ② 尝试把「上回已经回源完成」的意向转正
|
|
300
|
+
return this._tryPromote(key, cfg);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* 把 pending 提升为正式缓存条目。必须同时满足:
|
|
305
|
+
* · RES_RULES 已确认这是一次合格的 200 响应(pending.commit)
|
|
306
|
+
* · body 文件存在且非空
|
|
307
|
+
* · body 的 mtime 已静默超过 BODY_SETTLE_MS(写流已关闭,不会读到半截)
|
|
308
|
+
*/
|
|
309
|
+
_tryPromote(key, cfg) {
|
|
310
|
+
const pending = readJsonSafe(this.pendingPath(key));
|
|
311
|
+
if (!pending || !pending.commit || !pending.bodyFile) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let stat;
|
|
316
|
+
try {
|
|
317
|
+
stat = fs.statSync(this._toAbs(pending.bodyFile));
|
|
318
|
+
} catch (e) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
if (!stat.isFile() || stat.size <= 0) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
// 注意:Date.now() 是整数毫秒,而 stat.mtimeMs 带小数,两者相减可能得负数。
|
|
325
|
+
// 所以 settleMs 为 0 时必须直接跳过检查,不能写成 age < settleMs(-0.7 < 0 会误判)。
|
|
326
|
+
const settleMs = cfg.bodySettleMs != null ? cfg.bodySettleMs : DEFAULT_BODY_SETTLE_MS;
|
|
327
|
+
if (settleMs > 0 && Date.now() - stat.mtimeMs < settleMs) {
|
|
328
|
+
return null; // 写流可能还没关闭,再等下一轮
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const now = Date.now();
|
|
332
|
+
const meta = {
|
|
333
|
+
url: pending.url,
|
|
334
|
+
ext: pending.ext,
|
|
335
|
+
dir: pending.dir,
|
|
336
|
+
bodyFile: pending.bodyFile,
|
|
337
|
+
status: 200,
|
|
338
|
+
size: stat.size,
|
|
339
|
+
headers: pending.headers || {},
|
|
340
|
+
contentType: pending.contentType || '',
|
|
341
|
+
createdAt: Number(pending.at) || now,
|
|
342
|
+
lastHitAt: now,
|
|
343
|
+
validatedAt: now,
|
|
344
|
+
hits: 0
|
|
345
|
+
};
|
|
346
|
+
writeJsonSync(this.metaPath(key), meta);
|
|
347
|
+
this.dropPending(key);
|
|
348
|
+
|
|
349
|
+
const prev = this.index.get(key);
|
|
350
|
+
if (prev) {
|
|
351
|
+
this.totalSize -= prev.size;
|
|
352
|
+
}
|
|
353
|
+
this.index.set(key, {
|
|
354
|
+
size: stat.size,
|
|
355
|
+
ctime: meta.createdAt,
|
|
356
|
+
atime: now,
|
|
357
|
+
bodyFile: meta.bodyFile
|
|
358
|
+
});
|
|
359
|
+
this.totalSize += stat.size;
|
|
360
|
+
this.counters.stored += 1;
|
|
361
|
+
this.sweep(cfg);
|
|
362
|
+
|
|
363
|
+
this.counters.hit += 1;
|
|
364
|
+
this.counters.bytesServed += stat.size;
|
|
365
|
+
return {
|
|
366
|
+
dir: this._toAbs(meta.dir),
|
|
367
|
+
bodyFile: this._toAbs(meta.bodyFile),
|
|
368
|
+
ext: meta.ext,
|
|
369
|
+
meta,
|
|
370
|
+
size: stat.size
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ── 落盘意向 ────────────────────────────────────────────────────────────
|
|
375
|
+
newNonce() {
|
|
376
|
+
return Date.now().toString(36) + '-' + crypto.randomBytes(3).toString('hex');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 未命中时调用:算出目录与最终落盘路径,并记下意向
|
|
381
|
+
* @returns {{dir:string, bodyFile:string, ext:string}}
|
|
382
|
+
*/
|
|
383
|
+
beginPending(key, url, cfg) {
|
|
384
|
+
const ext = this.extFor(url);
|
|
385
|
+
const relPath = this.relPathFor(url);
|
|
386
|
+
const dir = this.bodyDirFor(key);
|
|
387
|
+
const bodyFile = path.join(dir, relPath);
|
|
388
|
+
ensureDirSync(dir);
|
|
389
|
+
writeJsonSync(this.pendingPath(key), {
|
|
390
|
+
url,
|
|
391
|
+
ext,
|
|
392
|
+
// 存相对路径(见 _toRel)
|
|
393
|
+
dir: this._toRel(dir),
|
|
394
|
+
relPath,
|
|
395
|
+
bodyFile: this._toRel(bodyFile),
|
|
396
|
+
at: Date.now()
|
|
397
|
+
});
|
|
398
|
+
// 对外仍然返回绝对路径:whistle 的 resWrite 规则需要真实路径
|
|
399
|
+
return { dir, bodyFile, ext };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
hasPending(key) {
|
|
403
|
+
try {
|
|
404
|
+
return fs.statSync(this.pendingPath(key)).isFile();
|
|
405
|
+
} catch (e) {
|
|
406
|
+
return false;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* RES_RULES 阶段调用:确认这是一次合格的 200 响应,给意向盖上「可转正」标记。
|
|
412
|
+
* 此刻 body 往往还没写完,所以这里不生成 meta。
|
|
413
|
+
* @returns {{ok:boolean, reason:string}}
|
|
414
|
+
*/
|
|
415
|
+
commitPending(key, status, resHeaders, cfg) {
|
|
416
|
+
const pending = readJsonSafe(this.pendingPath(key));
|
|
417
|
+
if (!pending) {
|
|
418
|
+
return { ok: false, reason: 'no-pending' };
|
|
419
|
+
}
|
|
420
|
+
if (status !== 200) {
|
|
421
|
+
this.dropPending(key);
|
|
422
|
+
return { ok: false, reason: 'status-' + status };
|
|
423
|
+
}
|
|
424
|
+
const check = policy.checkResponse(status, resHeaders);
|
|
425
|
+
if (!check.ok) {
|
|
426
|
+
this.dropPending(key);
|
|
427
|
+
return { ok: false, reason: check.reason };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const headers = policy.pickStoreHeaders(resHeaders, 0);
|
|
431
|
+
delete headers['content-length']; // 回放时由 file:// 自己算
|
|
432
|
+
|
|
433
|
+
pending.commit = Date.now();
|
|
434
|
+
pending.headers = headers;
|
|
435
|
+
pending.contentType = resHeaders['content-type'] || '';
|
|
436
|
+
writeJsonSync(this.pendingPath(key), pending);
|
|
437
|
+
return { ok: true, reason: 'committed' };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
dropPending(key) {
|
|
441
|
+
try {
|
|
442
|
+
fs.unlinkSync(this.pendingPath(key));
|
|
443
|
+
} catch (e) {
|
|
444
|
+
/* ignore */
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── SWR:后台校验后的落盘 ───────────────────────────────────────────────
|
|
449
|
+
/**
|
|
450
|
+
* 条件请求返回 304:只刷新「已验证时间」
|
|
451
|
+
*/
|
|
452
|
+
touchValidation(key) {
|
|
453
|
+
const meta = readJsonSafe(this.metaPath(key));
|
|
454
|
+
if (!meta) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
meta.validatedAt = Date.now();
|
|
458
|
+
try {
|
|
459
|
+
writeJsonSync(this.metaPath(key), meta);
|
|
460
|
+
this.counters.revalidated304 += 1;
|
|
461
|
+
return true;
|
|
462
|
+
} catch (e) {
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* 条件请求返回 200:原子替换 body 与 meta
|
|
469
|
+
* 先写临时文件再 rename,保证读者永远看到完整内容
|
|
470
|
+
* @returns {{ok:boolean, reason:string, size?:number}}
|
|
471
|
+
*/
|
|
472
|
+
replaceBody(key, url, buf, resHeaders) {
|
|
473
|
+
const meta = readJsonSafe(this.metaPath(key));
|
|
474
|
+
if (!meta || !meta.bodyFile) {
|
|
475
|
+
return { ok: false, reason: 'no-meta' };
|
|
476
|
+
}
|
|
477
|
+
if (!buf || !buf.length) {
|
|
478
|
+
return { ok: false, reason: 'empty-body' };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const bodyAbs = this._toAbs(meta.bodyFile);
|
|
482
|
+
const dir = path.dirname(bodyAbs);
|
|
483
|
+
const tmp = path.join(dir, '.' + key + '.new-' + Date.now());
|
|
484
|
+
try {
|
|
485
|
+
ensureDirSync(dir);
|
|
486
|
+
fs.writeFileSync(tmp, buf);
|
|
487
|
+
// 同目录 rename:Windows 上 libuv 以 FILE_SHARE_DELETE 打开,允许覆盖
|
|
488
|
+
fs.renameSync(tmp, bodyAbs);
|
|
489
|
+
} catch (e) {
|
|
490
|
+
try {
|
|
491
|
+
fs.unlinkSync(tmp);
|
|
492
|
+
} catch (e2) {
|
|
493
|
+
/* ignore */
|
|
494
|
+
}
|
|
495
|
+
return { ok: false, reason: 'rename-failed' };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const headers = policy.pickStoreHeaders(resHeaders, buf.length);
|
|
499
|
+
delete headers['content-length'];
|
|
500
|
+
|
|
501
|
+
const next = Object.assign({}, meta, {
|
|
502
|
+
size: buf.length,
|
|
503
|
+
headers,
|
|
504
|
+
contentType: resHeaders['content-type'] || meta.contentType,
|
|
505
|
+
lastHitAt: Date.now(),
|
|
506
|
+
validatedAt: Date.now(),
|
|
507
|
+
updatedAt: Date.now()
|
|
508
|
+
});
|
|
509
|
+
try {
|
|
510
|
+
writeJsonSync(this.metaPath(key), next);
|
|
511
|
+
} catch (e) {
|
|
512
|
+
return { ok: false, reason: 'meta-write-failed' };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const entry = this.index.get(key);
|
|
516
|
+
if (entry) {
|
|
517
|
+
this.totalSize -= entry.size;
|
|
518
|
+
entry.size = buf.length;
|
|
519
|
+
entry.atime = Date.now();
|
|
520
|
+
this.totalSize += buf.length;
|
|
521
|
+
}
|
|
522
|
+
this.counters.revalidated200 += 1;
|
|
523
|
+
this.counters.revalidateBytes += buf.length;
|
|
524
|
+
return { ok: true, reason: 'replaced', size: buf.length };
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// ── 淘汰 ────────────────────────────────────────────────────────────────
|
|
528
|
+
forget(key) {
|
|
529
|
+
const entry = this.index.get(key);
|
|
530
|
+
if (entry) {
|
|
531
|
+
this.totalSize -= entry.size;
|
|
532
|
+
this.index.delete(key);
|
|
533
|
+
}
|
|
534
|
+
// body 现在是「一个 URL 一个目录」,整目录删掉
|
|
535
|
+
try {
|
|
536
|
+
fs.rmSync(this.bodyDirFor(key), { recursive: true, force: true });
|
|
537
|
+
} catch (e) {
|
|
538
|
+
/* ignore */
|
|
539
|
+
}
|
|
540
|
+
try {
|
|
541
|
+
fs.unlinkSync(this.metaPath(key));
|
|
542
|
+
} catch (e) {
|
|
543
|
+
/* ignore */
|
|
544
|
+
}
|
|
545
|
+
this.dropPending(key);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
sweep(cfg) {
|
|
549
|
+
const limit = cfg.maxSize * 1024 * 1024;
|
|
550
|
+
if (this.totalSize > limit) {
|
|
551
|
+
const target = limit * 0.9;
|
|
552
|
+
const items = Array.from(this.index.entries())
|
|
553
|
+
.map(([key, val]) => ({ key, atime: val.atime, size: val.size }))
|
|
554
|
+
.sort((a, b) => a.atime - b.atime);
|
|
555
|
+
for (const item of items) {
|
|
556
|
+
if (this.totalSize <= target) {
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
this.forget(item.key);
|
|
560
|
+
this.counters.evicted += 1;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
this._sweepOrphans();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/**
|
|
567
|
+
* 清理孤儿 body 目录。
|
|
568
|
+
*
|
|
569
|
+
* 【关键】不能只按 index 判断:index 里只有「已转正」的条目,而刚落盘、
|
|
570
|
+
* 等着下一次请求转正的 body 并不在 index 里。如果把它们当孤儿删了,
|
|
571
|
+
* 下次请求就没东西可转正,只能重新下载 —— 缓存永远建不起来。
|
|
572
|
+
*
|
|
573
|
+
* 所以:有 pending 意向的一律保留;同时清理超过保留期(默认 7 天)
|
|
574
|
+
* 的废弃意向,防止磁盘无界增长。
|
|
575
|
+
*/
|
|
576
|
+
_sweepOrphans() {
|
|
577
|
+
const now = Date.now();
|
|
578
|
+
let shards;
|
|
579
|
+
try {
|
|
580
|
+
shards = fs.readdirSync(this.baseDir);
|
|
581
|
+
} catch (e) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
for (const shard of shards) {
|
|
585
|
+
const bodyRoot = path.join(this.baseDir, shard, 'body');
|
|
586
|
+
let dirs;
|
|
587
|
+
try {
|
|
588
|
+
dirs = fs.readdirSync(bodyRoot);
|
|
589
|
+
} catch (e) {
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
for (const name of dirs) {
|
|
593
|
+
if (this.index.has(name)) {
|
|
594
|
+
continue; // 已转正
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const pending = readJsonSafe(this.pendingPath(name));
|
|
598
|
+
if (pending) {
|
|
599
|
+
const age = now - (Number(pending.at) || 0);
|
|
600
|
+
if (age < PENDING_MAX_AGE_MS) {
|
|
601
|
+
continue; // 等着转正,不能删
|
|
602
|
+
}
|
|
603
|
+
// 意向已过期 → 连同 body 一起清掉
|
|
604
|
+
this.forget(name);
|
|
605
|
+
this.counters.evicted += 1;
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const full = path.join(bodyRoot, name);
|
|
610
|
+
try {
|
|
611
|
+
if (now - fs.statSync(full).mtimeMs > ORPHAN_GRACE_MS) {
|
|
612
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
613
|
+
}
|
|
614
|
+
} catch (e) {
|
|
615
|
+
/* ignore */
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── 统计 / 维护 ─────────────────────────────────────────────────────────
|
|
622
|
+
bump(name) {
|
|
623
|
+
if (this.counters[name] != null) {
|
|
624
|
+
this.counters[name] += 1;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** 累加型计数器(字节数等) */
|
|
629
|
+
bumpBytes(name, n) {
|
|
630
|
+
if (this.counters[name] != null && isFinite(n)) {
|
|
631
|
+
this.counters[name] += n;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
getStats() {
|
|
636
|
+
return {
|
|
637
|
+
dir: this.baseDir,
|
|
638
|
+
files: this.index.size,
|
|
639
|
+
totalBytes: this.totalSize,
|
|
640
|
+
totalMB: Math.round((this.totalSize / 1048576) * 10) / 10,
|
|
641
|
+
counters: Object.assign({}, this.counters)
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
list(limit) {
|
|
646
|
+
const max = Math.min(Number(limit) || 100, 1000);
|
|
647
|
+
const out = [];
|
|
648
|
+
for (const [key, entry] of this.index) {
|
|
649
|
+
const meta = readJsonSafe(this.metaPath(key));
|
|
650
|
+
if (!meta) {
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
out.push({
|
|
654
|
+
key,
|
|
655
|
+
url: meta.url,
|
|
656
|
+
size: entry.size,
|
|
657
|
+
contentType: meta.contentType,
|
|
658
|
+
createdAt: Number(meta.createdAt) || 0,
|
|
659
|
+
lastHitAt: Number(meta.lastHitAt) || 0,
|
|
660
|
+
validatedAt: Number(meta.validatedAt) || 0
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
out.sort((a, b) => b.size - a.size);
|
|
664
|
+
return out.slice(0, max);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
clear() {
|
|
668
|
+
const base = this.baseDir;
|
|
669
|
+
this.index = new Map();
|
|
670
|
+
this.totalSize = 0;
|
|
671
|
+
if (!base) {
|
|
672
|
+
return true;
|
|
673
|
+
}
|
|
674
|
+
let shards = [];
|
|
675
|
+
try {
|
|
676
|
+
shards = fs.readdirSync(base);
|
|
677
|
+
} catch (e) {
|
|
678
|
+
/* ignore */
|
|
679
|
+
}
|
|
680
|
+
for (const shard of shards) {
|
|
681
|
+
for (const kind of ['body', 'meta', 'pending']) {
|
|
682
|
+
try {
|
|
683
|
+
fs.rmSync(path.join(base, shard, kind), { recursive: true, force: true });
|
|
684
|
+
} catch (e) {
|
|
685
|
+
/* ignore */
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
this.counters.evicted = 0;
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
persistStats() {
|
|
694
|
+
if (!this.baseDir) {
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
try {
|
|
698
|
+
fs.writeFileSync(
|
|
699
|
+
path.join(this.baseDir, 'stats.json'),
|
|
700
|
+
JSON.stringify({
|
|
701
|
+
updatedAt: Date.now(),
|
|
702
|
+
counters: this.counters,
|
|
703
|
+
files: this.index.size,
|
|
704
|
+
totalBytes: this.totalSize
|
|
705
|
+
})
|
|
706
|
+
);
|
|
707
|
+
} catch (e) {
|
|
708
|
+
/* ignore */
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
module.exports = new Store();
|