From cc1dc5c9a8d0919b39b87b6b7655b623586d1905 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Mon, 14 Sep 2026 22:46:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(proxy):=20/plugins/=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E8=A1=A5=20ETag=20+=20304=20=E7=9F=AD?= =?UTF-8?q?=E8=B7=AF=20=E2=80=94=E2=80=94=20=E7=9C=81=E6=8E=89=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=2011=20MB=20=E9=87=8D=E4=B8=8B=EF=BC=88=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=2097=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题(实测 2026-09-14):官方 `@deepseek-ai/dsh-client-modules` 把全部客户端插件拼成**一条 11,172,365 字节的脚本**(combo URL `/plugins/??<模块列表>&rev=`);我们给它下发 `no-cache`(档案 95,为"改了 UI 就能看到"),而实例**既不给 ETag 也不给 Last-Modified** ⇒ 浏览器"回源校验"退化成**每次全量重下 11 MB** —— 经 Cloudflare 实测 **114.87 秒** (弱网/手机上直接表现为页面加载不出来)。 修法(只动 proxy.ts 一处,不改官方、不动 rev): · 按 URL 派生强校验器 `ETag = sha1(targetPath)`,**只对 GET/HEAD + /plugins/ 且 URL 含 `rev=` 生效** (无 rev 一律跳过)。依据是官方契约:「**已公告响应不可变;未知组合或 revision 返回 404**」 ⇒ (模块组合, rev) 唯一决定内容 ⇒ 同 URL 必同字节,故按 URL 派生 ETag 是安全的。 · 命中 `If-None-Match` 时 **直接回 304、完全不回源**(省的正是那 11 MB)。 · 未命中时行为与原先一致,只在既有缓存头区块多透出一个 `ETag`。 验证: · 本机 `npm run verify` EXIT=0;服务器 `ci.sh` CI OK(48 pass / 0 fail); · `scripts/verify-inject.cjs` 新增防回退断言「proxy.ts 有 /plugins/ ETag + 304 短路」; · **端到端实测**:① 首次 200 / 11,172,365 B + `ETag="034a459a92…"`;② 带 If-None-Match → **304 / 0 B** ✅ ⚠️ 顺带记录:本仓库 `core.autocrlf` 会在 git 触碰后把工作区文件改成 CRLF ⇒ 脚本化改文件必须 **行尾自适应**(本轮 `proxy.ts` 的多行匹配因此失配过一次)。 --- scripts/verify-inject.cjs | 12 ++++++++++++ src/supervisor/proxy.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/scripts/verify-inject.cjs b/scripts/verify-inject.cjs index 29d451f..6cfb7c3 100644 --- a/scripts/verify-inject.cjs +++ b/scripts/verify-inject.cjs @@ -72,6 +72,18 @@ if (fs.existsSync(PROXY_SRC)) { } else { console.log(' ✓ proxy.ts 缓存治理完整(/plugins/ 与 text/html 均 no-cache)') } + // 档案 97:/plugins/ 必须有**由 URL 派生的 ETag + 304 短路** —— + // 那条合并脚本 11 MB、未压缩,只靠 no-cache 会让浏览器**每次全量重下** + // (实测经 CF 114.87 s)。rev 即修订标识 ⇒ 用 URL 派生 ETag 安全。 + const hasEtag = /const pluginEtag =/.test(src) && /createHash\('sha1'\).update\(targetPath\)/.test(src) + const hasShort = /writeHead\(304, \{ etag: pluginEtag/.test(src) + const etagOut = /headers\.etag = pluginEtag/.test(src) + if (!hasEtag || !hasShort || !etagOut) { + console.log(` ✗ proxy.ts 缺 /plugins/ 的 ETag 条件请求短路(etag=${hasEtag} 304短路=${short} 透出=${etagOut})`.replace('${short}', String(hasShort))) + bad++ + } else { + console.log(' ✓ proxy.ts 有 /plugins/ ETag + 304 短路(省掉每次 11 MB 重下)') + } } console.log(bad ? '结论:' + bad + ' 项不合格 ❌ —— 别推上线' : '结论:全部合格 ✅') diff --git a/src/supervisor/proxy.ts b/src/supervisor/proxy.ts index 0d9d633..72dc897 100644 --- a/src/supervisor/proxy.ts +++ b/src/supervisor/proxy.ts @@ -15,6 +15,7 @@ import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { Agent, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http' import { connect } from 'node:net' +import { createHash } from 'node:crypto' import { hashSessionToken, parseCookie } from '../web/auth.js' import { requireAuth } from '../web/middleware/authn.js' import type { Endpoint } from './spawner.js' @@ -275,6 +276,31 @@ function proxyHttp( let authRetryUsed = false let replayCookies: string[] = [] + // ── 档案 97:`/plugins/` 合并脚本的**条件请求短路**(ETag → 304)────────────── + // 背景(实测 2026-09-14):那条把全部客户端插件拼起来的脚本 **11,172,365 B、未压缩**, + // 而我们对它下发 `no-cache`(档案 95,为"改了 UI 就能看到");实例既不给 `ETag` + // 也不给 `Last-Modified` ⇒ 浏览器"回源校验"退化成**每次全量重下 11 MB** + // (经 Cloudflare 实测 **114.87 s**;弱网/手机上直接表现为页面加载不出来)。 + // 本块 = 平台侧补一个**由 URL 派生**的强校验器(不改官方、不改实例、不动 rev): + // · 官方 `@deepseek-ai/dsh-client-modules` 的契约是 + // 「**已公告响应不可变;未知组合或 revision 返回 404**」⇒ `(模块组合, rev)` 唯一决定内容, + // 同一 URL 永远同一份字节 ⇒ 用 URL 派生 ETag 是**安全**的; + // · 只对 **GET/HEAD + `/plugins/` 且 URL 含 `rev=`** 生效(无 rev 的一律跳过,绝不冒险); + // · 命中 `If-None-Match` 时**直接回 304、完全不回源**(省掉的正是那 11 MB)。 + // · 未命中时行为与原先一致,只多一个 `ETag` 响应头(见响应头区块)。 + const pluginEtag = + (reqMethod === 'GET' || reqMethod === 'HEAD') && targetPath.startsWith('/plugins/') && targetPath.includes('rev=') + ? '"' + createHash('sha1').update(targetPath).digest('hex') + '"' + : undefined + if (pluginEtag !== undefined) { + const inm = request.headers['if-none-match'] + if (typeof inm === 'string' && inm.split(',').some((v) => v.trim() === pluginEtag)) { + reply.raw.writeHead(304, { etag: pluginEtag, 'cache-control': 'no-cache' }) + reply.raw.end() + return + } + } + const attempt = (connRetry: boolean, authCookie?: string): void => { // 档案 50 修正(实测定位):**HTML 导航请求必须向上游要 identity**。 // 原因:dsh 实例对带 Accept-Encoding 的请求会 **gzip 压缩 HTML**(浏览器就带), @@ -396,6 +422,8 @@ function proxyHttp( ) { headers['cache-control'] = 'no-cache' } + // 档案 97:把派生 ETag 一并透出,浏览器下次才能用 If-None-Match 换 304。 + if (pluginEtag !== undefined) headers.etag = pluginEtag // local mode only: DSH builds absolute URLs from the loopback Host we // forward; rewrite any 127.0.0.1 Location to the real origin so the // browser doesn't jump to the user's own machine. k8s mode keeps its