diff --git a/scripts/verify-inject.cjs b/scripts/verify-inject.cjs index 8cccb5d..29d451f 100644 --- a/scripts/verify-inject.cjs +++ b/scripts/verify-inject.cjs @@ -58,5 +58,21 @@ if (fs.existsSync(PROXY_SRC)) { } } +// 防回退(档案 95):proxy.ts 必须给**模块路径**与**HTML 外壳**都下发 `no-cache`。 +// 由来:`/` 原先不带任何缓存头 ⇒ 浏览器启发式缓存外壳;而外壳内嵌带内容哈希 `rev` 的 +// bundle URL ⇒ 插件集合一变/实例一重启,旧外壳就一直去请求**已不存在的 rev** ⇒ 实例按契约 404 +// ⇒ 界面「Failed to load plugins」,**普通刷新命中缓存的外壳、复现不消失**(2026-09-14 真实事故)。 +{ + const src = fs.readFileSync(PROXY_SRC, 'utf8') + const hasPlugins = /targetPath\.startsWith\('\/plugins\/'\)/.test(src) + const hasHtml = /includes\('text\/html'\)[\s\S]{0,200}cache-control/.test(src) || /text\/html[\s\S]{0,120}'no-cache'/.test(src) + if (!hasPlugins || !hasHtml) { + console.log(` ✗ proxy.ts 缓存治理不完整(模块路径 no-cache=${hasPlugins} / HTML 外壳 no-cache=${hasHtml})⇒ 插件一改用户就会"Failed to load plugins"`) + bad++ + } else { + console.log(' ✓ proxy.ts 缓存治理完整(/plugins/ 与 text/html 均 no-cache)') + } +} + console.log(bad ? '结论:' + bad + ' 项不合格 ❌ —— 别推上线' : '结论:全部合格 ✅') process.exit(bad ? 1 : 0) diff --git a/src/supervisor/proxy.ts b/src/supervisor/proxy.ts index 2de5e7f..0d9d633 100644 --- a/src/supervisor/proxy.ts +++ b/src/supervisor/proxy.ts @@ -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