1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
116 lines
7.0 KiB
Markdown
116 lines
7.0 KiB
Markdown
# 50-会话过期自愈:实例 HTML 注入「重连」脚本(2026-09-11 落地)
|
||
|
||
## 背景
|
||
|
||
档案 49 修好了「实例被空闲回收后,请求失败在传输层」的问题(反代自动拉起 + 等待就绪),
|
||
但用户实测仍报:
|
||
|
||
```
|
||
client api: session/prompt failed: transport failure for /api/session/prompt: HTTP 401 (gateway/internal)
|
||
```
|
||
|
||
**根因(代码级确认)**:`proxy.ts` 里那段 401 处理**只对"浏览器导航"生效**:
|
||
|
||
```ts
|
||
if (upRes.statusCode === 401 && request.raw.method === 'GET'
|
||
&& String(request.headers.accept ?? '').includes('text/html') && freshAuthUrl !== undefined) {
|
||
...302 到当前实例的新 token...
|
||
}
|
||
reply.raw.writeHead(upRes.statusCode ?? 502, headers); upRes.pipe(reply.raw) // ← XHR 一律原样透传
|
||
```
|
||
|
||
完整因果链:
|
||
1. 实例空闲被回收 → 2. 用户回到**已打开的页面**继续对话,前端发 XHR,**带着旧实例的 `dsh-auth-*` cookie**
|
||
→ 3. 档案 49 的修复把实例**拉起**了(这一步生效)→ 4. 新实例发现 token 是**旧 authority/端口的** → **401**
|
||
→ 5. 401 处理不管 XHR → 透传给前端 → dsh 显示 `transport failure` → 6. 用户手动刷新(=导航)才命中 401 处理、302 到新 token。
|
||
|
||
**→ 档案 49 消灭了"传输失败",但没消灭"认证失败"。**
|
||
|
||
## 方案取舍(用户选 C 先看效果)
|
||
|
||
| 方案 | 做法 | 评价 |
|
||
|---|---|---|
|
||
| **B** | 反代透明重放:XHR 401 时用 `freshAuthUrl()` 取新 token → 换新 cookie → **重放该请求** | 体验最好(前端无感),但要写 cookie 重放(中等复杂度)→ **留待后续** |
|
||
| **C(本次采用)** | 在反代返回的 **`text/html`** 里注入平台脚本 → 检测 `/api/*` 返回 **401** → 显示覆盖层动画 + `location.reload()`;reload 会走**已经工作**的"导航 401 → 302 新 token"链路自动恢复 | 简单、复用既有机制,且满足用户要的"当前页面给反馈" |
|
||
|
||
## 实现(`src/supervisor/proxy.ts`)
|
||
|
||
1. 新增模块级常量 **`SESSION_RECOVERY_JS`**(1480 字节,ES5 安全、全程 try/catch、幂等 `window.__dshRecover`):
|
||
- hook `window.fetch` 与 `XMLHttpRequest.prototype.open/send`(`loadend` 回调)
|
||
- 命中条件:**状态 401 且 URL 含 `/api/`** → 弹覆盖层(转圈 + 「会话已过期(实例被回收重建),正在重新连接…」)→ **900ms 后 `location.reload()`**
|
||
2. `injectRecovery(html)`:把 `<script>…</script>` 插到 `</body>` 之前(无 `</body>` 则追加)。
|
||
3. `proxyHttp` 的响应写出改为:**仅当 `content-type: text/html` 且无 `content-encoding` 且状态非 204/304** 时——
|
||
**缓冲正文 → 注入 → 重算 `content-length` → 写出**;其余情况**原样 `pipe`**(零影响)。
|
||
若因 `content-encoding` 跳过注入,会往 stderr 打 `[inject-recovery] skip:`(便于发现"没注入"的原因)。
|
||
4. **不落盘、不改官方文件**(符合 R2:只改写平台反代自身的响应内容)。
|
||
|
||
## 验证记录(真实反代链路,R4 模板测试)
|
||
|
||
| 检查 | 结果 |
|
||
|---|---|
|
||
| `enter` 起实例 + `curl -L`(跟随 302)+ cookie jar 取 HTML | **24059 字节**(正常) |
|
||
| 含 `__dshRecover` | ✅ 已注入 |
|
||
| 含 `loadend` 钩子 | ✅ |
|
||
| **`</body>` 出现次数** | **1** |
|
||
| 注入脚本是否在 `</body>` 之前 | ✅ |
|
||
| 提取注入脚本(以 `__dshRecover` 为中心向外取 `<script>` 边界)后 `node --check` | ✅ **语法正确** |
|
||
| 关键钩子齐备 | `window.fetch` ✅ / `XMLHttpRequest` ✅ / `loadend` ✅ / `s!==401` ✅ / `location.reload` ✅ |
|
||
| 清理 | 测试实例已 stop、临时会话已删(`user_agent=poc-inject3`) |
|
||
|
||
> 首轮验证曾误报"脚本语法错误"——原因是**我的提取正则跨了两个 `<script>` 标签**(dsh 把 boot 脚本放在 `<head>`,内含 `</script>`),换成"以 `__dshRecover` 为中心向外扩张"后通过。**注入本身一直是对的。**
|
||
|
||
## 仍未做
|
||
|
||
- **B(透明重放)**:终态方案 —— XHR 401 时自动换新 token 重放,用户连 reload 都不需要。属后续增强。
|
||
- **WS(`app.server.on upgrade`)**:实例未运行时仍 `socket.destroy()`;dsh 前端会自行重连。若要"等就绪再建隧道"需在 upgrade 回调做同样等待(未做)。
|
||
- **真实浏览器端的行为验证**:本次只验证了"注入正确 + 语法正确 + 结构正确";覆盖层与自动 reload 的**交互效果需在浏览器里实测**(起实例 → 停实例 → 在旧页面发消息 → 期望看到覆盖层并自动恢复)。
|
||
|
||
## 回滚
|
||
|
||
```
|
||
cp src/supervisor/proxy.ts.bak-<ts> src/supervisor/proxy.ts && npm run build && systemctl restart dshs
|
||
```
|
||
|
||
---
|
||
|
||
## 事后修正(2026-09-11 18:4x):注入对**浏览器无效**——上游 gzip 导致被跳过
|
||
|
||
**用户实测反馈**:「并没有效果,还是报刚才截图的错误」。排查后确认**是我的 bug**,且我此前的验证是**假阳性**。
|
||
|
||
### 根因
|
||
|
||
dsh 实例(上游)对**带 `Accept-Encoding` 的请求**会 **gzip 压缩 HTML**:
|
||
|
||
| 请求 | 上游响应头 | 注入 |
|
||
|---|---|---|
|
||
| 不带 `Accept-Encoding`(**我最初的 curl 验证方式**) | `content-encoding: (无)` | ✅ 已注入 |
|
||
| **带浏览器式 `Accept-Encoding: gzip, deflate, br`** | **`content-encoding: gzip`** | **❌ 被跳过** ← 浏览器就是这种 |
|
||
|
||
我的代码在 `content-encoding` 存在时**主动跳过**注入(为避免破坏已编码内容)→ 浏览器拿到的页面**没有自愈脚本**
|
||
→ C 对真实用户完全无效。代理 stderr 里正好有 `[inject-recovery] skip` 记录,与推断一致。
|
||
|
||
### 修法
|
||
|
||
`proxyHttp` 里:**只要客户端接受 HTML,就把转发给上游的 `accept-encoding` 覆盖为 `identity`**
|
||
(预先算好 `upHeaders` 再传入 `httpRequest`)→ 上游回未压缩 HTML → 注入成功。
|
||
体积影响可忽略(HTML ~24KB);**边缘 nginx 仍会对浏览器 gzip**,用户侧无感知;静态资源/SSE 的压缩行为不受影响。
|
||
|
||
### 验证(浏览器等价:`curl --compressed`)
|
||
|
||
| 检查 | 结果 |
|
||
|---|---|
|
||
| 响应 `content-encoding` | `gzip`(**由边缘 nginx 压缩**,非上游) |
|
||
| 解压后正文 | **24059 字节** |
|
||
| 含 `__dshRecover` / `loadend` | **✅ / ✅** |
|
||
| **代理 `[inject-recovery] skip` 记录(本次)** | **0 条** ✅(修正生效,上游不再压缩) |
|
||
| 注入脚本 `node --check` | ✅ 通过 |
|
||
| 注入位置 | ✅ 在 `</body>` 之前 |
|
||
|
||
### 第二个教训:**测试方法的假阳性 → 假阴性**
|
||
|
||
- **假阳性**(第一版验证):curl **不带** `Accept-Encoding` → 拿到未压缩 HTML → 通过 → 误以为"浏览器也行"。
|
||
- **假阴性**(复验时):`curl -H 'Accept-Encoding: gzip'` 时 **curl 默认不解压** → 我读到的是压缩字节 →
|
||
`includes('__dshRecover')` 必然为 false → 误判"修复无效"。
|
||
- **正确姿势**:模拟浏览器必须用 **`curl --compressed`**(既发 `Accept-Encoding`,**又自动解压**)。
|
||
→ **凡"注入/改写响应"类改动,验证必须覆盖"浏览器式请求头 + 解压后正文"两个条件**。
|