## 性能:瓶颈在传输,不在代码
先量后改。helper 冷启动 0.44s(编译 22173 行)看着像瓶颈,但
drouter-helpd 是常驻守护,请求走 Unix socket,正常路径根本不 fork;
实测各 API 已是毫秒级。app.js 语法编译 18ms、顶层执行 18ms
(393 个顶层函数 / 13401 行)—— JS 执行不是瓶颈。
真瓶颈是 app.js 690,482 字节每次重传。**决定不拆包**:JS 执行只 18ms,
拆包引入首屏 waterfall 的风险大于收益。改走三条:
1. 缓存策略按「有没有指纹」区分(不是按扩展名)
- 带 ?v=<mtime> → public, max-age=31536000, immutable
- 没指纹 → 仍 no-cache(否则部署后用户拿到旧文件)
- index.html → 永远 no-store(每次都要重新注入)
2. 编码协商 br > gzip > 原文,**逐级降级**
3. 压缩结果按 (path, mtime, size, enc) 缓存
真机实测:首次 690,482 → 200,137 字节,第二次打开 0 字节(304)。
## 逐级降级不是可选项
目标机**没装 python3-brotli**(只有 C 库 libbrotli1)。最初写的
_brotli_cached 在模块缺失时 `return data, None`,而 None 是 encoding,
调用方理解为「压不动,原样输出」—— 于是 583KB 裸传,**比优化前更慢**。
改成 (None, None) 明确表示「这条路不通」,由 _encode_body 继续试 gzip。
同时修:Accept-Encoding 探测必须认 q=0(br;q=0 是「明确不要」,
子串匹配会正好发给解不开的客户端 → 满屏乱码);编码必须进缓存键
(共用键会互相覆盖);brotli 固定 q5(q11 只省 10KB 却要 1.5-3s)。
## 宽 except 把 bug 装成了特性
兜底只接 ImportError 这类**真·预期**的缺失,不接 TypeError/ValueError
(「str 传给要 bytes 的接口」也是 TypeError,接了等于没接)。
早先的 `except Exception` 吞掉了一个 NameError(漏注入 _comp_lock),
表现是压缩功能完好但从不生效,页面照常能开、只看响应头的判据完全发现不了。
## 版本号:建立单一真源 packaging/VERSION
原来版本号在 4 个构建脚本各写死一个默认值 + openapi 里硬编码。
漏改一处就产出「文件名 1.0.9、control 里 1.0.8」的包 —— apt 按
control 认版本,dpkg -l 永远显示旧版且升不上去。
index.html 的 ETag 并入版本号:它发出去的是注入后的内容,
ETag 却按原文件算,只改 VERSION 文件时 ETag 不变,客户端换到 304
继续用旧副本,页面版本号永远停在旧版。
## logo 回概述
用 <a> 而不是 <div onclick>:Tab 能聚焦、Enter 能触发、读屏会念「链接」。
已在 dash 页时不重复 go()(go 会 stopPageTimers + 重拉数据)。
CSS 显式去 text-decoration/color(UA 样式表不声明仍生效),
:focus-visible 焦点环保留(纯键盘用户唯一的路标)。
## 顺带修掉一个已发布版本的真 bug
_vpn_env 里 os.path.join('/lib/modules', ...) 被留成了 '/nonexistent'
(反向验证脚本被 SIGTERM 打断,finally 没执行,注入留在源码里),
于是 mod_file 永远为空、**need_module 整个状态从四态里消失** ——
这正是 v1.0.8 release notes 声称修好的那个 bug(PVE 的 KVM 虚机内核
明明有 wireguard.ko.xz 却报 unsupported,前端配一句写死的
「通常说明跑在精简容器里」把排查方向带偏)。
新增 _dev/t-worktree-clean.py 扫注入哨兵,放在 sync.sh 所有判据之前 ——
基线被污染时,后面每一条红都不可信。
## 判据
新增 t-static-perf.py(42 项静态)、t-encoding-runtime.py(26 项**真跑**,
覆盖 9 种 Accept-Encoding 组合、brotli 缺失降级、两种编码不互相覆盖、
压不动直出、内部错误不被吞)、t-worktree-clean.py。
三个都做了反向验证,注入后确实变红。
反向验证本身也踩了坑:判据里 len(body) 在 body 为 None 时先崩 ——
退出码非 0 但没有 FAIL 行,看着像「跑了」,实际没人知道它验出了什么。
取长度一律过 _sz() 包一层。
全量回归 54 个判据 0 失败;真机验收 22 项全通过;
前端三查 47 视图 0 异常、26 项移动端适配全过。
97 lines
4.1 KiB
JavaScript
97 lines
4.1 KiB
JavaScript
/**
|
||
* jsstrip.py 的**独立交叉验证**(node 侧)。
|
||
*
|
||
* 分工:jsstrip.py 自己跑全部陷阱测试(那是它的主场,见那边)。
|
||
* node 这边只做一件 python 做不到的事:**把剥完的 app.js 真的
|
||
* 交给 V8 解析一遍**。如果 jsstrip 把哪段真代码误当注释剥掉了,
|
||
* 或者剥到一半把字符串/模板串的边界搞坏了,产物就会语法错误 ——
|
||
* 这条判据抓的是「语义层面的破坏」,比「某个字符串还在不在」硬得多。
|
||
*
|
||
* ⛔ 第一版在这里 spawn python 去要 jsstrip 的输出,结果撞上
|
||
* Windows 的 EBUSY / rc=null(execFileSync 与 spawnSync 对「被占用
|
||
* 的 exe」都直接失败),白跑两轮。**判据脚本自己因为环境抖动崩掉,
|
||
* 比它要检的 bug 更让人分神。** 现在改成:node 读一份由调用方
|
||
* (devtools 的运行脚本 / 人工)预先用 python 剥好的文件。
|
||
*
|
||
* ⛔ 另外这份文件的 docstring 第一版里写了块注释的起止字面量,
|
||
* node 直接报 "Invalid regular expression: missing /" —— 正是本轮
|
||
* 在修的那个 bug,我自己又犯了一次。在注释里描述注释语法时,
|
||
* 得把它们拆开写。
|
||
*/
|
||
'use strict';
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ROOT = path.dirname(__dirname);
|
||
const APP = path.join(ROOT, 'web', 'app.js');
|
||
|
||
let pass = 0, fail = 0;
|
||
const detail = [];
|
||
function chk(desc, cond, extra) {
|
||
if (cond) { pass++; console.log('[OK] ' + desc); }
|
||
else {
|
||
fail++; detail.push(desc);
|
||
console.log('[NG] ' + desc);
|
||
if (extra !== undefined) console.log(' ' + String(extra).slice(0, 400));
|
||
}
|
||
}
|
||
|
||
const src = fs.readFileSync(APP, 'utf8');
|
||
|
||
// ---------- 1) 原文件本身必须是合法 JS ----------
|
||
try {
|
||
new Function(src);
|
||
chk('app.js 是合法 JS(V8 接受原文)', true);
|
||
} catch (e) {
|
||
chk('app.js 是合法 JS(V8 接受原文)', false, e.message);
|
||
}
|
||
|
||
// ---------- 2) 剥后的代码必须仍是合法 JS ----------
|
||
// 产物由调用方预先生成:node 自己跑 python 这条路在 Windows 上不通。
|
||
const STRIPPED = process.env.JSSTRIP_OUT ||
|
||
path.join(ROOT, '_dev', '.jsstrip-appjs.out');
|
||
if (!fs.existsSync(STRIPPED)) {
|
||
chk('剥后产物存在(先跑 _dev/jsstrip.py 生成 ' +
|
||
path.relative(ROOT, STRIPPED) + ')', false);
|
||
} else {
|
||
const out = fs.readFileSync(STRIPPED, 'utf8');
|
||
try {
|
||
new Function(out);
|
||
chk('剥后的代码仍是合法 JS(jsstrip 没有剥坏结构)', true);
|
||
} catch (e) {
|
||
chk('剥后的代码仍是合法 JS(jsstrip 没有剥坏结构)', false, e.message);
|
||
}
|
||
|
||
// ---------- 3) 剥后关键 token 数量不该变多 ----------
|
||
// ⚠️ 判据是「只多不少」,**不是**「完全相等」。
|
||
// 写死相等会红:注释里经常提到这些标识符(比如「这里之前用
|
||
// innerHTML 于是…这种讲 bug 来历的话),剥注释会连带剥掉它们。
|
||
// 而「变多」才是误吃的信号 —— 原文被切开重组时 token 数会膨胀。
|
||
const TOKENS = ['function', 'const', 'innerHTML',
|
||
'addEventListener', 'querySelector'];
|
||
for (const t of TOKENS) {
|
||
const re = new RegExp(t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
|
||
const a = (src.match(re) || []).length;
|
||
const b = (out.match(re) || []).length;
|
||
chk('剥后 token 数量只多不少:' + t, b <= a, '原 ' + a + ' → 剥后 ' + b);
|
||
}
|
||
|
||
// ---------- 4) 剥后不含注释(否则等于没剥) ----------
|
||
for (const marker of ['eatComment', 'CSS 注释']) {
|
||
chk('剥后无注释残留 ' + marker, out.indexOf(marker) < 0);
|
||
}
|
||
|
||
// ---------- 5) 块注释起止必须配平 ----------
|
||
const opens = (out.match(/\/\*/g) || []).length;
|
||
const closes = (out.match(/\*\//g) || []).length;
|
||
chk('剥后块注释起止配平(没有半开的残留)',
|
||
opens === closes, '开 ' + opens + ' / 闭 ' + closes);
|
||
}
|
||
|
||
console.log();
|
||
console.log('='.repeat(60));
|
||
console.log('通过 ' + pass + ' 项,失败 ' + fail + ' 项');
|
||
if (fail) { console.log(); detail.forEach(d => console.log(' · ' + d)); }
|
||
console.log('='.repeat(60));
|
||
process.exit(fail ? 1 : 0);
|