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/uiServer.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UI 服务模块
|
|
5
|
+
*
|
|
6
|
+
* 提供插件 Option 页面:查看缓存统计 / 命中率 / 已缓存条目,并支持一键清空。
|
|
7
|
+
* whistle 会把请求路径标准化为 /whistle.figma-cache/...,所以这里统一用后缀匹配。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
const store = require('./store');
|
|
14
|
+
const config = require('./config');
|
|
15
|
+
|
|
16
|
+
function sendJson(res, data, status) {
|
|
17
|
+
const body = Buffer.from(JSON.stringify(data, null, 2), 'utf8');
|
|
18
|
+
res.writeHead(status || 200, {
|
|
19
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
20
|
+
'Content-Length': String(body.length),
|
|
21
|
+
'Cache-Control': 'no-store'
|
|
22
|
+
});
|
|
23
|
+
res.end(body);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sendHtml(res, html) {
|
|
27
|
+
const body = Buffer.from(html, 'utf8');
|
|
28
|
+
res.writeHead(200, {
|
|
29
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
30
|
+
'Content-Length': String(body.length),
|
|
31
|
+
'Cache-Control': 'no-store'
|
|
32
|
+
});
|
|
33
|
+
res.end(body);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = (server) => {
|
|
37
|
+
server.on('request', (req, res) => {
|
|
38
|
+
let pathname = '/';
|
|
39
|
+
try {
|
|
40
|
+
pathname = new URL(req.url, 'http://' + (req.headers.host || 'localhost')).pathname;
|
|
41
|
+
} catch (e) {
|
|
42
|
+
pathname = req.url || '/';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
store.use(config.resolveConfig(''));
|
|
47
|
+
|
|
48
|
+
// ── 首页 ──────────────────────────────────────────────────────────
|
|
49
|
+
if (/\/$/.test(pathname) || /index\.html$/.test(pathname)) {
|
|
50
|
+
const htmlPath = path.join(__dirname, '..', 'public', 'index.html');
|
|
51
|
+
if (fs.existsSync(htmlPath)) {
|
|
52
|
+
sendHtml(res, fs.readFileSync(htmlPath, 'utf8'));
|
|
53
|
+
} else {
|
|
54
|
+
sendHtml(res, '<h1>whistle.figma-cache</h1><p>public/index.html 缺失</p>');
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── 统计 ──────────────────────────────────────────────────────────
|
|
60
|
+
if (/\/cgi-bin\/stats$/.test(pathname)) {
|
|
61
|
+
const stats = store.getStats();
|
|
62
|
+
const c = stats.counters;
|
|
63
|
+
const total = c.hit + c.miss;
|
|
64
|
+
stats.hitRate = total > 0 ? ((c.hit / total) * 100).toFixed(1) + '%' : '-';
|
|
65
|
+
stats.savedMB = Math.round((c.bytesServed / 1048576) * 10) / 10;
|
|
66
|
+
sendJson(res, stats);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── 条目列表 ──────────────────────────────────────────────────────
|
|
71
|
+
if (/\/cgi-bin\/entries$/.test(pathname)) {
|
|
72
|
+
sendJson(res, store.list(200));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── 清空缓存 ──────────────────────────────────────────────────────
|
|
77
|
+
if (/\/cgi-bin\/clear$/.test(pathname)) {
|
|
78
|
+
store.clear();
|
|
79
|
+
sendJson(res, { ok: true, message: '缓存已清空' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
sendJson(res, { ok: false, message: 'not found: ' + pathname }, 404);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
sendJson(res, { ok: false, message: String((e && e.message) || e) }, 500);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "whistle.figma-cache",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Figma 客户端静态资源磁盘缓存:只缓存 content-hash 命名的不可变资源,命中即由本地磁盘回放,绝不触碰画布数据 / API / WebSocket",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"lib/",
|
|
9
|
+
"rules.txt",
|
|
10
|
+
"public/",
|
|
11
|
+
"README.md",
|
|
12
|
+
"AGENTS.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node test/policy.test.js && node test/store.test.js && node test/hooks.test.js && node test/revalidate.test.js",
|
|
17
|
+
"test:policy": "node test/policy.test.js",
|
|
18
|
+
"test:store": "node test/store.test.js",
|
|
19
|
+
"test:hooks": "node test/hooks.test.js",
|
|
20
|
+
"test:swr": "node test/revalidate.test.js",
|
|
21
|
+
"prepublishOnly": "npm test",
|
|
22
|
+
"link": "npm link",
|
|
23
|
+
"unlink": "npm unlink -g whistle.figma-cache"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"whistle",
|
|
27
|
+
"whistle-plugin",
|
|
28
|
+
"figma",
|
|
29
|
+
"cache",
|
|
30
|
+
"proxy",
|
|
31
|
+
"cdn"
|
|
32
|
+
],
|
|
33
|
+
"author": "ducaoya",
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/ducaoya/whistle-figma-cache.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/ducaoya/whistle-figma-cache#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/ducaoya/whistle-figma-cache/issues"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=14"
|
|
49
|
+
},
|
|
50
|
+
"whistleConfig": {
|
|
51
|
+
"priority": 0,
|
|
52
|
+
"hintList": [
|
|
53
|
+
"figma-cache://",
|
|
54
|
+
"figma-cache://revalidate=7d",
|
|
55
|
+
"figma-cache://revalidate=0",
|
|
56
|
+
"figma-cache://revalidate=-1",
|
|
57
|
+
"figma-cache://maxSize=8192,revalidate=24h",
|
|
58
|
+
"figma-cache://log=1"
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>whistle.figma-cache</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root {
|
|
9
|
+
--bg: #16181d;
|
|
10
|
+
--panel: #1e2128;
|
|
11
|
+
--panel2: #22252c;
|
|
12
|
+
--line: #2c3039;
|
|
13
|
+
--fg: #e6e8ec;
|
|
14
|
+
--dim: #9aa1ad;
|
|
15
|
+
--accent: #4c8dff;
|
|
16
|
+
--ok: #35c46b;
|
|
17
|
+
--warn: #f0a23c;
|
|
18
|
+
--bad: #f06060;
|
|
19
|
+
}
|
|
20
|
+
* { box-sizing: border-box; }
|
|
21
|
+
html, body { height: 100%; }
|
|
22
|
+
body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
background: var(--bg);
|
|
25
|
+
color: var(--fg);
|
|
26
|
+
font: 13px/1.65 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
|
27
|
+
display: flex;
|
|
28
|
+
flex-direction: column;
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/* ── 顶部:标题 + 页签 ───────────────────────────────── */
|
|
33
|
+
header { flex: none; padding: 14px 20px 0; border-bottom: 1px solid var(--line); }
|
|
34
|
+
.title { font-size: 16px; font-weight: 600; }
|
|
35
|
+
.subtitle { color: var(--dim); font-size: 12px; margin: 2px 0 10px; }
|
|
36
|
+
.tabs { display: flex; gap: 4px; }
|
|
37
|
+
.tab {
|
|
38
|
+
padding: 7px 16px; cursor: pointer; font-size: 13px;
|
|
39
|
+
color: var(--dim); border: 1px solid transparent; border-bottom: none;
|
|
40
|
+
border-radius: 7px 7px 0 0; user-select: none; position: relative; top: 1px;
|
|
41
|
+
}
|
|
42
|
+
.tab:hover { color: var(--fg); }
|
|
43
|
+
.tab.on {
|
|
44
|
+
color: var(--fg); background: var(--panel);
|
|
45
|
+
border-color: var(--line); border-bottom-color: var(--panel);
|
|
46
|
+
font-weight: 500;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ── 页面容器 ─────────────────────────────────────────── */
|
|
50
|
+
.page { display: none; flex: 1; min-height: 0; }
|
|
51
|
+
.page.on { display: flex; flex-direction: column; }
|
|
52
|
+
|
|
53
|
+
/* ── 第一页:使用方式(整页滚动)─────────────────────── */
|
|
54
|
+
#page-guide { overflow-y: auto; padding: 18px 20px 40px; display: none; }
|
|
55
|
+
#page-guide.on { display: block; }
|
|
56
|
+
.guide { max-width: 860px; }
|
|
57
|
+
h2 { font-size: 13px; color: var(--dim); font-weight: 500; margin: 22px 0 8px; }
|
|
58
|
+
h2:first-child { margin-top: 0; }
|
|
59
|
+
.step {
|
|
60
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
|
|
61
|
+
padding: 12px 16px; margin-bottom: 10px;
|
|
62
|
+
}
|
|
63
|
+
.step .h { font-weight: 600; margin-bottom: 6px; }
|
|
64
|
+
.step .h .n {
|
|
65
|
+
display: inline-block; width: 18px; height: 18px; line-height: 18px; text-align: center;
|
|
66
|
+
background: var(--accent); color: #fff; border-radius: 50%; font-size: 11px; margin-right: 7px;
|
|
67
|
+
}
|
|
68
|
+
pre {
|
|
69
|
+
background: #111318; border: 1px solid var(--line); border-radius: 6px;
|
|
70
|
+
padding: 9px 12px; margin: 7px 0 0; overflow-x: auto;
|
|
71
|
+
font: 12px/1.55 ui-monospace, Consolas, monospace; color: #cfe3ff;
|
|
72
|
+
}
|
|
73
|
+
code { font-family: ui-monospace, Consolas, monospace; }
|
|
74
|
+
p { margin: 6px 0; }
|
|
75
|
+
.dim { color: var(--dim); }
|
|
76
|
+
.tip { color: var(--warn); }
|
|
77
|
+
table.kv { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
|
78
|
+
table.kv th, table.kv td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
|
|
79
|
+
table.kv th { color: var(--dim); font-weight: 500; }
|
|
80
|
+
table.kv tr:last-child td { border-bottom: 0; }
|
|
81
|
+
table.kv code { color: #cfe3ff; }
|
|
82
|
+
|
|
83
|
+
/* ── 第二页:数据 ─────────────────────────────────────── */
|
|
84
|
+
#page-data { padding: 16px 20px 0; }
|
|
85
|
+
.cards { flex: none; display: flex; flex-wrap: wrap; gap: 10px; }
|
|
86
|
+
.card {
|
|
87
|
+
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
|
|
88
|
+
padding: 9px 14px; min-width: 118px;
|
|
89
|
+
}
|
|
90
|
+
.card .k { color: var(--dim); font-size: 11px; letter-spacing: .3px; white-space: nowrap; }
|
|
91
|
+
.card .v { font-size: 19px; font-weight: 600; margin-top: 1px; font-variant-numeric: tabular-nums; }
|
|
92
|
+
.v.ok { color: var(--ok); }
|
|
93
|
+
.v.warn { color: var(--warn); }
|
|
94
|
+
.v.bad { color: var(--bad); }
|
|
95
|
+
|
|
96
|
+
.bar { flex: none; display: flex; align-items: center; gap: 10px; margin: 14px 0 10px; flex-wrap: wrap; }
|
|
97
|
+
button {
|
|
98
|
+
background: var(--accent); color: #fff; border: 0; border-radius: 6px;
|
|
99
|
+
padding: 6px 14px; font-size: 13px; cursor: pointer;
|
|
100
|
+
}
|
|
101
|
+
button:hover { filter: brightness(1.1); }
|
|
102
|
+
button.ghost { background: transparent; border: 1px solid var(--line); color: var(--fg); }
|
|
103
|
+
.path { color: var(--dim); font-size: 12px; word-break: break-all; }
|
|
104
|
+
|
|
105
|
+
/* 列表:占据剩余高度并滚动 */
|
|
106
|
+
.table-wrap {
|
|
107
|
+
flex: 1; min-height: 0; overflow: auto;
|
|
108
|
+
border: 1px solid var(--line); border-radius: 8px 8px 0 0; background: var(--panel);
|
|
109
|
+
margin-bottom: 0;
|
|
110
|
+
}
|
|
111
|
+
table.list { width: 100%; border-collapse: collapse; }
|
|
112
|
+
table.list th {
|
|
113
|
+
position: sticky; top: 0; z-index: 1;
|
|
114
|
+
background: var(--panel2); color: var(--dim); font-weight: 500; font-size: 11px;
|
|
115
|
+
letter-spacing: .3px; text-align: left; padding: 8px 10px;
|
|
116
|
+
border-bottom: 1px solid var(--line);
|
|
117
|
+
}
|
|
118
|
+
table.list td { padding: 6px 10px; border-bottom: 1px solid var(--line); font-size: 12px; }
|
|
119
|
+
table.list tr:last-child td { border-bottom: 0; }
|
|
120
|
+
table.list tr:hover td { background: #1a1d23; }
|
|
121
|
+
td.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
122
|
+
td.url { font-family: ui-monospace, Consolas, monospace; font-size: 11.5px; max-width: 0; width: 100%;
|
|
123
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
124
|
+
td.type { color: var(--dim); white-space: nowrap; font-size: 11px; }
|
|
125
|
+
.empty { color: var(--dim); text-align: center; padding: 30px 0; }
|
|
126
|
+
|
|
127
|
+
footer { flex: none; padding: 8px 20px 12px; color: var(--dim); font-size: 11.5px; }
|
|
128
|
+
</style>
|
|
129
|
+
</head>
|
|
130
|
+
<body>
|
|
131
|
+
|
|
132
|
+
<header>
|
|
133
|
+
<div class="title">whistle.figma-cache</div>
|
|
134
|
+
<div class="subtitle">Figma 静态资源磁盘缓存 · 命中即本地回放,不再重复走跨境带宽</div>
|
|
135
|
+
<div class="tabs">
|
|
136
|
+
<div class="tab on" data-tab="guide">使用方式</div>
|
|
137
|
+
<div class="tab" data-tab="data">数据</div>
|
|
138
|
+
</div>
|
|
139
|
+
</header>
|
|
140
|
+
|
|
141
|
+
<!-- ═══════════════ 第一页:使用方式 ═══════════════ -->
|
|
142
|
+
<div class="page on" id="page-guide">
|
|
143
|
+
<div class="guide">
|
|
144
|
+
|
|
145
|
+
<h2>一、启用插件(本机只需一次)</h2>
|
|
146
|
+
<div class="step">
|
|
147
|
+
<div class="h"><span class="n">1</span>确认 whistle 在运行</div>
|
|
148
|
+
<pre>w2 status</pre>
|
|
149
|
+
<p class="dim">插件安装在 whistle 的 <code>custom_plugins</code> 目录,whistle 启动时会自动加载。
|
|
150
|
+
本页能打开就说明插件已经加载成功了。</p>
|
|
151
|
+
</div>
|
|
152
|
+
<div class="step">
|
|
153
|
+
<div class="h"><span class="n">2</span>确认插件处于启用状态</div>
|
|
154
|
+
<p>whistle 界面 → <b>Plugins</b> 面板 → 找到 <code>figma-cache</code>,确保未被禁用。</p>
|
|
155
|
+
<p class="dim">插件自带的 <code>rules.txt</code> 会随插件一起启用 / 停用 —— 禁用插件后所有缓存能力立即失效,不留任何痕迹。</p>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<h2>二、让 Figma 走 whistle</h2>
|
|
159
|
+
<div class="step">
|
|
160
|
+
<div class="h"><span class="n">3</span>写注册表(需要管理员权限)</div>
|
|
161
|
+
<p>Figma 只读 <code>HKLM\Software\Figma</code>,用管理员身份打开 PowerShell 执行:</p>
|
|
162
|
+
<pre>New-Item -Path 'HKLM:\SOFTWARE\Figma' -Force | Out-Null
|
|
163
|
+
Set-ItemProperty -Path 'HKLM:\SOFTWARE\Figma' -Name 'ProxyUrl' `
|
|
164
|
+
-Value 'http://127.0.0.1:8899' -Type String</pre>
|
|
165
|
+
<p class="tip">⚠ 端口要换成 <code>w2 status</code> 显示的实际端口。这里是进程级代理,<b>不影响系统代理</b>。</p>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="step">
|
|
168
|
+
<div class="h"><span class="n">4</span>完全退出 Figma 再重新打开</div>
|
|
169
|
+
<p>Figma 只在<b>启动那一瞬间</b>读一次这个配置。必须让所有 <code>Figma.exe</code> 进程都结束
|
|
170
|
+
(关窗口不够,看托盘图标退出,或任务管理器结束)。</p>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="step">
|
|
173
|
+
<div class="h"><span class="n">5</span>打开一个设计文件</div>
|
|
174
|
+
<p>第一次加载仍然慢 —— 它要把资源回源并写入缓存。之后再刷新就是走本地磁盘了。</p>
|
|
175
|
+
<p class="dim">只停在「最近文件」列表页不会触发缓存,因为白名单只覆盖编辑器资源。</p>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
<h2>三、验证是否生效</h2>
|
|
179
|
+
<div class="step">
|
|
180
|
+
<div class="h">看「数据」页这几个数</div>
|
|
181
|
+
<table class="kv">
|
|
182
|
+
<tr><th>指标</th><th>生效的表现</th></tr>
|
|
183
|
+
<tr><td><code>命中</code></td><td>第二次刷新后大幅增长</td></tr>
|
|
184
|
+
<tr><td><code>回源</code></td><td>只在第一次加载时增长</td></tr>
|
|
185
|
+
<tr><td><code>命中率</code></td><td>第二次刷新后应 > 90%</td></tr>
|
|
186
|
+
<tr><td><code>已省流量</code></td><td>持续增长 —— 这就是没走网络的量</td></tr>
|
|
187
|
+
<tr><td><code>后台校验流量</code></td><td>应远小于「已省流量」(理想接近 0)</td></tr>
|
|
188
|
+
</table>
|
|
189
|
+
<p class="dim" style="margin-top:8px">另外可以在 whistle 的 <b>Network</b> 面板看到
|
|
190
|
+
<code>www.figma.com/webpack-artifacts/</code> 与 <code>static.figma.com/uploads/</code> 的请求记录。</p>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<h2>四、可选参数</h2>
|
|
194
|
+
<div class="step">
|
|
195
|
+
<p>默认零配置即可。需要调整时,在 whistle 的 <b>Rules</b> 面板追加一条更高优先级的规则:</p>
|
|
196
|
+
<pre>static.figma.com/uploads/ figma-cache://revalidate=7d,maxSize=8192,log=1</pre>
|
|
197
|
+
<table class="kv">
|
|
198
|
+
<tr><th>参数</th><th>默认</th><th>说明</th></tr>
|
|
199
|
+
<tr><td><code>maxSize</code></td><td>4096 MB</td><td>缓存总量上限,超出按 LRU 淘汰</td></tr>
|
|
200
|
+
<tr><td><code>maxFileSize</code></td><td>64 MB</td><td>单文件上限</td></tr>
|
|
201
|
+
<tr><td><code>revalidate</code></td><td>24h</td><td>后台静默校验冷却期;<code>0</code>=每次命中都校验;<code>-1</code>=关闭</td></tr>
|
|
202
|
+
<tr><td><code>ttl</code></td><td>0(永久)</td><td>缓存有效期</td></tr>
|
|
203
|
+
<tr><td><code>dir</code></td><td>插件目录/data/cache</td><td>缓存目录(<b>路径不能带空格</b>)</td></tr>
|
|
204
|
+
<tr><td><code>bodySettle</code></td><td>500 ms</td><td>判定「body 写完」的静默阈值</td></tr>
|
|
205
|
+
<tr><td><code>log</code></td><td>0</td><td>设 <code>1</code> 在插件控制台打印 HIT / MISS 日志</td></tr>
|
|
206
|
+
</table>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<h2>五、关闭 / 撤销</h2>
|
|
210
|
+
<div class="step">
|
|
211
|
+
<table class="kv">
|
|
212
|
+
<tr><th>操作</th><th>效果</th></tr>
|
|
213
|
+
<tr><td>Plugins 面板禁用插件</td><td>缓存能力立即失效,请求原样透传</td></tr>
|
|
214
|
+
<tr><td>删掉注册表的 <code>ProxyUrl</code></td><td>Figma 恢复直连 / 走系统代理</td></tr>
|
|
215
|
+
<tr><td>数据页「清空缓存」</td><td>清掉全部本地缓存</td></tr>
|
|
216
|
+
</table>
|
|
217
|
+
<pre>Remove-ItemProperty -Path 'HKLM:\SOFTWARE\Figma' -Name 'ProxyUrl'</pre>
|
|
218
|
+
</div>
|
|
219
|
+
|
|
220
|
+
<h2>六、常见问题</h2>
|
|
221
|
+
<div class="step">
|
|
222
|
+
<p><b>Q:数据页一直是 0,计数器不动?</b><br>
|
|
223
|
+
A:① 确认写的是 <code>HKLM</code> 而不是 <code>HKCU</code>(Figma 只读 HKLM);② 确认 Figma 是<b>完全退出后</b>重启的;③ 确认打开的是设计文件而不是只停在文件列表页。</p>
|
|
224
|
+
<p><b>Q:Figma 打不开了 / 白屏?</b><br>
|
|
225
|
+
A:说明代理进去了但 whistle 没响应。检查 whistle 是否在运行(<code>w2 status</code>)。急着用的话删掉注册表的 <code>ProxyUrl</code> 立刻恢复。</p>
|
|
226
|
+
<p><b>Q:第一次加载还是慢?</b><br>
|
|
227
|
+
A:正常。第一次必须回源填缓存,从第二次开始才走本地。</p>
|
|
228
|
+
<p><b>Q:第二次刷新还是不够快?</b><br>
|
|
229
|
+
A:网络成本已经消除(数据页「已省流量」很大、「后台校验流量」很小即可确认)。剩下的时间是 Figma 自己解析编译 JS/WASM、渲染文档的客户端开销,缓存帮不上忙 —— 需要从文件复杂度(拆分大文件、删除隐藏图层、压缩大图)方向优化。</p>
|
|
230
|
+
<p><b>Q:后台校验会不会偷跑我的流量?</b><br>
|
|
231
|
+
A:不会。它带 <code>If-None-Match</code> 条件请求,绝大多数返回 304(body 0 字节);而且同一资源 24 小时内最多校验一次。数据页的「后台校验流量」可以直接确认。</p>
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
</div>
|
|
235
|
+
</div>
|
|
236
|
+
|
|
237
|
+
<!-- ═══════════════ 第二页:数据 ═══════════════ -->
|
|
238
|
+
<div class="page" id="page-data">
|
|
239
|
+
<div class="cards" id="cards"></div>
|
|
240
|
+
<div class="bar">
|
|
241
|
+
<button onclick="refresh()">刷新</button>
|
|
242
|
+
<button class="ghost" onclick="clearCache()">清空缓存</button>
|
|
243
|
+
<span class="path" id="dir"></span>
|
|
244
|
+
</div>
|
|
245
|
+
<div class="table-wrap">
|
|
246
|
+
<table class="list">
|
|
247
|
+
<thead>
|
|
248
|
+
<tr><th>体积</th><th>URL</th><th>类型</th><th>最后命中</th><th>最后校验</th></tr>
|
|
249
|
+
</thead>
|
|
250
|
+
<tbody id="rows"><tr><td colspan="5" class="empty">加载中…</td></tr></tbody>
|
|
251
|
+
</table>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<footer id="foot">「命中」为从本地磁盘回放的次数;「放行」为白名单外、原样透传的请求。</footer>
|
|
256
|
+
|
|
257
|
+
<script>
|
|
258
|
+
function fmtMB(b) { return (b / 1048576).toFixed(1) + ' MB'; }
|
|
259
|
+
function fmtBytes(b) {
|
|
260
|
+
b = b || 0;
|
|
261
|
+
if (b < 1024) return b + ' B';
|
|
262
|
+
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
|
|
263
|
+
return (b / 1048576).toFixed(2) + ' MB';
|
|
264
|
+
}
|
|
265
|
+
function fmtTime(ts) {
|
|
266
|
+
if (!ts) return '-';
|
|
267
|
+
var d = new Date(ts);
|
|
268
|
+
function p(n) { return n < 10 ? '0' + n : n; }
|
|
269
|
+
return p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
|
|
270
|
+
}
|
|
271
|
+
function card(k, v, cls) {
|
|
272
|
+
return '<div class="card"><div class="k">' + k + '</div><div class="v ' + (cls || '') + '">' + v + '</div></div>';
|
|
273
|
+
}
|
|
274
|
+
function esc(s) {
|
|
275
|
+
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
276
|
+
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* ── 页签切换 ── */
|
|
281
|
+
document.querySelectorAll('.tab').forEach(function (el) {
|
|
282
|
+
el.onclick = function () {
|
|
283
|
+
document.querySelectorAll('.tab').forEach(function (t) { t.classList.remove('on'); });
|
|
284
|
+
document.querySelectorAll('.page').forEach(function (p) { p.classList.remove('on'); });
|
|
285
|
+
el.classList.add('on');
|
|
286
|
+
document.getElementById('page-' + el.dataset.tab).classList.add('on');
|
|
287
|
+
if (el.dataset.tab === 'data') refresh();
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
async function refresh() {
|
|
292
|
+
try {
|
|
293
|
+
const s = await (await fetch('cgi-bin/stats')).json();
|
|
294
|
+
const c = s.counters || {};
|
|
295
|
+
const rate = parseFloat(s.hitRate);
|
|
296
|
+
document.getElementById('cards').innerHTML = [
|
|
297
|
+
card('缓存占用', s.totalMB + ' MB'),
|
|
298
|
+
card('文件数', s.files),
|
|
299
|
+
card('命中', c.hit, 'ok'),
|
|
300
|
+
card('回源', c.miss),
|
|
301
|
+
card('命中率', s.hitRate, isNaN(rate) ? '' : (rate > 50 ? 'ok' : 'warn')),
|
|
302
|
+
card('已省流量', s.savedMB + ' MB', 'ok'),
|
|
303
|
+
card('后台校验 304', c.revalidated304 || 0, 'ok'),
|
|
304
|
+
card('后台校验 200', c.revalidated200 || 0, (c.revalidated200 || 0) > 0 ? 'warn' : ''),
|
|
305
|
+
card('后台校验流量', fmtBytes(c.revalidateBytes)),
|
|
306
|
+
card('放行(不缓存)', c.bypass),
|
|
307
|
+
card('淘汰', c.evicted, (c.evicted || 0) > 0 ? 'warn' : '')
|
|
308
|
+
].join('');
|
|
309
|
+
document.getElementById('dir').textContent = s.dir || '';
|
|
310
|
+
document.getElementById('foot').textContent =
|
|
311
|
+
'后台校验 304 = 内容未变(body 0 字节);后台校验 200 = 内容变了已替换;「放行」为白名单外原样透传。';
|
|
312
|
+
|
|
313
|
+
const list = await (await fetch('cgi-bin/entries')).json();
|
|
314
|
+
const rows = document.getElementById('rows');
|
|
315
|
+
if (!Array.isArray(list) || !list.length) {
|
|
316
|
+
rows.innerHTML = '<tr><td colspan="5" class="empty">暂无缓存</td></tr>';
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
rows.innerHTML = list.map(function (e) {
|
|
320
|
+
return '<tr>' +
|
|
321
|
+
'<td class="num">' + fmtMB(e.size) + '</td>' +
|
|
322
|
+
'<td class="url" title="' + esc(e.url) + '">' + esc(e.url) + '</td>' +
|
|
323
|
+
'<td class="type">' + esc((e.contentType || '').split(';')[0]) + '</td>' +
|
|
324
|
+
'<td class="num">' + fmtTime(e.lastHitAt) + '</td>' +
|
|
325
|
+
'<td class="num">' + fmtTime(e.validatedAt) + '</td>' +
|
|
326
|
+
'</tr>';
|
|
327
|
+
}).join('');
|
|
328
|
+
} catch (e) {
|
|
329
|
+
document.getElementById('rows').innerHTML =
|
|
330
|
+
'<tr><td colspan="5" class="empty" style="color:var(--warn)">读取失败:' + esc(e.message) + '</td></tr>';
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function clearCache() {
|
|
335
|
+
if (!confirm('确定清空全部 Figma 静态资源缓存?\n\n清空后首次刷新 Figma 会重新下载全部资源。')) return;
|
|
336
|
+
await fetch('cgi-bin/clear', { method: 'POST' });
|
|
337
|
+
refresh();
|
|
338
|
+
}
|
|
339
|
+
</script>
|
|
340
|
+
</body>
|
|
341
|
+
</html>
|
package/rules.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
2
|
+
# whistle.figma-cache —— 内置规则
|
|
3
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
4
|
+
# 本文件由插件自动加载:
|
|
5
|
+
# · 插件「启用」时自动生效,无需在 Rules 面板做任何配置
|
|
6
|
+
# · 插件「禁用」时不再加载,所有能力立即失效
|
|
7
|
+
#
|
|
8
|
+
# 匹配范围刻意收得极窄 —— 只有下面两类 URL 会进入本插件。
|
|
9
|
+
# 画布数据、文件内容、GraphQL / REST API、WebSocket、图片素材
|
|
10
|
+
# 全都不在匹配范围内,完全不受影响(原样透传,零改动)。
|
|
11
|
+
#
|
|
12
|
+
# 第二道闸门在 lib/policy.js:即使规则意外命中,只要 URL 不是
|
|
13
|
+
# 「内容哈希命名」的不可变资源,也会被拒绝并原样透传。
|
|
14
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
# ① Figma 编辑器 JS/CSS bundle(webpack [contenthash] 命名,内容不可变)
|
|
17
|
+
# 例:https://www.figma.com/webpack-artifacts/assets/vendor-1d532d39d96c5d27.min.js.br
|
|
18
|
+
www.figma.com/webpack-artifacts/ whistle.figma-cache://
|
|
19
|
+
|
|
20
|
+
# ② Figma 自有静态 CDN 的 content-addressed 资源(字体 / 图标等)
|
|
21
|
+
# 例:https://static.figma.com/uploads/<40 位内容哈希>
|
|
22
|
+
static.figma.com/uploads/ whistle.figma-cache://
|
|
23
|
+
|
|
24
|
+
# ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
# ✖ 以下域名/路径【严禁】加入本插件,否则会导致 Figma 白屏或拿不到最新数据:
|
|
26
|
+
#
|
|
27
|
+
# www.figma.com/api/... GraphQL / REST 接口(文件内容、样式、版本)
|
|
28
|
+
# www.figma.com/file|design/... 画布与文件数据
|
|
29
|
+
# s3-alpha.figma.com 用户内容(画布图片、素材)
|
|
30
|
+
# s3-alpha-sig.figma.com 带签名的临时地址(会过期)
|
|
31
|
+
# wss://... multiplayer 实时协作长连接
|
|
32
|
+
#
|
|
33
|
+
# 'lib/policy.js' 里已对上述目标做了硬性拒绝,作为双保险。
|
|
34
|
+
# ═══════════════════════════════════════════════════════════════════════════
|