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 ADDED
@@ -0,0 +1,466 @@
1
+ # AGENTS.md — 实现细节与维护手册
2
+
3
+ > 面向后续接手的 AI / 开发者。README 只讲「是什么、怎么用」,这里讲「为什么这么写、坑在哪」。
4
+ >
5
+ > **改动本插件前请先通读本文**,尤其是第 3 节的五个坑和第 4 节的时序模型 ——
6
+ > 它们都是实测踩出来的,凭直觉改会**静默失效**。
7
+
8
+ ---
9
+
10
+ ## 1. 项目定位与文件地图
11
+
12
+ 给 Figma 桌面客户端做静态资源磁盘缓存。核心目标只有一个:
13
+
14
+ > **把每次刷新重复下载的编辑器资源,换成一次本地磁盘读取。**
15
+
16
+ ```
17
+ whistle-figma-cache/
18
+ ├── index.js 入口:导出 3 个 whistle 钩子
19
+ ├── rules.txt 内置规则(插件启用时自动加载)
20
+ ├── package.json whistleConfig / test 脚本
21
+ ├── lib/
22
+ │ ├── policy.js ★ 安全白名单 + 各类闸门(最重要的文件)
23
+ │ ├── rulesServer.js REQ_RULES 钩子:命中回放 / 未命中落盘
24
+ │ ├── resRulesServer.js RES_RULES 钩子:给落盘意向盖章
25
+ │ ├── revalidate.js SWR 后台静默校验(条件请求)
26
+ │ ├── store.js ★ 磁盘缓存 + 时序模型 + LRU
27
+ │ ├── config.js 参数解析与缓存
28
+ │ └── uiServer.js 状态页接口
29
+ ├── public/index.html 状态页(两页:使用方式 / 数据)
30
+ ├── test/ 4 个测试文件,126 项断言
31
+ ├── legacy/ 早期 server hook 尝试(whistle 不派发该钩子,仅留存参考)
32
+ └── data/cache/ 运行期生成
33
+ ```
34
+
35
+ **依赖**:零运行时依赖(只用 Node 内置模块)。`legacy/` 不纳入 `npm test`。
36
+
37
+ ---
38
+
39
+ ## 2. 架构:为什么用「规则钩子」而不是「接管请求」
40
+
41
+ ### 2.1 两条路都试过
42
+
43
+ | 方案 | 做法 | 结果 |
44
+ |---|---|---|
45
+ | **server hook**(接管请求) | `exports.server`,插件自己查缓存 / 回源 | ❌ 不被派发 |
46
+ | **规则钩子**(当前方案) | `exports.rulesServer` + `resRulesServer` | ✅ 实测可用 |
47
+
48
+ ### 2.2 server hook 为什么不行(已用对照实验证明)
49
+
50
+ `lib/handlers/http-proxy.js` 是 `server` hook 的唯一入口:
51
+
52
+ ```js
53
+ var protocol = req.options && req.options.protocol;
54
+ var plugin = !req.isWebProtocol && pluginMgr.getPlugin(protocol);
55
+ ```
56
+
57
+ 它要求 `req.options.protocol === '<插件名>:'`。而规则 `pattern whistle.<插件名>://` 走的是
58
+ `resolveWhistlePlugins` → `req.whistlePlugins`,**只喂给 REQ_RULES / RES_RULES / stats 钩子,不碰 server**。
59
+
60
+ **证据**(在 whistle 2.10.10 上实测):
61
+
62
+ 1. 用 `/cgi-bin/rules/project` 注入 `* statusCode://418` → 返回 418,说明规则注入机制是活的;
63
+ 2. 注入 `whistle.<插件名>://` → 插件计数器始终不动;
64
+ 3. 换成 `rule` 协议形式 `//<插件名>://` → 返回 **502 Unsupported protocol**,
65
+ 说明请求走到了 `http-proxy.js`,但 `allPlugins['<插件名>:']` 查不到。
66
+
67
+ **结论:该版本的 whistle 不把 URL 规则派发到 `server` hook。**
68
+
69
+ > `legacy/server-hook.js` 保留了那版实现。如果日后 whistle 修复了派发,
70
+ > 取消 `index.js` 里对应导出的注释即可启用。
71
+
72
+ ### 2.3 当前方案的完整链路
73
+
74
+ ```
75
+ Figma 请求 www.figma.com/webpack-artifacts/assets/xxx.min.js.br
76
+
77
+ ├─ rules.txt 命中 → 该请求被登记到 req.whistlePlugins
78
+
79
+ ├─ ① whistle 调用 REQ_RULES 钩子(rulesServer)
80
+ │ 命中磁盘 → '* file://<目录>/ resType://js cache://31536000'
81
+ │ → whistle 直接从本地回放,零网络
82
+ │ → 同时 revalidate.schedule() 发起后台条件请求
83
+ │ 未命中 → '* resWrite://<目录>/'
84
+ │ → whistle 照常回源,顺手把响应落盘
85
+ │ 不该管 → ''(完全不受影响)
86
+
87
+ └─ ② 响应阶段 whistle 调用 RES_RULES 钩子(resRulesServer)
88
+ 读 req.originalRes.statusCode + req.headers(此时语义是响应头)
89
+ 确认是合格的 200 → 给 pending 盖上 commit 标记
90
+ ```
91
+
92
+ **关键:插件不需要 MITM、不需要独立端口、不需要改系统代理,生命周期天然跟随插件启停。**
93
+
94
+ ---
95
+
96
+ ## 3. 五个必须知道的坑(改动前必看)
97
+
98
+ ### 坑 1:钩子返回的规则文本必须带匹配模式
99
+
100
+ whistle 的插件钩子返回的是**完整规则行**,形如 `模式 操作符://值`。
101
+
102
+ ```js
103
+ // ❌ 静默失效:单 token 行会被当成 pattern(请求 URL 匹配表达式),不是操作符
104
+ return 'statusCode://418';
105
+
106
+ // ✅
107
+ return '* statusCode://418';
108
+ ```
109
+
110
+ **没有任何报错**,请求照常返回 200,你会以为钩子没被调用。这是排查最久的一个坑。
111
+
112
+ ### 坑 2:`resWrite` / `file://` 会自动拼接 URL 剩余路径
113
+
114
+ whistle 会把「匹配模式之后的剩余路径」拼到给定路径后面。传文件路径会得到叠出来的多层路径:
115
+
116
+ ```
117
+ resWrite://C:/cache/body/<key>.js → 实际写到 C:/cache/body/<key>.js/<URL 的 pathname>
118
+ ```
119
+
120
+ **正确做法:传目录(以 `/` 结尾),让它自己拼。**
121
+
122
+ 因此存储布局是「**一个 URL 一个目录**」:
123
+
124
+ ```
125
+ data/cache/<哈希前2位>/
126
+ body/<sha1>/<URL 的 pathname> ← 响应体(whistle 解压后写入,二进制安全)
127
+ meta/<sha1>.json ← 正式缓存记录
128
+ pending/<sha1>.json ← 本次回源的落盘意向(等转正)
129
+ ```
130
+
131
+ `store.relPathFor(url)` = `new URL(url).pathname.replace(/^\/+/, '')`,两侧用同一套算法算路径。
132
+
133
+ **meta / pending 里存的是相对路径**(`store._toRel()`),读取时用 `store._toAbs()` 还原。
134
+ 这样整个 `data/cache` 目录可以整体搬迁(换盘符 / 挪插件目录)而不会让已有缓存失效。
135
+ 历史遗留的绝对路径记录仍能正常读取(`_toAbs` 会原样返回绝对路径),会自动兼容。
136
+ 注意:`beginPending()` / `lookup()` 对外返回的**仍是绝对路径**,因为 whistle 的 `file://` 规则需要真实路径。
137
+
138
+ ### 坑 3:`file://` 按扩展名猜 MIME,必须显式指定 `resType`
139
+
140
+ `static.figma.com/uploads/<contenthash>` 这类地址**本身没有扩展名**,
141
+ whistle 会把它猜成 `text/html` —— 浏览器拿到 JS 却按 HTML 解析,直接报错。
142
+
143
+ ```js
144
+ // 从存储的 Content-Type 反推短名
145
+ parts.push('resType://' + resTypeOf(meta.contentType)); // js / css / json / html / xml
146
+ ```
147
+
148
+ > **不能**用 `resHeaders://{content-type: application/javascript; charset=utf-8}` ——
149
+ > 规则文本按空白分词,值里的空格和分号会把规则拆散。
150
+ > `resHeader://key=value`(单数)**不是合法协议**,`protocols.js` 里只有 `resHeaders`。
151
+
152
+ ### 坑 4:`Date.now()` 是整数毫秒,`stat.mtimeMs` 带小数
153
+
154
+ 判断「body 是否写完」时:
155
+
156
+ ```js
157
+ // ❌ age 可能是 -0.7(mtime 比 Date.now() 还"新"),settleMs=0 时被误判成"未静默"
158
+ if (Date.now() - stat.mtimeMs < settleMs) return null;
159
+
160
+ // ✅ settleMs 为 0 时直接跳过检查
161
+ if (settleMs > 0 && Date.now() - stat.mtimeMs < settleMs) return null;
162
+ ```
163
+
164
+ 表现为单测三次挂两次的 flaky。
165
+
166
+ ### 坑 5:孤儿清理会误删待转正的 body
167
+
168
+ `_sweepOrphans()` **不能只按 `this.index` 判断**。index 里只有「已转正」的条目,
169
+ 而刚落盘、等下一次请求转正的 body 不在 index 里 —— 会被当孤儿删掉,
170
+ 下次请求无物可转正 → 重新下载 → **缓存永远建不起来**。
171
+
172
+ ```js
173
+ if (this.index.has(name)) continue; // 已转正
174
+ const pending = readJsonSafe(this.pendingPath(name));
175
+ if (pending && age < PENDING_MAX_AGE_MS) continue; // 等待转正,不能删
176
+ ```
177
+
178
+ ---
179
+
180
+ ## 4. 时序模型:为什么转正要等到「下一次请求」
181
+
182
+ **问题**:whistle 的 `resWrite` 是边收边写的流式落盘。`RES_RULES` 钩子触发时,
183
+ body 往往**还没写完甚至还没开始写**,所以不能在那一刻就生成 meta。
184
+
185
+ **方案**:分两步,把「意向」和「转正」拆开。
186
+
187
+ ```
188
+ REQ_RULES(未命中) → beginPending() 写 pending/<key>.json(只有 url/ext/路径)
189
+ 响应流 → whistle 的 resWrite 往盘上写 body
190
+ RES_RULES → commitPending() 校验状态码与响应头,给 pending 盖 commit 标记
191
+
192
+ ……之后任何一次对同一 URL 的请求……
193
+
194
+ REQ_RULES → lookup() → _tryPromote()
195
+ 条件:pending.commit 存在
196
+ + body 存在且非空
197
+ + body 的 mtime 已静默超过 bodySettleMs(默认 500ms)
198
+ 满足 → 写 meta/ 并删 pending → 本次直接命中(file://)
199
+ 不满足 → 返回 null,走未命中流程
200
+ ```
201
+
202
+ **这个设计一次解决三个问题**:
203
+
204
+ 1. **时序问题** —— 转正时 body 一定写完了(是上一次响应留下的)
205
+ 2. **半截文件问题** —— `bodySettleMs` 静默期保证写流已关闭
206
+ 3. **状态码可信问题** —— 状态码由 RES_RULES 单独确认,拿不到就放弃转正(宁可少缓存,不缓存错误页)
207
+
208
+ **代价**:一个 URL 的第一次请求总是 MISS,第二次起才 HIT。对 Figma 完全可接受
209
+ (同一资源每次刷新都会被重新请求)。
210
+
211
+ ---
212
+
213
+ ## 5. 安全设计(三层防护)
214
+
215
+ **缓存错了东西会让 Figma 拿不到最新数据。** 所以做了三层。
216
+
217
+ ### 第 1 层:`rules.txt` 匹配范围极窄
218
+
219
+ ```txt
220
+ www.figma.com/webpack-artifacts/ whistle.figma-cache://
221
+ static.figma.com/uploads/ whistle.figma-cache://
222
+ ```
223
+
224
+ 只匹配两条路径前缀,**不是整个域名**。WebSocket、`/api/`、`/file/`、`/design/`、
225
+ `s3-alpha*.figma.com` 全都不在匹配范围内。
226
+
227
+ ### 第 2 层:`lib/policy.js` 白名单(独立于规则)
228
+
229
+ 即使规则被误改成宽匹配,只要 URL 不满足白名单也**不会**被缓存:
230
+
231
+ ```js
232
+ // 允许:域名精确匹配 + 文件名含内容哈希
233
+ { host: /^(?:www\.)?figma\.com$/, path: /^\/webpack-artifacts\/assets\/...+-[0-9a-f]{8,}\.min\.(js|css)...$/ },
234
+ { host: /^static\.figma\.com$/, path: /^\/uploads\/[0-9a-f]{32,}$/ }
235
+ ```
236
+
237
+ 硬性拒绝(`DENY_HOST` / `DENY_PATH`):
238
+
239
+ | 类别 | 拒绝内容 |
240
+ |---|---|
241
+ | 域名 | `s3-alpha.figma.com`、`s3-alpha-sig.figma.com`、`api.figma.com`、`*.amazonaws.com`、`*.cloudfront.net`、`figma-alpha-api.*` |
242
+ | 路径 | `/api/`、`/graphql`、`/file/`、`/design/`、`/board/`、`/proto/`、`/multiplayer`、`/render/`、`/export/` |
243
+ | URL | **任何带查询串的地址**(签名 token、缓存破坏参数) |
244
+ | 请求 | 非 `GET`、带 `Range`、`Cache-Control: no-store` |
245
+ | 协议 | 非 `https` |
246
+ | 升级 | 带 `Upgrade` / `Sec-WebSocket-Key` 的请求 |
247
+
248
+ ### 第 3 层:响应侧闸门
249
+
250
+ `policy.checkResponse(status, headers)`:状态码必须 `200`、无 `Set-Cookie`、
251
+ `Cache-Control` 不含 `no-store`/`private`、`Content-Type` 不是 `text/html`、`Vary` 不是 `*`。
252
+
253
+ **SWR 还有一道额外的内容类型护栏**:后台校验拿到的新响应 `Content-Type` 必须与缓存里记录的
254
+ 完全一致,否则**绝不替换** —— 专门挡住「被重定向到登录页 / 错误页把缓存写坏」。
255
+
256
+ ### 二进制安全
257
+
258
+ - whistle 的 `resWrite` 落盘的是**解压后**字节(`addZipTransform` 会置 `_needGunzip`,并删掉 `content-length`)
259
+ - 后台校验请求带 `Accept-Encoding: identity`,拿到压缩响应也会先 `zlib` 解压再存
260
+ - 命中时 `resType://` 修正 MIME,`file://` 自己算 `Content-Length`
261
+
262
+ ---
263
+
264
+ ## 6. SWR(后台静默校验)
265
+
266
+ 命中时(在冷却期外)在后台对同一 URL 发一次**条件请求**:
267
+
268
+ ```
269
+ If-None-Match: <存储的 etag>
270
+ If-Modified-Since: <存储的 last-modified>
271
+
272
+ 304 → store.touchValidation() 只刷新 validatedAt,body 0 字节
273
+ 200 → store.replaceBody() 内容真变了 → 临时文件 + rename 原子替换
274
+ 其它 → 忽略,绝不动缓存
275
+ ```
276
+
277
+ **成本控制**:
278
+
279
+ | 手段 | 说明 |
280
+ |---|---|
281
+ | 冷却期 | `revalidate` 默认 24h,同一资源一天最多校验一次 |
282
+ | 条件请求 | 绝大多数返回 304,**body 0 字节** |
283
+ | 并发上限 | `MAX_CONCURRENCY = 4` |
284
+ | 去重 | `inflight` Set,同一 key 同时只跑一个 |
285
+ | 内容类型护栏 | 见第 5 节 |
286
+
287
+ Figma 的 CDN 实测支持条件请求(带 `If-None-Match` 返回 304 / 0 字节)。
288
+
289
+ **后台请求直连源站**(`agent: false`,不走 whistle),避免自己拦自己形成死循环。
290
+ 副作用:不会经过用户自己的 whistle 规则。
291
+
292
+ `replaceBody` 用**同目录临时文件 + `fs.renameSync`** 原子替换。
293
+ Windows 上 libuv 以 `FILE_SHARE_DELETE` 打开文件,所以覆盖正在被读的文件不会失败。
294
+
295
+ ---
296
+
297
+ ## 7. 配置解析(lib/config.js)
298
+
299
+ 按 `ruleValue` 字符串缓存(`cacheByValue` Map),避免每请求重复解析。
300
+
301
+ | 参数 | 默认 | 解析函数 | 备注 |
302
+ |---|---|---|---|
303
+ | `dir` | `<插件目录>/data/cache` | `path.resolve` | **不能带空格**(规则文本按空白分词) |
304
+ | `ttl` | 0(永久) | `toSeconds`(支持 `30d`/`12h`) | URL 含内容哈希,语义上不可变 |
305
+ | `maxSize` | 4096 MB | `toPositiveNumber` | LRU 依据 |
306
+ | `maxFileSize` | 64 MB | `toPositiveNumber` | 超出不缓存,仍正常透传 |
307
+ | `revalidate` | 86400 | `toRevalidate`(`-1`/`off` = 关闭) | |
308
+ | `bodySettle` | 500 ms | `toMillis` | 测试里设 0 让提升立即发生 |
309
+ | `log` | 0 | — | 设 1 打印 HIT/MISS/PEND/SKIP |
310
+
311
+ ---
312
+
313
+ ## 8. 测试策略(126 项)
314
+
315
+ ```bash
316
+ npm test # 全部 130 项
317
+ npm run test:policy # 60 项:白名单 / 各类闸门
318
+ npm run test:store # 31 项:时序模型 / 原子替换 / LRU / 孤儿清理 / 相对路径搬迁
319
+ npm run test:hooks # 24 项:两个钩子的规则产出与完整闭环
320
+ npm run test:swr # 15 项:后台校验(起一个真实本地 HTTP 服务)
321
+ ```
322
+
323
+ **设计原则**:
324
+
325
+ - `policy.test.js` 里**「必须拒绝」的用例数量远多于「必须通过」**——误放行才是真事故
326
+ - 覆盖域名后缀伪造(如 `www.figma.com.evil.com`)、画布内图片、带 token 的签名地址
327
+ - `hooks.test.js` **mock 了 whistle 的钩子契约**(`req.originalReq` / `req.originalRes` / `req.headers`
328
+ 在 REQ_RULES 与 RES_RULES 阶段语义不同),不需要真跑 whistle
329
+ - `store.test.js` 里 monkey-patch 了 `fs.writeFileSync` 自动补父目录(因为测试直接写 body,
330
+ 而真实环境是 whistle 的 resWrite 负责建目录)
331
+ - `revalidate.test.js` 用真实 HTTP 服务 + `waitIdle()` 轮询,
332
+ 覆盖 304 / 200 / 类型变化 / 5xx / 压缩 / 冷却期 / 去重 / 连接失败
333
+
334
+ **测试里的一个陷阱**:`pending` / `meta` 存的是**相对路径**。测试如果需要直接读写 body 文件,
335
+ 必须先过了 `store._toAbs()`,否则会写到 `process.cwd()` 下面(表现为「提升失败」但没有任何报错)。
336
+
337
+ **改动后务必连跑 3 轮** —— 第 3 节的坑 4 曾经让测试三次挂两次。
338
+
339
+ ---
340
+
341
+ ## 9. 已确认的技术事实(别再重新验证一遍)
342
+
343
+ | 事实 | 依据 |
344
+ |---|---|
345
+ | **Figma 只读 `HKLM\Software\Figma`** | app.asar 里:`reg query ${NODE_ENV==="test" ? "HKCU" : "HKLM"}\Software\Figma`。写 HKCU 被**静默忽略**,不报错 |
346
+ | Figma 在**启动时一次性**读取该配置 | app.asar 里有 `To == null` 的缓存判断,改注册表后必须重启 |
347
+ | Figma CDN 支持条件请求 | 带 `If-None-Match` 返回 304 / 0 字节 |
348
+ | `file://` 回放比网络快一个数量级 | 同一资源本地回放 45 MB/s,直连网络约 600 KB/s |
349
+ | whistle 的 `resWrite` 落盘是解压后内容 | `lib/init.js` 的 `addZipTransform` 会置 `res._needGunzip` 并删 `content-length` |
350
+ | Chromium 默认 HTTP 缓存配额很小 | 实测长时间稳定在 80–100 MB 区间,写入量一大就驱逐 |
351
+ | `--disk-cache-size` 对 Electron 无效 | 进程命令行确认带上该参数,但 Chromium 缓存占用没有变化 |
352
+ | `cache://` 协议能设置命中响应的缓存头 | 命中响应出现 `cache-control: max-age=...` 与 `expires` |
353
+
354
+ ### 关于性能优化边界
355
+
356
+ 消除网络成本之后,加载仍然需要可观测的一段时间。实测该阶段:
357
+
358
+ - **网络不饱和**(远低于链路带宽上限)
359
+ - **CPU 也不饱和**(只占单核量级,远未跑满多核)
360
+
361
+ 即既不是网络瓶颈也不是 CPU 瓶颈,而是**串行流水线**特征:请求 → 解析 → 编译 → 渲染,
362
+ 绝大部分落在单核上,任何一环都在等下一环。**这部分客户端开销缓存无法优化。**
363
+
364
+ > 另注:给命中响应加 `Cache-Control`(`cache://31536000`)实测**没有带来可测量的改善** ——
365
+ > 多轮加载耗时基本一致。保留它是因为语义正确、无副作用,但不要指望它提速。
366
+
367
+ ---
368
+
369
+ ## 10. 调试手册
370
+
371
+ ### 计数器不动?
372
+
373
+ ```bash
374
+ # 1. 插件是否被调用(miss 是否增长)
375
+ curl -s "http://127.0.0.1:<端口>/plugin.figma-cache/cgi-bin/stats"
376
+
377
+ # 2. 客户端是否在用代理(应全部指向 127.0.0.1:<端口>,直连 443 应为 0)
378
+ # PowerShell:
379
+ # $ids=(Get-Process Figma).Id
380
+ # Get-NetTCPConnection -State Established | ? { $ids -contains $_.OwningProcess } |
381
+ # Group-Object RemotePort
382
+ ```
383
+
384
+ - `bypass` 在涨但 `miss` 不动 → 请求进了插件但被 policy 拒了,开 `log=1` 看 BYPASS 原因
385
+ - 连 `bypass` 都不动 → 请求压根没进插件,检查 `rules.txt` 是否加载、插件是否启用
386
+
387
+ ### 想看详细日志
388
+
389
+ 临时加一条更高优先级的规则。**注意字段是 `rules=` 不是 `data=`,且接口是 `/cgi-bin/rules/project`**:
390
+
391
+ ```bash
392
+ # 创建并置顶启用(top=1)
393
+ curl -X POST -u <用户>:<密码> \
394
+ --data-urlencode "name=__debug" --data-urlencode "enable=1&top=1" --data-urlencode "groupName=" \
395
+ "http://127.0.0.1:<端口>/cgi-bin/rules/project"
396
+
397
+ # 写入规则
398
+ curl -X POST -u <用户>:<密码> \
399
+ --data-urlencode "name=__debug" \
400
+ --data-urlencode "rules=static.figma.com/uploads/ figma-cache://log=1" \
401
+ --data-urlencode "groupName=" \
402
+ "http://127.0.0.1:<端口>/cgi-bin/rules/project"
403
+
404
+ # 用完删掉
405
+ curl -X POST -u <用户>:<密码> --data-urlencode "name=__debug" \
406
+ "http://127.0.0.1:<端口>/cgi-bin/rules/remove"
407
+ ```
408
+
409
+ > ⚠ `/cgi-bin/rules/add` 接口**不保存 `data` 字段** —— 用它会创建出**空规则组**,
410
+ > 而且返回 `{"ec":0}` 看起来像成功。排查时用它会白费大量功夫。
411
+
412
+ ### 验证某个 URL 会不会被缓存
413
+
414
+ ```bash
415
+ node -e "const p=require('./lib/policy'); console.log(p.checkUrl('<URL>'))"
416
+ ```
417
+
418
+ ### 缓存目录排查
419
+
420
+ ```bash
421
+ find data/cache -path '*pending*' -name '*.json' | wc -l # 待转正
422
+ find data/cache -path '*meta*' -name '*.json' | wc -l # 已转正
423
+ find data/cache -path '*/body/*' -type f | wc -l # 响应体
424
+ du -sh data/cache
425
+ ```
426
+
427
+ `meta` 数量长期远小于 `pending` → 提升没发生,检查 `bodySettle` 与 RES_RULES 是否拿到状态码。
428
+
429
+ ---
430
+
431
+ ## 11. 已知限制
432
+
433
+ 1. **首次加载仍然慢** —— 必须回源填缓存。
434
+ 2. **`x-figma-cache` 之类的调试响应头加不上** —— `resHeaders` 在 `file://` 回放场景下不生效,
435
+ 规则文本又受空白分词限制。可观测性靠状态页计数器。
436
+ 3. **依赖 whistle 提供状态码** —— RES_RULES 阶段拿不到 `_statusCode` 时主动放弃转正。
437
+ 4. **缓存目录路径不能带空格**。
438
+ 5. **未转正的 body 不计入 `maxSize`** —— 它们不在 index 里,LRU 管不到。
439
+ 目前靠 `PENDING_MAX_AGE_MS`(7 天)兜底清理。
440
+ 6. **后台校验请求不走 whistle 的规则链** —— 直连源站。
441
+ 7. **`cache://` 让 Chromium 也缓存** → Chromium 自己的缓存会跟着 churn。
442
+ 实测无性能影响;若要减少磁盘 IO,可改成 `cache://no-store` 让 Chromium 每次都问我们。
443
+
444
+ ## 12. 未解之谜(留待后续)
445
+
446
+ - **加载期间剩余的客户端计算具体花在哪**:V8 解析 JS?WASM 编译?文档渲染?
447
+ 没有对 Figma 内部做 profiling,无法定论。
448
+ - **`Code Cache/wasm` 频繁被改写**:究竟是 LRU 时间戳刷新还是真的在重编译,
449
+ 未验证(用目录总大小的变化可以区分,尚未做)。
450
+ - **`server` hook 的正确触发语法**:可能是某个协议别名,也可能该版本已移除 URL 规则入口。
451
+ 建议去 whistle 仓库确认。
452
+
453
+ ---
454
+
455
+ ## 13. 运维速查
456
+
457
+ | 项 | 说明 |
458
+ |---|---|
459
+ | 插件安装位置 | whistle 的 `custom_plugins` 目录下,形如 `<WhistleAppData>/custom_plugins/whistle.figma-cache/node_modules/whistle.figma-cache` |
460
+ | 常见做法 | 该路径做成指向开发目录的符号链接,改代码即时生效 |
461
+ | 重启 whistle | `w2 restart`(会重置计数器,但**磁盘缓存保留**) |
462
+ | 状态页 | `http://127.0.0.1:<端口>/plugin.figma-cache/` |
463
+ | 状态接口 | `.../cgi-bin/stats`、`.../cgi-bin/entries`、`.../cgi-bin/clear` |
464
+
465
+ **新增 / 修改插件后必须 `w2 restart`** —— whistle 只缓存插件元数据(含 `rules.txt`),
466
+ 改文件不会热重载。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ducaoya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # whistle.figma-cache
2
+
3
+ [![npm version](https://img.shields.io/npm/v/whistle.figma-cache.svg)](https://www.npmjs.com/package/whistle.figma-cache)
4
+ [![license](https://img.shields.io/npm/l/whistle.figma-cache.svg)](./LICENSE)
5
+
6
+ 给 **Figma 桌面客户端**加一层**磁盘缓存**,消除每次刷新都重复下载的静态资源。
7
+
8
+ > 实现细节、踩坑记录、调试手册见 **[AGENTS.md](./AGENTS.md)**(供 AI / 后续维护者阅读)。
9
+ > 开发与发布流程见 **[RELEASE.md](./RELEASE.md)**。
10
+
11
+ ## 它解决什么
12
+
13
+ Figma 编辑器每次刷新都要重新拉取**数十 MB** 的 JS / WASM / 字体。带宽受限时,这部分会占掉刷新耗时的大头。
14
+
15
+ 而 Chromium 自带的 HTTP 缓存救不了这个场景:
16
+
17
+ - 它的缓存配额只有约 **80 MB**,一次刷新就有数十 MB 资产要写进去,**写进去就被驱逐** ——
18
+ 热加载和冷加载一样慢;
19
+ - Electron 升级会更换 profile 目录,**上一次的缓存全部作废**。
20
+
21
+ 本插件把缓存放到自己的磁盘目录里:不设 80 MB 上限,不受 Electron 版本影响,命中后由本地磁盘回放。
22
+
23
+ ## 安装
24
+
25
+ ### 从 npm(推荐)
26
+
27
+ ```bash
28
+ w2 i whistle.figma-cache
29
+ w2 restart
30
+ ```
31
+
32
+ 或者先全局安装再让 whistle 启动时发现:
33
+
34
+ ```bash
35
+ npm i -g whistle.figma-cache
36
+ w2 restart
37
+ ```
38
+
39
+ ### 从源码(开发用)
40
+
41
+ ```bash
42
+ git clone https://github.com/ducaoya/whistle-figma-cache.git
43
+ cd whistle-figma-cache
44
+ npm link # 在 whistle 的全局 node_modules 里建立同名符号链接
45
+ w2 restart
46
+ ```
47
+
48
+ 装好后在 whistle 的 **Plugins** 面板应能看到 `figma-cache`。
49
+
50
+ ## 使用方式
51
+
52
+ ### 1. 确认插件已加载
53
+
54
+ whistle 界面 → **Plugins** 面板 → 找到 `figma-cache`,确保处于启用状态。
55
+ 点 **Option** 打开状态页 —— 里面有两页:**使用方式**(含验证方法与常见问题)和**数据**。
56
+
57
+ ### 2. 让 Figma 走 whistle(管理员 PowerShell)
58
+
59
+ Figma 只读 `HKLM\Software\Figma`,写别处会被静默忽略:
60
+
61
+ ```powershell
62
+ New-Item -Path 'HKLM:\SOFTWARE\Figma' -Force | Out-Null
63
+ Set-ItemProperty -Path 'HKLM:\SOFTWARE\Figma' -Name 'ProxyUrl' `
64
+ -Value 'http://127.0.0.1:8899' -Type String
65
+ ```
66
+
67
+ 把端口换成 `w2 status` 显示的实际值。这是**进程级代理,不影响系统代理**。
68
+
69
+ ### 3. 完全退出 Figma 再打开
70
+
71
+ Figma 只在启动时读一次这个配置,必须让所有 `Figma.exe` 进程结束(关窗口不够)。
72
+ 然后打开一个**设计文件** —— 第一次仍慢(在填缓存),之后刷新就走本地了。
73
+
74
+ ### 4. 可选参数
75
+
76
+ 默认零配置。需要调整时,在 whistle 的 **Rules** 面板追加一条更高优先级的规则:
77
+
78
+ ```txt
79
+ static.figma.com/uploads/ figma-cache://revalidate=7d,maxSize=8192,log=1
80
+ ```
81
+
82
+ | 参数 | 默认 | 说明 |
83
+ |---|---|---|
84
+ | `maxSize` | 4096 MB | 缓存总量上限,超出按 LRU 淘汰 |
85
+ | `maxFileSize` | 64 MB | 单文件上限 |
86
+ | `revalidate` | 24h | 后台静默校验冷却期;`0` = 每次命中都校验,`-1` = 关闭 |
87
+ | `ttl` | 0(永久) | 缓存有效期 |
88
+ | `dir` | `<插件目录>/data/cache` | 缓存目录(路径不能带空格) |
89
+ | `bodySettle` | 500 ms | 判定「body 写完」的静默阈值 |
90
+ | `log` | 0 | 设 `1` 打印 HIT / MISS 日志 |
91
+
92
+ ## 常见问题
93
+
94
+ **Q:状态页数据一直是 0,计数器不动?**
95
+ 逐一确认:① 写的是 `HKLM` 而不是 `HKCU`;② Figma 是**完全退出后**重启的(不是关窗口);
96
+ ③ 打开的是**设计文件**,不是只停在文件列表页 —— 白名单只覆盖编辑器资源。
97
+
98
+ **Q:Figma 打不开了 / 白屏 / 一直转圈?**
99
+ 说明代理生效了但 whistle 没响应。检查 whistle 是否在运行(`w2 status`)。急着用就先删掉注册表值恢复:
100
+ ```powershell
101
+ Remove-ItemProperty -Path 'HKLM:\SOFTWARE\Figma' -Name 'ProxyUrl'
102
+ ```
103
+
104
+ **Q:第一次加载还是慢?**
105
+ 正常。第一次必须回源并写入缓存,从第二次开始才走本地磁盘。
106
+
107
+ **Q:第二次刷新还是不够快?**
108
+ 网络成本已经消除 —— 看状态页:`已省流量` 很大且 `后台校验流量` 接近 0 就说明缓存没问题了。
109
+ 剩下的时间是 Figma 自身的客户端开销(解析编译 JS/WASM、渲染文档),缓存帮不上忙。
110
+ 继续优化要看文件复杂度:拆分大文件、删除隐藏图层(官方明确隐藏图层照样占内存)、
111
+ 用 component properties 替代海量 variants、压缩大图。
112
+
113
+ **Q:后台校验会不会偷跑流量?**
114
+ 不会。它用 `If-None-Match` 条件请求,绝大多数返回 304(body 0 字节),
115
+ 且同一资源 24 小时内最多校验一次。状态页的「后台校验流量」可以直接确认。
116
+
117
+ **Q:会不会缓存到画布数据,导致拿不到最新内容?**
118
+ 不会。白名单只覆盖两类**内容哈希命名、永远不可变**的地址:
119
+
120
+ ```
121
+ www.figma.com/webpack-artifacts/assets/<name>-<contenthash>.min.js(.br)
122
+ static.figma.com/uploads/<contenthash>
123
+ ```
124
+
125
+ 画布数据、`/api/`、`/file/`、`/design/`、`s3-alpha*.figma.com`、WebSocket、
126
+ 任何带查询串的地址全部拒绝。详见 AGENTS.md 的「安全设计」。
127
+
128
+ ## 关闭 / 卸载
129
+
130
+ | 操作 | 效果 |
131
+ |---|---|
132
+ | Plugins 面板禁用插件 | 缓存能力立即失效,请求原样透传 |
133
+ | 删掉注册表的 `ProxyUrl` | Figma 恢复直连 / 走系统代理 |
134
+ | 状态页「清空缓存」 | 清掉全部本地缓存 |
135
+ | `npm unlink -g whistle.figma-cache` | 从 node_modules 移除 |
136
+
137
+ ## 自测
138
+
139
+ ```bash
140
+ npm test # 130 项:安全策略 60 / 存储 31 / 钩子 24 / SWR 15
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT
package/index.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * whistle.figma-cache 插件入口
3
+ *
4
+ * 实现方式:只借用 whistle 的「规则钩子」,不接管请求、不做 MITM、不开独立端口。
5
+ *
6
+ * 请求阶段 (REQ_RULES / rulesServer)
7
+ * 命中磁盘 → 返回 file://<body> whistle 直接从本地回放,零网络
8
+ * 未命中 → 返回 resWrite://<body> whistle 照常回源,顺手把响应落盘
9
+ *
10
+ * 响应阶段 (RES_RULES / resRulesServer)
11
+ * 把落盘意向转正成可命中的 meta 记录(校验状态码与响应头)
12
+ *
13
+ * 界面 (UI / uiServer)
14
+ * 查看命中率、缓存条目,一键清空
15
+ *
16
+ * 全部能力都挂在插件自身的生命周期上:
17
+ * · 插件「启用」→ rules.txt 被加载、钩子进程启动,缓存生效
18
+ * · 插件「禁用」→ rules.txt 不再加载、进程被杀,能力立即失效
19
+ *
20
+ * 注:rules.txt 里的 `whistle.figma-cache://` 规则会把命中路径的请求登记到
21
+ * req.whistlePlugins,whistle 随后就会调用上面的 REQ_RULES / RES_RULES 钩子。
22
+ * 这也是为什么不需要改动 Figma 的代理设置、不影响系统代理。
23
+ */
24
+
25
+ // 请求阶段:决定「用本地文件回放」还是「回源 + 落盘」
26
+ exports.rulesServer = require('./lib/rulesServer');
27
+
28
+ // 响应阶段:把落盘结果转正为缓存条目
29
+ exports.resRulesServer = require('./lib/resRulesServer');
30
+
31
+ // 界面:状态查看 / 清空缓存
32
+ exports.uiServer = require('./lib/uiServer');
33
+
34
+ // ─────────────────────────────────────────────────────────────────────────────
35
+ // 以下为早期尝试:想用 `server` hook(完全接管请求)实现,代码与测试都保留着,
36
+ // 但 whistle 2.10.10 不会把 URL 规则派发到 server hook,因此不导出。
37
+ // 若日后 whistle 修复了该派发,取消下面一行的注释即可启用。
38
+ //
39
+ // exports.server = require('./lib/server');
40
+ // ─────────────────────────────────────────────────────────────────────────────