fix(proxy): HTML 外壳也下发 no-cache —— 修「Failed to load plugins」根因(档案 95)

症状:用户页面报
  client-modules: bundle script /plugins/??…&rev=… failed to load  ⇒ 界面「Failed to load plugins」

根因(实测三条对照):
· `GET /`(外壳 HTML)**没有任何缓存头**(无 Cache-Control/ETag/Last-Modified/Expires)⇒ 浏览器启发式缓存
· 外壳内嵌带**内容哈希 `rev`** 的插件 bundle URL;`rev`/模块列表与实例当前状态**必须完全一致**:
  原样 200(11.17 MB)|只改 rev → **404**|rev 对但少一个模块 → **404**
· 于是「改了插件 / 重启了实例」之后,旧外壳永远去请求**已不存在的 rev** ⇒ 404 ⇒ 报错,
  且**普通刷新会命中缓存的外壳 ⇒ 复现不消失**(2026-09-14 事故:当天连铺 4 次插件 + 3 次重启 dshs)

修法:把既有的 `no-cache` 治理(2026-09-12 只覆盖 `/plugins/`、`/assets/`)**扩到 HTML 外壳**——
`String(headers['content-type']).includes('text/html')` 也下发 `Cache-Control: no-cache`。
实例端仍是唯一事实源,平台只加缓存头,不改 rev(R2)。

验证:
· 服务器 `bash scripts/ci.sh` → CI OK(48 pass / 0 fail)
· `scripts/verify-inject.cjs` 新增防回退断言「/plugins/ 与 text/html 都必须 no-cache」
· 经 nginx 公网路径实测 `GET /` → **cache-control: no-cache**(改前为空)
· 端到端:取各用户页面里**自身**的 bundle URL 回拉 → admin/guest 均 200(11.69 / 11.13 MB)

⚠️ 本提交不能回溯治愈「已经坏在用户浏览器里的那份旧外壳」——用户需**强刷一次**
(Ctrl/Cmd+Shift+R,或 DevTools 勾 Disable cache,或无痕窗口)。

⚠️ 同批未提交(属别人 lane,见档案 95 §六):`BRIEF.md` / `DEPLOY-本部署.md` 的配额口径同步
(我改了它们的配额数字,但那两个文件本就有别人未提交的改动 ⇒ 不跟提,宁可保持 dirty)。
This commit is contained in:
admin committed 2026-09-14 21:50:28 +08:00
1 parent 683c4cdeff
commit e18eaa2b64
2 files changed
+30 -1

No files matched your search

+14 -1
View File
@@ -379,8 +379,21 @@ function proxyHttp(
// **允许缓存,但每次必须回源校验**(配合上游 ETag/Last-Modified 走 304,开销极小)。
// ⇒ bundle 一变,浏览器下一次请求就拿到新的,**用户无需清缓存/换无痕**。
//
// 2026-09-14(档案 95 · 追加):**HTML 外壳同样必须 no-cache**。
// 实测根因:`/` 原先**不带任何缓存头**(无 Cache-Control / ETag / Last-Modified)
// ⇒ 浏览器对它走**启发式缓存**;而外壳里内嵌了带**内容哈希 `rev`** 的插件 bundle URL
// (见下方 `if` 之上那段注释)。插件集合一变、或实例重启重算 rev,
// 旧外壳就会一直去请求**已不存在的 rev** ⇒ 实例按契约返回 **404**
// ⇒ dsh 客户端报 `client-modules: bundle script … failed to load` ⇒ 界面「Failed to load plugins」,
// 且**普通刷新会命中缓存的外壳、复现不消失**(2026-09-14 真实事故:当天连铺 4 次插件 + 2 次重启平台)。
// ⇒ 外壳加 no-cache 后,浏览器每次都会回源取到**当前**的外壳与 rev。
//
// ⚠️ 勿删:这是 UI 改动能否被验收的前置机制(`06-工作台UI规范 §6.5`)。
if (targetPath.startsWith('/plugins/') || targetPath.startsWith('/assets/')) {
if (
targetPath.startsWith('/plugins/') ||
targetPath.startsWith('/assets/') ||
String(headers['content-type'] ?? '').includes('text/html')
) {
headers['cache-control'] = 'no-cache'
}
// local mode only: DSH builds absolute URLs from the loopback Host we