diff --git a/lib/proxy.mjs b/lib/proxy.mjs
index 8e29df1..f85f240 100644
--- a/lib/proxy.mjs
+++ b/lib/proxy.mjs
@@ -841,6 +841,15 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
}
}
const headers = loopbackAuthority({ ...req.headers }, upstream);
+ // HTML 导航请求向上游要**未压缩**文档(issue #150)。
+ // 浏览器导航必然带 Accept-Encoding,dsh web 于是 gzip 压缩 HTML,下面「只给未压缩
+ // HTML 注入」的分支因此永不命中 —— data-dsh-pocket-polyfill(randomUUID /
+ // AbortSignal.any)与 data-dsh-pocket-transport-shim 对真实浏览器全是死代码。
+ // 后果:缺 AbortSignal.any 的内核(Android WebView / 厂商浏览器 < Chrome 116)
+ // 在连接层抛 TypeError,/api/remote.mux 建链即断,页面永久停在「重新连接中」。
+ // 上游若忽略 identity,isCompressed 分支照旧原样透传压缩流,无损坏风险;
+ // JS/CSS 等子资源的压缩透传不受影响(只对 HTML 导航生效)。
+ if (injectHtml && isHtmlRequest(req)) headers['accept-encoding'] = 'identity';
// dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
// 先清掉历史遗留的 dsh-desktop-* 参数(issue #75),再补 launch token
diff --git a/test/proxy.test.js b/test/proxy.test.js
index 77fe8c3..7a13505 100644
--- a/test/proxy.test.js
+++ b/test/proxy.test.js
@@ -393,9 +393,13 @@ test('会话指纹已移除(2.10.0):页面与登录页不再注入指纹/a
});
test('压缩 HTML(gzip)不注入 polyfill——防止损坏压缩流', async () => {
+ // 注意(issue #150):代理现在对 HTML 导航请求带 accept-encoding: identity,
+ // 正常 dsh web 会因此回未压缩文档。本用例覆盖的是**上游忽略 identity** 的兜底路径:
+ // 上游照旧回 gzip 时,代理必须原样透传、绝不注入,否则会损坏压缩流。
const zlib = await import('node:zlib');
const http = await import('node:http');
const up = createServer((req, res) => {
+ // 无条件 gzip:模拟不认 identity 的上游
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-encoding': 'gzip' });
res.end(zlib.gzipSync('
compressed-page'));
});
@@ -422,6 +426,95 @@ test('压缩 HTML(gzip)不注入 polyfill——防止损坏压缩流', async
}
});
+test('issue #150:HTML 导航请求向上游要 identity,注入对真实浏览器生效', async () => {
+ // 真实浏览器导航必然带 Accept-Encoding,dsh web 于是 gzip 压缩 HTML。
+ // 本用例的假上游**尊重 Accept-Encoding**(像 dsh web 一样),用于证明代理现在
+ // 会主动要 identity,从而拿到未压缩文档并完成注入。
+ const zlib = await import('node:zlib');
+ const http = await import('node:http');
+ const seenAcceptEncoding = [];
+ const up = createServer((req, res) => {
+ seenAcceptEncoding.push(req.headers['accept-encoding'] ?? '(none)');
+ const ae = String(req.headers['accept-encoding'] ?? '');
+ const html = Buffer.from('xapp');
+ if (/\bgzip\b/.test(ae)) {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-encoding': 'gzip' });
+ res.end(zlib.gzipSync(html));
+ } else {
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': String(html.length) });
+ res.end(html);
+ }
+ });
+ await new Promise((r) => up.listen(0, '127.0.0.1', r));
+ const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port } });
+ try {
+ // 原始 http.request:模拟真实浏览器导航(带 Accept-Encoding + 移动端 UA),
+ // 用原始字节断言,避免 undici 自动解压干扰。
+ const raw = await new Promise((resolve, reject) => {
+ const req = http.request({
+ host: '127.0.0.1', port: proxy.port, path: '/',
+ headers: {
+ accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'accept-encoding': 'gzip, deflate, br',
+ 'user-agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Chrome/115 Mobile Safari/537.36',
+ },
+ }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve({ headers: res.headers, body: Buffer.concat(chunks) }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+
+ assert.equal(seenAcceptEncoding.at(-1), 'identity', 'HTML 导航向上游要 identity');
+ assert.equal(raw.headers['content-encoding'], undefined, '拿到未压缩文档,无 content-encoding');
+ const html = raw.body.toString('utf8');
+ assert.ok(html.includes('data-dsh-pocket-polyfill="1"'), 'polyfill 已注入真实浏览器导航');
+ assert.ok(html.includes('self.AbortSignal.any'), 'AbortSignal.any 兼容代码随文档下发');
+ assert.ok(html.includes('data-dsh-pocket-transport-shim="1"'), 'transport shim 已注入');
+ assert.ok(html.includes('app'), '原始页面内容完整');
+ } finally {
+ await proxy.close();
+ await new Promise((r) => up.close(r));
+ }
+});
+
+test('issue #150:上游忽略 identity 时,压缩流仍原样透传(不损坏)', async () => {
+ // 与上一条互补:上游不认 identity(照旧回 gzip)时,代理必须退回原有
+ // 「压缩流原样透传、不注入」的行为,绝不能破坏压缩字节。
+ const zlib = await import('node:zlib');
+ const http = await import('node:http');
+ const up = createServer((req, res) => {
+ // 无论 Accept-Encoding 是什么,一律返回 gzip(模拟忽略 identity 的上游)
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-encoding': 'gzip' });
+ res.end(zlib.gzipSync('always-gzip'));
+ });
+ await new Promise((r) => up.listen(0, '127.0.0.1', r));
+ const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.address().port } });
+ try {
+ const raw = await new Promise((resolve, reject) => {
+ const req = http.request({
+ host: '127.0.0.1', port: proxy.port, path: '/',
+ headers: { accept: 'text/html', 'accept-encoding': 'gzip, deflate, br' },
+ }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => resolve({ headers: res.headers, body: Buffer.concat(chunks) }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+ assert.equal(raw.headers['content-encoding'], 'gzip', '压缩头原样透传');
+ assert.ok(raw.body[0] === 0x1f && raw.body[1] === 0x8b, 'gzip 魔数未被破坏');
+ assert.ok(!raw.body.toString('utf8').includes('randomUUID'), '未对压缩流做文本注入');
+ assert.ok(zlib.gunzipSync(raw.body).toString('utf8').includes('always-gzip'), '解压后内容完整');
+ } finally {
+ await proxy.close();
+ await new Promise((r) => up.close(r));
+ }
+});
+
test('活动 WS 连接存在时 close 不挂起(closeAllConnections)', async () => {
const up = await fakeUpstream();
const proxy = await createPocketProxy({ port: 0, host: '127.0.0.1', upstream: { host: '127.0.0.1', port: up.port } });