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/config.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 配置解析
|
|
5
|
+
*
|
|
6
|
+
* 默认配置写死在代码里,rules.txt 里不带任何参数即可工作(零配置)。
|
|
7
|
+
* 如需调整,可在 Rules 面板追加一条更高优先级的规则覆盖,例如:
|
|
8
|
+
*
|
|
9
|
+
* www.figma.com/webpack-artifacts/ figma-cache://ttl=0,maxSize=8192,log=1
|
|
10
|
+
*
|
|
11
|
+
* 支持的参数:
|
|
12
|
+
* dir 缓存目录(绝对路径),默认 <插件目录>/data/cache
|
|
13
|
+
* ttl 缓存有效期,如 0 / 3600 / 30d / 12h;0 表示永不过期(默认)
|
|
14
|
+
* URL 内含内容哈希,语义上不可变,所以默认永不过期
|
|
15
|
+
* maxSize 缓存总量上限,单位 MB,默认 4096
|
|
16
|
+
* maxFileSize 单个文件缓存上限,单位 MB,默认 64
|
|
17
|
+
* revalidate 后台静默校验的冷却期,默认 24h;0 = 每次命中都校验;-1 = 关闭
|
|
18
|
+
* log 1 时在 Whistle 插件控制台打印 HIT/MISS/STORE 日志,默认 0
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const path = require('path');
|
|
22
|
+
|
|
23
|
+
const PLUGIN_ROOT = path.join(__dirname, '..');
|
|
24
|
+
|
|
25
|
+
const DEFAULTS = {
|
|
26
|
+
dir: path.join(PLUGIN_ROOT, 'data', 'cache'),
|
|
27
|
+
ttl: 0,
|
|
28
|
+
maxSize: 4096,
|
|
29
|
+
maxFileSize: 64,
|
|
30
|
+
revalidate: 86400,
|
|
31
|
+
bodySettleMs: 500,
|
|
32
|
+
log: 0
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 解析形如 "ttl=30d,maxSize=8192" 的参数串
|
|
37
|
+
*/
|
|
38
|
+
function parseParams(value) {
|
|
39
|
+
const out = {};
|
|
40
|
+
if (!value) {
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
String(value)
|
|
44
|
+
.split(/[,;]/)
|
|
45
|
+
.forEach((seg) => {
|
|
46
|
+
const idx = seg.indexOf('=');
|
|
47
|
+
if (idx <= 0) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const key = seg.slice(0, idx).trim();
|
|
51
|
+
const val = seg.slice(idx + 1).trim();
|
|
52
|
+
if (key) {
|
|
53
|
+
out[key] = val;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 把 "30d" / "12h" / "600" 转成秒;非法值返回 fallback
|
|
61
|
+
*/
|
|
62
|
+
function toSeconds(input, fallback) {
|
|
63
|
+
if (input == null || input === '') {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
const m = /^(\d+(?:\.\d+)?)\s*(s|m|h|d|w)?$/i.exec(String(input).trim());
|
|
67
|
+
if (!m) {
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
70
|
+
const n = parseFloat(m[1]);
|
|
71
|
+
if (!isFinite(n) || n < 0) {
|
|
72
|
+
return fallback;
|
|
73
|
+
}
|
|
74
|
+
const unit = (m[2] || 's').toLowerCase();
|
|
75
|
+
const factor = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 }[unit] || 1;
|
|
76
|
+
return Math.round(n * factor);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function toPositiveNumber(input, fallback) {
|
|
80
|
+
const n = parseFloat(input);
|
|
81
|
+
return isFinite(n) && n > 0 ? n : fallback;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 毫秒参数(允许 0) */
|
|
85
|
+
function toMillis(input, fallback) {
|
|
86
|
+
if (input == null || input === '') {
|
|
87
|
+
return fallback;
|
|
88
|
+
}
|
|
89
|
+
const n = parseFloat(input);
|
|
90
|
+
return isFinite(n) && n >= 0 ? n : fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 后台校验冷却期。
|
|
95
|
+
* 86400 / 24h / 1d → 同一资源 24 小时内最多校验一次(默认)
|
|
96
|
+
* 0 → 每次命中都校验
|
|
97
|
+
* -1 / off → 完全关闭后台校验
|
|
98
|
+
*/
|
|
99
|
+
function toRevalidate(input, fallback) {
|
|
100
|
+
if (input == null || input === '') {
|
|
101
|
+
return fallback;
|
|
102
|
+
}
|
|
103
|
+
const s = String(input).trim().toLowerCase();
|
|
104
|
+
if (s === '-1' || s === 'off' || s === 'false' || s === 'no' || s === 'none') {
|
|
105
|
+
return -1;
|
|
106
|
+
}
|
|
107
|
+
return toSeconds(s, fallback);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cacheByValue = new Map();
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 根据 ruleValue 得到生效配置(带缓存,避免每次请求重复解析)
|
|
114
|
+
*/
|
|
115
|
+
function resolveConfig(ruleValue) {
|
|
116
|
+
const raw = ruleValue == null ? '' : String(ruleValue);
|
|
117
|
+
if (cacheByValue.has(raw)) {
|
|
118
|
+
return cacheByValue.get(raw);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const p = parseParams(raw);
|
|
122
|
+
const cfg = {
|
|
123
|
+
dir: p.dir ? path.resolve(p.dir) : DEFAULTS.dir,
|
|
124
|
+
ttl: toSeconds(p.ttl, DEFAULTS.ttl),
|
|
125
|
+
maxSize: toPositiveNumber(p.maxSize, DEFAULTS.maxSize),
|
|
126
|
+
maxFileSize: toPositiveNumber(p.maxFileSize, DEFAULTS.maxFileSize),
|
|
127
|
+
revalidate: toRevalidate(p.revalidate, DEFAULTS.revalidate),
|
|
128
|
+
bodySettleMs: toMillis(p.bodySettle, DEFAULTS.bodySettleMs),
|
|
129
|
+
log: String(p.log || '0') === '1' || String(p.log || '').toLowerCase() === 'true'
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
cacheByValue.set(raw, cfg);
|
|
133
|
+
return cfg;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 轻量日志(只在 log=1 时输出)。用 console.log 可被 Whistle 写入插件控制台。
|
|
138
|
+
*/
|
|
139
|
+
function log(cfg, ...args) {
|
|
140
|
+
if (!cfg || !cfg.log) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
// eslint-disable-next-line no-console
|
|
145
|
+
console.log('[figma-cache]', ...args);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
PLUGIN_ROOT,
|
|
153
|
+
DEFAULTS,
|
|
154
|
+
resolveConfig,
|
|
155
|
+
parseParams,
|
|
156
|
+
toSeconds,
|
|
157
|
+
log
|
|
158
|
+
};
|
package/lib/policy.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 缓存准入策略 —— 本插件的【安全核心】
|
|
5
|
+
*
|
|
6
|
+
* 设计原则:
|
|
7
|
+
* 1. 白名单,不是黑名单。只有明确认定「内容哈希命名、永远不可变」的
|
|
8
|
+
* URL 才允许进入缓存,其余一律放行给 Whistle 原生流程。
|
|
9
|
+
* 2. 这是一道独立于 rules.txt 的第二道闸门。即使规则被误改成
|
|
10
|
+
* www.figma.com/ 这种宽匹配,只要 URL 不满足白名单,也不会被缓存。
|
|
11
|
+
* 3. 涉及画布 / 文件内容 / 接口 / 签名地址的请求,一律硬性拒绝。
|
|
12
|
+
*
|
|
13
|
+
* 【绝对不能缓存的东西】
|
|
14
|
+
* · 画布与文件数据:/file/、/design/、/board/、/proto/、/multiplayer
|
|
15
|
+
* · 接口:/api/、/graphql
|
|
16
|
+
* · 用户内容素材:s3-alpha.figma.com、s3-alpha-sig.figma.com、*.amazonaws.com
|
|
17
|
+
* · 带查询串的地址(签名 token、缓存破坏参数)
|
|
18
|
+
* · 非 GET 请求、WebSocket 升级请求
|
|
19
|
+
* · 非 200 响应、带 Set-Cookie 的响应、Cache-Control 含 no-store/private
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
// ── 可缓存白名单 ───────────────────────────────────────────────────────────
|
|
23
|
+
// host 必须精确匹配,path 必须满足「文件名内含足够长的十六进制哈希」
|
|
24
|
+
const ALLOW = [
|
|
25
|
+
{
|
|
26
|
+
name: 'figma-editor-bundle',
|
|
27
|
+
// 例:/webpack-artifacts/assets/vendor-1d532d39d96c5d27.min.js.br
|
|
28
|
+
// /webpack-artifacts/assets/3866-6a39a297e15317ca.min.js.br
|
|
29
|
+
host: /^(?:www\.)?figma\.com$/i,
|
|
30
|
+
path: /^\/webpack-artifacts\/assets\/[A-Za-z0-9._$-]+-[0-9a-f]{8,}\.(?:min\.)?(?:js|mjs|css|json|wasm)(?:\.(?:br|gz))?$/i
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'figma-static-cdn',
|
|
34
|
+
// 仅放行已验证存在的 /uploads/<内容哈希> 路径。
|
|
35
|
+
// 例:/uploads/<40 位内容哈希>,响应头 cache-control: max-age=31536000
|
|
36
|
+
//
|
|
37
|
+
// 如需放行其它路径(如 /fonts/、/fullscreen/),请先用下面命令确认
|
|
38
|
+
// 该路径确实带长缓存头且文件名含内容哈希,再往这里加:
|
|
39
|
+
// curl -sSI https://static.figma.com/xxx | grep -i cache-control
|
|
40
|
+
host: /^static\.figma\.com$/i,
|
|
41
|
+
path: /^\/uploads\/[0-9a-f]{32,}(?:\.[A-Za-z0-9]+)?$/i
|
|
42
|
+
}
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
// ── 硬性拒绝:域名(画布素材 / 用户内容 / 签名地址)────────────────────────
|
|
46
|
+
const DENY_HOST = [
|
|
47
|
+
/(^|\.)s3-alpha\.figma\.com$/i,
|
|
48
|
+
/(^|\.)s3-alpha-sig\.figma\.com$/i,
|
|
49
|
+
/(^|\.)s3-downtime\.figma\.com$/i,
|
|
50
|
+
/(^|\.)api\.figma\.com$/i,
|
|
51
|
+
/(^|\.)amazonaws\.com$/i,
|
|
52
|
+
/(^|\.)cloudfront\.net$/i,
|
|
53
|
+
/(^|\.)figma-alpha-api\./i
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
// ── 硬性拒绝:路径关键字(画布 / 文件 / 接口 / 实时协作)──────────────────
|
|
57
|
+
const DENY_PATH = [
|
|
58
|
+
'/api/',
|
|
59
|
+
'/graphql',
|
|
60
|
+
'/file/',
|
|
61
|
+
'/design/',
|
|
62
|
+
'/board/',
|
|
63
|
+
'/proto/',
|
|
64
|
+
'/multiplayer',
|
|
65
|
+
'/render/',
|
|
66
|
+
'/export/',
|
|
67
|
+
'/__/',
|
|
68
|
+
'/v1/'
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
// ── 存储哪些响应头 ─────────────────────────────────────────────────────────
|
|
72
|
+
const HEADER_KEEP = [
|
|
73
|
+
'content-type',
|
|
74
|
+
'content-encoding',
|
|
75
|
+
'cache-control',
|
|
76
|
+
'etag',
|
|
77
|
+
'last-modified',
|
|
78
|
+
'vary',
|
|
79
|
+
'content-language',
|
|
80
|
+
'access-control-allow-origin'
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
// 命中后重新下发的缓存时长(一年)—— 让 Chromium 也一起缓存,形成双层
|
|
84
|
+
const REPLAY_CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 请求本身是否适合缓存
|
|
88
|
+
*/
|
|
89
|
+
function isSafeRequest(req) {
|
|
90
|
+
if (!req || req.method !== 'GET') {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
const h = req.headers || {};
|
|
94
|
+
// WebSocket / HTTP Upgrade 一律不碰
|
|
95
|
+
if (h.upgrade || h['sec-websocket-key'] || h['sec-websocket-version']) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
// 明确要求不走缓存的请求,尊重它
|
|
99
|
+
const cc = String(h['cache-control'] || '').toLowerCase();
|
|
100
|
+
if (cc.indexOf('no-store') !== -1) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
// 分段请求(媒体拖动进度等)不缓存,避免把 206 或半截内容当成完整资源
|
|
104
|
+
if (h.range) {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* URL 是否在白名单内
|
|
112
|
+
* @returns {{ok: boolean, reason: string, rule?: string}}
|
|
113
|
+
*/
|
|
114
|
+
function checkUrl(rawUrl) {
|
|
115
|
+
if (!rawUrl || typeof rawUrl !== 'string') {
|
|
116
|
+
return { ok: false, reason: 'empty-url' };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let u;
|
|
120
|
+
try {
|
|
121
|
+
u = new URL(rawUrl);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
return { ok: false, reason: 'invalid-url' };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (u.protocol !== 'https:') {
|
|
127
|
+
return { ok: false, reason: 'not-https' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 任何查询串都不缓存:签名地址 / 带 token / 缓存破坏参数都在这里被拦掉
|
|
131
|
+
if (u.search && u.search.length > 1) {
|
|
132
|
+
return { ok: false, reason: 'has-query' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const host = u.hostname.toLowerCase();
|
|
136
|
+
const path = u.pathname;
|
|
137
|
+
|
|
138
|
+
if (DENY_HOST.some((re) => re.test(host))) {
|
|
139
|
+
return { ok: false, reason: 'deny-host' };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lowerPath = path.toLowerCase();
|
|
143
|
+
const hitDeny = DENY_PATH.find((seg) => lowerPath.indexOf(seg) !== -1);
|
|
144
|
+
if (hitDeny) {
|
|
145
|
+
return { ok: false, reason: 'deny-path:' + hitDeny };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rule = ALLOW.find((r) => r.host.test(host) && r.path.test(path));
|
|
149
|
+
if (!rule) {
|
|
150
|
+
return { ok: false, reason: 'not-allowlisted' };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { ok: true, reason: 'allow', rule: rule.name };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 响应是否适合落盘
|
|
158
|
+
* @returns {{ok: boolean, reason: string}}
|
|
159
|
+
*/
|
|
160
|
+
function checkResponse(statusCode, headers) {
|
|
161
|
+
if (statusCode !== 200) {
|
|
162
|
+
return { ok: false, reason: 'status-' + statusCode };
|
|
163
|
+
}
|
|
164
|
+
const h = headers || {};
|
|
165
|
+
|
|
166
|
+
if (h['set-cookie']) {
|
|
167
|
+
return { ok: false, reason: 'set-cookie' };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const cc = String(h['cache-control'] || '').toLowerCase();
|
|
171
|
+
if (cc.indexOf('no-store') !== -1) {
|
|
172
|
+
return { ok: false, reason: 'no-store' };
|
|
173
|
+
}
|
|
174
|
+
if (cc.indexOf('private') !== -1) {
|
|
175
|
+
return { ok: false, reason: 'private' };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const ct = String(h['content-type'] || '').toLowerCase();
|
|
179
|
+
if (ct.indexOf('text/html') !== -1) {
|
|
180
|
+
return { ok: false, reason: 'html' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (String(h['vary'] || '').trim() === '*') {
|
|
184
|
+
return { ok: false, reason: 'vary-star' };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return { ok: true, reason: 'ok' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 从原始响应头中挑出需要持久化的部分
|
|
192
|
+
*/
|
|
193
|
+
function pickStoreHeaders(headers, bodyLength) {
|
|
194
|
+
const out = {};
|
|
195
|
+
HEADER_KEEP.forEach((key) => {
|
|
196
|
+
const val = headers[key];
|
|
197
|
+
if (val != null && val !== '') {
|
|
198
|
+
out[key] = Array.isArray(val) ? val.join(', ') : String(val);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// content-length 一律以实际落盘字节数为准,避免「Whistle 已解压但
|
|
203
|
+
// 头里还写着压缩后长度」这类不一致把报文搞坏
|
|
204
|
+
const declared = Number(headers['content-length']);
|
|
205
|
+
out['content-length'] = String(bodyLength);
|
|
206
|
+
|
|
207
|
+
// 若声明长度与实际字节数不符,说明上游流已被解压,此时必须摘掉
|
|
208
|
+
// content-encoding,否则回放时会按错误的方式解码
|
|
209
|
+
if (out['content-encoding'] && isFinite(declared) && declared > 0 && declared !== bodyLength) {
|
|
210
|
+
delete out['content-encoding'];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 命中缓存时下发给客户端的响应头
|
|
218
|
+
*/
|
|
219
|
+
function buildReplayHeaders(storeHeaders, bodyLength) {
|
|
220
|
+
const out = Object.assign({}, storeHeaders);
|
|
221
|
+
out['content-length'] = String(bodyLength);
|
|
222
|
+
out['cache-control'] = REPLAY_CACHE_CONTROL;
|
|
223
|
+
out['x-figma-cache'] = 'HIT';
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
ALLOW,
|
|
229
|
+
DENY_HOST,
|
|
230
|
+
DENY_PATH,
|
|
231
|
+
REPLAY_CACHE_CONTROL,
|
|
232
|
+
isSafeRequest,
|
|
233
|
+
checkUrl,
|
|
234
|
+
checkResponse,
|
|
235
|
+
pickStoreHeaders,
|
|
236
|
+
buildReplayHeaders
|
|
237
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* resRulesServer hook —— 对应 whistle 的 RES_RULES 钩子(响应阶段)
|
|
5
|
+
*
|
|
6
|
+
* 这个阶段 whistle 已经拿到源站响应,所以我们能读到:
|
|
7
|
+
* req.originalRes.statusCode 响应状态码
|
|
8
|
+
* req.headers 响应头(whistle 在 resRules 阶段把 res.headers 当请求头传过来)
|
|
9
|
+
*
|
|
10
|
+
* 职责很简单:把 REQ_RULES 阶段登记的「落盘意向」转正成一条可命中的 meta 记录。
|
|
11
|
+
* 不满足条件(非 200、带 Set-Cookie、no-store 等)就丢弃意向,避免缓存脏数据。
|
|
12
|
+
*
|
|
13
|
+
* 注意:命中回放时不会有 pending(转正时已删除),所以这里天然不会自我覆盖。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const config = require('./config');
|
|
17
|
+
const policy = require('./policy');
|
|
18
|
+
const store = require('./store');
|
|
19
|
+
|
|
20
|
+
function fullUrlOf(req) {
|
|
21
|
+
const oReq = req.originalReq || {};
|
|
22
|
+
return oReq.realUrl || req.fullUrl || oReq.url || '';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function reply(res) {
|
|
26
|
+
try {
|
|
27
|
+
res.writeHead(200, {
|
|
28
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
29
|
+
'Content-Length': '0'
|
|
30
|
+
});
|
|
31
|
+
} catch (e) {
|
|
32
|
+
/* ignore */
|
|
33
|
+
}
|
|
34
|
+
res.end();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = (server) => {
|
|
38
|
+
server.on('request', (req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const oReq = req.originalReq || {};
|
|
41
|
+
const url = fullUrlOf(req);
|
|
42
|
+
const cfg = config.resolveConfig(oReq.ruleValue);
|
|
43
|
+
store.use(cfg);
|
|
44
|
+
|
|
45
|
+
const verdict = policy.checkUrl(url);
|
|
46
|
+
if (!verdict.ok) {
|
|
47
|
+
return reply(res);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const key = store.keyOf(url);
|
|
51
|
+
|
|
52
|
+
// 没有待转正的意向 → 可能是命中回放,也可能是别人加的规则,什么都不做
|
|
53
|
+
if (!store.hasPending(key)) {
|
|
54
|
+
return reply(res);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 状态码拿不到时宁可放弃,也不要误判成 200 缓存一个错误页
|
|
58
|
+
const oRes = req.originalRes || {};
|
|
59
|
+
const rawStatus = oRes.statusCode;
|
|
60
|
+
if (rawStatus == null || rawStatus === '') {
|
|
61
|
+
store.dropPending(key);
|
|
62
|
+
config.log(cfg, 'DROP(no-status) %s', url);
|
|
63
|
+
return reply(res);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const status = Number(rawStatus) || 0;
|
|
67
|
+
const resHeaders = req.headers || {};
|
|
68
|
+
|
|
69
|
+
const result = store.commitPending(key, status, resHeaders, cfg);
|
|
70
|
+
if (result.ok) {
|
|
71
|
+
config.log(cfg, 'PEND %s %s', resHeaders['content-type'] || '', url);
|
|
72
|
+
} else {
|
|
73
|
+
config.log(cfg, 'SKIP(%s) %s', result.reason, url);
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
/* 静默失败,不影响响应 */
|
|
77
|
+
}
|
|
78
|
+
return reply(res);
|
|
79
|
+
});
|
|
80
|
+
};
|