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.
@@ -0,0 +1,246 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 后台静默校验(Stale-While-Revalidate)
5
+ *
6
+ * 命中缓存时会立刻把本地文件交给 Figma,同时(在有冷却期约束的前提下)
7
+ * 在后台对同一个 URL 发起一次**条件请求**,用返回结果静默更新缓存:
8
+ *
9
+ * 304 Not Modified → 只刷新 validatedAt,传输量约 0.5 KB,几乎零成本
10
+ * 200 OK → 内容真的变了 → 原子替换本地缓存
11
+ * 其它 → 丢弃,绝不动缓存
12
+ *
13
+ * 为什么用条件请求而不是直接重下:
14
+ * Figma 的静态资源每次刷新都要重新下载数十 MB。若每次命中都重下,
15
+ * 流量直接翻倍。带 If-None-Match 之后,绝大多数请求只会产生一个 304。
16
+ *
17
+ * 安全护栏(任何一条不满足都不替换缓存):
18
+ * · 必须是 200 且通过 policy.checkResponse(非 HTML、无 Set-Cookie、非 no-store)
19
+ * · Content-Type 必须与缓存中记录的完全一致
20
+ * —— 这道护栏专门用来挡住「被重定向到登录页/错误页」把缓存写坏
21
+ * · body 非空且不超过上限
22
+ *
23
+ * 所有后台请求都是 fire-and-forget,任何异常都被吞掉,绝不影响前台响应。
24
+ */
25
+
26
+ const http = require('http');
27
+ const https = require('https');
28
+ const zlib = require('zlib');
29
+ const { URL } = require('url');
30
+
31
+ const store = require('./store');
32
+ const policy = require('./policy');
33
+
34
+ const MAX_CONCURRENCY = 4;
35
+ const TIMEOUT_MS = 30000;
36
+ const MAX_BODY_SIZE = 64 * 1024 * 1024;
37
+ const USER_AGENT =
38
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
39
+
40
+ /** 正在校验或排队中的 key,避免同一资源被重复触发 */
41
+ const inflight = new Set();
42
+ const queue = [];
43
+ let running = 0;
44
+
45
+ /**
46
+ * 是否需要为这个条目安排后台校验
47
+ */
48
+ function shouldRevalidate(key, meta, cfg) {
49
+ if (!cfg || cfg.revalidate === -1) {
50
+ return false; // 显式关闭
51
+ }
52
+ if (inflight.has(key)) {
53
+ return false;
54
+ }
55
+ if (key.indexOf('\u0000') !== -1) {
56
+ return false;
57
+ }
58
+ if (cfg.revalidate > 0) {
59
+ const last = Number(meta.validatedAt || meta.createdAt) || 0;
60
+ if (Date.now() - last < cfg.revalidate * 1000) {
61
+ return false; // 还在冷却期内
62
+ }
63
+ }
64
+ return true;
65
+ }
66
+
67
+ /**
68
+ * 安排一次后台校验(非阻塞)
69
+ * @returns {boolean} 是否真的安排了
70
+ */
71
+ function schedule(key, url, meta, cfg) {
72
+ try {
73
+ if (!shouldRevalidate(key, meta, cfg)) {
74
+ return false;
75
+ }
76
+ inflight.add(key);
77
+ queue.push({ key, url, meta, cfg });
78
+ pump();
79
+ return true;
80
+ } catch (e) {
81
+ inflight.delete(key);
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function pump() {
87
+ while (running < MAX_CONCURRENCY && queue.length) {
88
+ const task = queue.shift();
89
+ running += 1;
90
+ runTask(task, () => {
91
+ running -= 1;
92
+ inflight.delete(task.key);
93
+ pump();
94
+ });
95
+ }
96
+ }
97
+
98
+ function decodeBody(buf, contentEncoding) {
99
+ const enc = String(contentEncoding || '').toLowerCase().trim();
100
+ if (!enc || enc === 'identity') {
101
+ return buf;
102
+ }
103
+ try {
104
+ if (enc === 'gzip' || enc === 'x-gzip') {
105
+ return zlib.gunzipSync(buf);
106
+ }
107
+ if (enc === 'deflate') {
108
+ return zlib.inflateSync(buf);
109
+ }
110
+ if (enc === 'br') {
111
+ return zlib.brotliDecompressSync(buf);
112
+ }
113
+ if (enc === 'zstd' && typeof zlib.zstdDecompressSync === 'function') {
114
+ return zlib.zstdDecompressSync(buf);
115
+ }
116
+ } catch (e) {
117
+ return null;
118
+ }
119
+ return null; // 不认识的编码,宁可不缓存
120
+ }
121
+
122
+ function runTask(task, done) {
123
+ let finished = false;
124
+ const finish = () => {
125
+ if (finished) {
126
+ return;
127
+ }
128
+ finished = true;
129
+ done();
130
+ };
131
+
132
+ let u;
133
+ try {
134
+ u = new URL(task.url);
135
+ } catch (e) {
136
+ return finish();
137
+ }
138
+ // 生产路径上只会出现 https(policy 已经卡过),允许 http 仅为了可测
139
+ if (u.protocol !== 'https:' && u.protocol !== 'http:') {
140
+ return finish();
141
+ }
142
+
143
+ const stored = (task.meta && task.meta.headers) || {};
144
+ const headers = {
145
+ // 关键:要求不压缩,这样拿到的字节与缓存里(whistle 解压后)的格式一致
146
+ 'Accept-Encoding': 'identity',
147
+ 'User-Agent': USER_AGENT,
148
+ Accept: '*/*'
149
+ };
150
+ if (stored.etag) {
151
+ headers['If-None-Match'] = stored.etag;
152
+ }
153
+ if (stored['last-modified']) {
154
+ headers['If-Modified-Since'] = stored['last-modified'];
155
+ }
156
+
157
+ let req;
158
+ try {
159
+ const mod = u.protocol === 'http:' ? http : https;
160
+ req = mod.request(
161
+ {
162
+ protocol: u.protocol,
163
+ hostname: u.hostname,
164
+ port: u.port || (u.protocol === 'http:' ? 80 : 443),
165
+ path: u.pathname + (u.search || ''),
166
+ method: 'GET',
167
+ headers,
168
+ // 后台请求直连源站,故意不走 whistle 的规则链,避免自己拦自己
169
+ agent: false
170
+ },
171
+ (res) => {
172
+ const status = res.statusCode || 0;
173
+
174
+ // 304:内容没变,只刷新校验时间
175
+ if (status === 304) {
176
+ res.resume();
177
+ store.touchValidation(task.key);
178
+ return finish();
179
+ }
180
+
181
+ if (status !== 200) {
182
+ res.resume();
183
+ return finish();
184
+ }
185
+
186
+ const chunks = [];
187
+ let size = 0;
188
+ let aborted = false;
189
+ res.on('data', (chunk) => {
190
+ size += chunk.length;
191
+ if (size > MAX_BODY_SIZE) {
192
+ aborted = true;
193
+ res.destroy();
194
+ return;
195
+ }
196
+ chunks.push(chunk);
197
+ });
198
+ res.on('error', () => {
199
+ aborted = true;
200
+ finish();
201
+ });
202
+ res.on('end', () => {
203
+ if (aborted) {
204
+ return finish();
205
+ }
206
+
207
+ const raw = Buffer.concat(chunks);
208
+ const body = decodeBody(raw, res.headers['content-encoding']);
209
+ if (!body || !body.length) {
210
+ return finish();
211
+ }
212
+
213
+ // ── 护栏:任何一条不满足都不动缓存 ──
214
+ if (!policy.checkResponse(status, res.headers).ok) {
215
+ return finish();
216
+ }
217
+ const newType = String(res.headers['content-type'] || '');
218
+ const oldType = String(task.meta.contentType || '');
219
+ if (oldType && newType.split(';')[0].trim() !== oldType.split(';')[0].trim()) {
220
+ // 类型变了 → 很可能是被重定向到了登录页/错误页,宁可不动
221
+ return finish();
222
+ }
223
+
224
+ // 计数由 store.replaceBody / store.touchValidation 内部按 304 / 200 分别累加
225
+ store.replaceBody(task.key, task.url, body, res.headers);
226
+ return finish();
227
+ });
228
+ }
229
+ );
230
+ } catch (e) {
231
+ return finish();
232
+ }
233
+
234
+ req.setTimeout(TIMEOUT_MS, () => {
235
+ try {
236
+ req.destroy();
237
+ } catch (e) {
238
+ /* ignore */
239
+ }
240
+ finish();
241
+ });
242
+ req.on('error', () => finish());
243
+ req.end();
244
+ }
245
+
246
+ module.exports = { schedule, inflight, queue };
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * rulesServer hook —— 对应 whistle 的 REQ_RULES 钩子(请求阶段)
5
+ *
6
+ * whistle 在请求发出前会问我们一句:「这条请求你想加什么规则?」
7
+ * 我们据此给出两种回答:
8
+ *
9
+ * 命中磁盘 → file://<body 文件> 让 whistle 直接从本地回放,零网络
10
+ * 未命中 → resWrite://<body 文件> 让 whistle 照常回源,顺手把响应落盘
11
+ * 不该管 → 返回空字符串 完全不受影响
12
+ *
13
+ * 这里返回的是「规则文本」,由 whistle 自己执行 —— 所以本插件不需要接管请求、
14
+ * 不需要 MITM、不需要独立端口,生命周期天然跟随插件启停。
15
+ *
16
+ * 安全:任何异常都必须返回空字符串(放行),绝不阻断请求。
17
+ */
18
+
19
+ const config = require('./config');
20
+ const policy = require('./policy');
21
+ const store = require('./store');
22
+ const revalidate = require('./revalidate');
23
+
24
+ /**
25
+ * 取本次请求的真实 URL
26
+ * whistle 的 setContext 会把 fullUrl / realUrl 挂在 req 上
27
+ */
28
+ function fullUrlOf(req) {
29
+ const oReq = req.originalReq || {};
30
+ return oReq.realUrl || req.fullUrl || oReq.url || '';
31
+ }
32
+
33
+ function toSlash(p) {
34
+ return String(p).replace(/\\/g, '/');
35
+ }
36
+
37
+ function reply(res, text) {
38
+ const body = Buffer.from(text || '', 'utf8');
39
+ try {
40
+ res.writeHead(200, {
41
+ 'Content-Type': 'text/plain; charset=utf-8',
42
+ 'Content-Length': String(body.length)
43
+ });
44
+ } catch (e) {
45
+ /* ignore */
46
+ }
47
+ res.end(body);
48
+ }
49
+
50
+ /**
51
+ * 命中:让 whistle 用本地文件直接回放。
52
+ * 注意 `* ` 前缀:whistle 的插件钩子返回的是「完整规则行」,
53
+ * 缺少匹配模式时会被当成 pattern 而不是操作符,规则不会生效。
54
+ */
55
+ /**
56
+ * 把存储下来的 Content-Type 映射成 whistle 的 resType 短名。
57
+ * 必须显式指定:因为 whistle 的 file:// 是按「文件扩展名」猜 MIME 的,
58
+ * 而 static.figma.com/uploads/<hash> 这类地址本身没有扩展名,会猜成 text/html。
59
+ * resType 的值不能带空格(规则文本按空白分词),所以只能用它而不是 resHeaders。
60
+ */
61
+ const RESTYPE_BY_CT = [
62
+ [/javascript|ecmascript/i, 'js'],
63
+ [/text\/css/i, 'css'],
64
+ [/json/i, 'json'],
65
+ [/html/i, 'html'],
66
+ [/xml/i, 'xml']
67
+ ];
68
+
69
+ function resTypeOf(contentType) {
70
+ const ct = String(contentType || '');
71
+ for (const [re, type] of RESTYPE_BY_CT) {
72
+ if (re.test(ct)) {
73
+ return type;
74
+ }
75
+ }
76
+ return '';
77
+ }
78
+
79
+ function hitRule(dir, contentType) {
80
+ const rt = resTypeOf(contentType);
81
+ // 传目录(以 / 结尾),让 whistle 自己拼接剩余路径
82
+ const parts = ['*', 'file://' + toSlash(dir) + '/'];
83
+ if (rt) {
84
+ parts.push('resType://' + rt);
85
+ }
86
+ // 关键:让 Chromium 也把这些资源存进它自己的缓存。
87
+ // 否则响应没有 Cache-Control,Chromium 不会缓存 -> 也就不会生成 code cache,
88
+ // 每次加载都要把几十 MB JS/WASM 重新解析编译一遍(实测 5 分钟重写 109MB WASM 缓存)。
89
+ parts.push('cache://31536000');
90
+ return parts.join(' ');
91
+ }
92
+
93
+ /**
94
+ * 未命中:让 whistle 回源并把响应体写到指定文件。
95
+ *
96
+ * 注意两个规则前面的 `* `:whistle 的插件钩子返回的是「完整规则行」,
97
+ * 形如 `模式 操作符://值`。单 token 的行会被当成 pattern(请求 URL 的匹配
98
+ * 表达式)而不是操作符,规则会静默失效 —— 这是踩过的坑。
99
+ * 这里只要一个通配模式就够了,因为插件规则只会合并进当前这一个请求。
100
+ */
101
+ function missRule(dir) {
102
+ // 同样传目录:whistle 会把 URL 的剩余路径接到后面
103
+ return '* resWrite://' + toSlash(dir) + '/';
104
+ }
105
+
106
+ module.exports = (server) => {
107
+ server.on('request', (req, res) => {
108
+ let ruleText = '';
109
+
110
+ try {
111
+ const oReq = req.originalReq || {};
112
+ const url = fullUrlOf(req);
113
+ const cfg = config.resolveConfig(oReq.ruleValue);
114
+ store.use(cfg);
115
+
116
+ // ── 闸门 1:请求形态 ────────────────────────────────────────────
117
+ const method = oReq.method || 'GET';
118
+ const reqHeaders = oReq.headers || req.headers || {};
119
+ if (!policy.isSafeRequest({ method, headers: reqHeaders })) {
120
+ store.bump('bypass');
121
+ return reply(res, '');
122
+ }
123
+
124
+ // ── 闸门 2:URL 白名单 ─────────────────────────────────────────
125
+ const verdict = policy.checkUrl(url);
126
+ if (!verdict.ok) {
127
+ store.bump('bypass');
128
+ config.log(cfg, 'BYPASS(%s) %s', verdict.reason, url);
129
+ return reply(res, '');
130
+ }
131
+
132
+ const key = store.keyOf(url);
133
+
134
+ // ── 命中 ───────────────────────────────────────────────────────
135
+ const hit = store.lookup(key, url, cfg);
136
+ if (hit) {
137
+ // 后台静默校验(非阻塞、有冷却期约束);任何失败都不影响本次响应
138
+ try {
139
+ revalidate.schedule(key, url, hit.meta, cfg);
140
+ } catch (e) {
141
+ /* ignore */
142
+ }
143
+ config.log(cfg, 'HIT %s KB %s', Math.round(hit.size / 1024), url);
144
+ return reply(res, hitRule(hit.dir, hit.meta && hit.meta.contentType));
145
+ }
146
+
147
+ // ── 未命中:登记落盘意向,让 whistle 回源并顺手写盘 ─────────────
148
+ store.bump('miss');
149
+ const pending = store.beginPending(key, url, cfg);
150
+ config.log(cfg, 'MISS %s', url);
151
+ return reply(res, missRule(pending.dir));
152
+ } catch (e) {
153
+ // 出任何问题都放行,绝不阻断
154
+ return reply(res, ruleText);
155
+ }
156
+ });
157
+ };