@mrrisega/dsh-remote 0.6.13 → 0.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +124 -14
  2. package/clients/dsh-remote/dsh-events.mjs +22 -2
  3. package/clients/dsh-remote/e2ee-client.mjs +97 -13
  4. package/clients/dsh-remote/e2ee-shim-script.js +31 -3
  5. package/clients/dsh-remote/mobile-adapter.mjs +283 -1
  6. package/clients/dsh-remote/test/boot-tip.test.mjs +158 -0
  7. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  8. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  9. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  10. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  11. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +18 -1
  12. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  13. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  14. package/clients/dsh-remote/test/upstream-discovery.test.mjs +196 -0
  15. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  16. package/clients/dsh-remote/upstream-discovery.mjs +448 -0
  17. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  18. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  19. package/dsh-setup.mjs +225 -23
  20. package/package.json +1 -1
  21. package/packages/dsh-remote-web/lib/client.js +31 -1
  22. package/packages/dsh-remote-web/lib/index.js +464 -15
  23. package/packages/dsh-remote-web/package.json +1 -1
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +124 -14
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +283 -1
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +448 -0
  30. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  31. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  32. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +225 -23
  33. package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +16 -1
  34. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  35. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  36. package/packages/dsh-remote-web/test/picker-pin.test.mjs +201 -0
  37. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
  38. package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +170 -0
@@ -76,6 +76,7 @@ import { promisify } from "node:util";
76
76
  import { gzip as gzipCb } from "node:zlib";
77
77
  // 移动端适配层(经隧道访问的官方 dsh web 窄屏注入;DSH_MOBILE_ADAPTER=0 可关闭,默认开启)
78
78
  import { maybeInjectMobileAdapter } from "./mobile-adapter.mjs";
79
+ import { discoverUpstream, resolveUpstreamHint, FALLBACK_UPSTREAM } from "./upstream-discovery.mjs";
79
80
  // 镜像页 E2EE 加密 shim(Phase-4):text/html 注入;DSH_E2EE_SHIM=0 可关闭,叠加 e2ee.enabled 灰度门
80
81
  import { maybeInjectE2eeShim } from "./e2ee-shim.mjs";
81
82
  // E2EE(端到端加密)客户端基建(Phase-2):MK 派生/会话密钥/信封/握手/开关
@@ -88,6 +89,8 @@ import {
88
89
  encodeHttpResponsePlain,
89
90
  hasEnvelopeMarker,
90
91
  parseWsE2eeParams,
92
+ stripPlainWantHeader,
93
+ wantsBinaryResponsePlain,
91
94
  writeE2eeStateFile
92
95
  } from "./e2ee-client.mjs";
93
96
  // 微信机器人通道(编排层):绑定控制面 + 出站通知路由 + 入站长轮询。
@@ -114,7 +117,23 @@ const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
114
117
  // 隧道模式(唯一):bridge 主动 WS 连 relay-router 的 /_bridge
115
118
  const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "");
116
119
  const TUNNEL_HEARTBEAT_MS = Math.max(100, Number(process.env.DSH_BRIDGE_HEARTBEAT_MS) || 15_000);
117
- const UPSTREAM = process.env.DSH_BRIDGE_UPSTREAM || "http://127.0.0.1:3080";
120
+ /**
121
+ * 上游 dsh web 的地址。
122
+ *
123
+ * ⚠️ 这里**不能**只信环境变量 + 写死 3080(2026-09-23 两起用户实测):
124
+ * ① `dsh web --port 8090` / `--port 0`(系统分配)→ 3080 没人听;
125
+ * ② **DSH Desktop**(Electron 壳 `dsh-plugin-desktop`)默认 43120,被占用还会 +1。
126
+ * bridge 与 watcher 共用同一份发现实现(`upstream-discovery.mjs`):
127
+ * 显式环境变量 > `<relayDir>/.dsh-upstream`(插件半用 ctx.webServer.port 落盘)> DSH_WEB_URL
128
+ * > **动态发现**(本机 dsh 进程实际监听端口 + Desktop 端口区间,且每个候选都做身份校验)。
129
+ *
130
+ * 用 `let` 是因为:① 发现发生在启动之后(异步);② 上游端口变了要能在**不重启 bridge** 的前提下跟上。
131
+ */
132
+ const RELAY_DIR = path.dirname(CONFIG_PATH);
133
+ let UPSTREAM = (() => {
134
+ const hint = resolveUpstreamHint({ relayDir: RELAY_DIR });
135
+ return hint.url || FALLBACK_UPSTREAM;
136
+ })();
118
137
  // 微信机器人通道总开关:DSH_WECHAT=0 关闭(默认开启)。控制面只 bind 回环,且必须带 bridge_secret。
119
138
  const WECHAT_DISABLED = String(process.env.DSH_WECHAT || "") === "0";
120
139
  // 默认云端服务地址(dsh-remote setup 会显式传入;自建模式无需账号 API)
@@ -385,6 +404,37 @@ const STRIP_RES_HEADERS = new Set([
385
404
  "connection", "keep-alive", "upgrade"
386
405
  ]);
387
406
 
407
+
408
+ /** 上游重新发现的冷却:失败请求可能连成片,不能每个都去 lsof/pgrep + 探端口。 */
409
+ const UPSTREAM_REFRESH_COOLDOWN_MS = 15_000;
410
+ let upstreamRefreshedAt = 0;
411
+ let upstreamRefreshInflight = null;
412
+ /**
413
+ * 重新解析上游地址(带身份校验;显式 DSH_BRIDGE_UPSTREAM 时是恒等操作)。
414
+ * 上游端口变了(Desktop 换端口 / dsh web 重启到别的端口)时,靠它自愈而不必重启 bridge。
415
+ */
416
+ async function refreshUpstream(reason = "") {
417
+ if (upstreamRefreshInflight) return upstreamRefreshInflight;
418
+ const now = Date.now();
419
+ if (now - upstreamRefreshedAt < UPSTREAM_REFRESH_COOLDOWN_MS) return UPSTREAM;
420
+ upstreamRefreshedAt = now;
421
+ upstreamRefreshInflight = (async () => {
422
+ try {
423
+ const found = await discoverUpstream({ relayDir: RELAY_DIR });
424
+ if (found.url && found.url !== UPSTREAM) {
425
+ console.log(`[bridge] 上游地址切换: ${UPSTREAM} → ${found.url}(来源 ${found.source}${reason ? `,${reason}` : ""})`);
426
+ UPSTREAM = found.url;
427
+ }
428
+ return UPSTREAM;
429
+ } catch {
430
+ return UPSTREAM;
431
+ } finally {
432
+ upstreamRefreshInflight = null;
433
+ }
434
+ })();
435
+ return upstreamRefreshInflight;
436
+ }
437
+
388
438
  console.log(`[bridge] 设备 ${DEVICE_ID} → 隧道 ${TUNNEL_URL}/_bridge`);
389
439
  console.log(`[bridge] 上游 ${UPSTREAM}`);
390
440
 
@@ -752,6 +802,34 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
752
802
  };
753
803
  }
754
804
 
805
+ /**
806
+ * 构造 e2ee http 响应帧(**导出给用例**:第三层 base64 的去留必须有测试钉住)。
807
+ *
808
+ * 【0.6.15】这里曾经是 `body: Buffer.from(JSON.stringify(respEnv)).toString("base64")`
809
+ * + `bodyBase64: true` —— 也就是把整个信封 JSON 再 base64 一次。中继拿到它做的第一件事
810
+ * 就是解回 UTF-8 再写给手机,所以这一层**纯属白花 33% 流量**,而且正好落在按流量计量的
811
+ * bridge→中继 那条 WS 上(业主实测那条聚合包:12.69 MiB vs 直接发 UTF-8 的 9.5 MiB)。
812
+ * 中继对两种形态都支持(`frame.bodyBase64` 真假之分),所以直接发 UTF-8 明文即可。
813
+ *
814
+ * @param {string|number} id 帧 id
815
+ * @param {string} sessId E2EE 会话 id(只进标记头)
816
+ * @param {object} respEnv 已封好的响应信封
817
+ * @returns {{id:any,type:string,status:number,headers:object,body:string,bodyBase64:boolean}}
818
+ */
819
+ export function e2eeHttpResponseFrame(id, sessId, respEnv) {
820
+ return {
821
+ id,
822
+ type: "http",
823
+ status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
824
+ headers: {
825
+ "content-type": ENVELOPE_CONTENT_TYPE,
826
+ "x-dsh-e2ee": `v=2;s=${sessId};k=http-resp`
827
+ },
828
+ body: JSON.stringify(respEnv),
829
+ bodyBase64: false // ★ 第三层 base64 在这里被去掉(见上)
830
+ };
831
+ }
832
+
755
833
  export async function handleHttpFrame(dchOrSend, frame) {
756
834
  const send = toSender(dchOrSend);
757
835
  const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
@@ -779,22 +857,19 @@ export async function handleHttpFrame(dchOrSend, frame) {
779
857
  const session = e2ee.guardHttpRequest(String(env?.s || ""), env);
780
858
  const opened = session.open({ kind: "http", dir: "p2b", env, counter: env?.c ?? 0 });
781
859
  const req = decodeHttpRequestPlain(opened.data);
782
- const reply = await doHttp(req.method, req.path, req.headers, req.bodyB64, true);
860
+ // 客户端是否声明"响应用二进制明文框架"(省掉正文那一层 base64,见 e2ee-client.mjs 长注释)。
861
+ // 老客户端(native.html 的 WC-CORE / 企业版内置那份)不声明 → 照旧走 JSON,零影响。
862
+ // ★ 这个头**无条件**摘掉再转发:它是端到端内部协商头,不是业务头,不该出现在上游请求里
863
+ // (哪怕客户端给了个我们看不懂的值,也不能原样漏给 dsh web)。
864
+ const wantBinPlain = wantsBinaryResponsePlain(req.headers);
865
+ const upstreamHeaders = stripPlainWantHeader(req.headers);
866
+ const reply = await doHttp(req.method, req.path, upstreamHeaders, req.bodyB64, true);
783
867
  const bodyBuffer = Buffer.from(reply.body, "base64");
784
- const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer });
868
+ const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer, binary: wantBinPlain });
785
869
  // 响应压缩已发生在 doHttp(明文侧,gzip 在加密前 §4.7);信封用 http-resp kind
786
870
  const respEnv = session.seal({ kind: "http-resp", dir: "b2p", counter: env?.c ?? 0, data: plain, reqNonceB64: String(env?.n || "") });
787
- send({
788
- id,
789
- type: "http",
790
- status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
791
- headers: {
792
- "content-type": ENVELOPE_CONTENT_TYPE,
793
- "x-dsh-e2ee": `v=2;s=${env.s};k=http-resp`
794
- },
795
- body: Buffer.from(JSON.stringify(respEnv)).toString("base64"),
796
- bodyBase64: true
797
- });
871
+ // ★ 0.6.15:**不再**把整个信封 JSON 再 base64 一次(第三层)。理由见 e2eeHttpResponseFrame。
872
+ send(e2eeHttpResponseFrame(id, env.s, respEnv));
798
873
  console.log(`[bridge] e2ee http ${req.method} ${req.path} → ${reply.status} (${Date.now() - t0}ms, 信封 ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
799
874
  return;
800
875
  } catch (e) {
@@ -816,6 +891,9 @@ export async function handleHttpFrame(dchOrSend, frame) {
816
891
  send(reply);
817
892
  console.log(`[bridge] ${method} ${path} → ${reply.status} (${Date.now() - t0}ms, ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
818
893
  } catch (e) {
894
+ // 上游不可达是「端口可能变了」的最强信号(Desktop 换端口 / dsh web 重启到别的端口):
895
+ // 触发一次带冷却的重新发现,后续请求自动跟上,不必重启 bridge。
896
+ void refreshUpstream("上游请求失败");
819
897
  console.log(`[bridge] ${method} ${path} 上游错误: ${e.message}`);
820
898
  send({ id, type: "http", status: 502, headers: { "content-type": "application/json" }, body: Buffer.from(JSON.stringify({ error: String(e.message || e) })).toString("base64"), bodyBase64: true });
821
899
  }
@@ -877,6 +955,7 @@ export async function handleLegacyFrame(dchOrSend, frame) {
877
955
  send({ id, status: reply.status, headers: reply.headers, body: text });
878
956
  console.log(`[bridge] legacy ${method} ${path} → ${reply.status} (${Date.now() - t0}ms)`);
879
957
  } catch (e) {
958
+ void refreshUpstream("上游请求失败(legacy)");
880
959
  console.log(`[bridge] legacy ${method} ${path} 上游错误: ${e.message}`);
881
960
  send({ id, status: 502, headers: {}, body: JSON.stringify({ error: String(e.message || e) }) });
882
961
  }
@@ -1469,6 +1548,9 @@ function startWeChat() {
1469
1548
  const cfg = loadLocalConfig();
1470
1549
  wechatRuntime = createWeChatRuntime({
1471
1550
  relayDir,
1551
+ // 上游地址在 runTunnel() 开头已完成动态发现(见 refreshUpstream),所以这里拿到的是真实端口。
1552
+ // ⚠️ 这是**取值**而非引用:运行中若上游端口又变了(自愈重发现),微信通道要等 bridge 重启才跟上;
1553
+ // 隧道转发那一侧不受影响(它每次都读最新的 UPSTREAM)。
1472
1554
  upstream: UPSTREAM,
1473
1555
  cookieOf: harnessCookieOf,
1474
1556
  secret: process.env.DSH_BRIDGE_SECRET || (typeof cfg.bridge_secret === "string" ? cfg.bridge_secret : ""),
@@ -1514,6 +1596,10 @@ function redactText(e) {
1514
1596
  }
1515
1597
 
1516
1598
  async function runTunnel() {
1599
+ // 先动态确定上游再连隧道:上游端口不是 3080 时(DSH Desktop 默认 43120)这一步是关键 ——
1600
+ // 否则 bridge 会连上中继、手机也能打开页面,但每个请求都打到一个没人听的端口。
1601
+ await refreshUpstream("启动");
1602
+ console.log(`[bridge] 上游地址已确定: ${UPSTREAM}`);
1517
1603
  const token = await resolveToken();
1518
1604
  if (!token) {
1519
1605
  console.error("[bridge] 隧道模式需要账号认证:请设 DSH_BRIDGE_TOKEN,或 DSH_BRIDGE_PHONE+DSH_BRIDGE_PASSWORD");
@@ -1535,7 +1621,31 @@ async function runTunnel() {
1535
1621
  }, 1000);
1536
1622
  }
1537
1623
 
1624
+ /**
1625
+ * 给 stdout/stderr 的每一行加 ISO 时间戳。
1626
+ *
1627
+ * 【2026-09-25 事故】bridge 日志**没有任何时间戳**,于是排查
1628
+ * 「任务跑完没收到微信推送」时完全无法把日志和事件对上时间:
1629
+ * 不知道最后一条 `sendmessage 失败` 发生在用户的回话之前还是之后,
1630
+ * 最后只能靠"数行数 ÷ 每分钟行数"去估时间(估出来的结论还差点搞反方向)。
1631
+ * 加了时间戳之后,这类问题一眼可读。只影响本进程的输出(日志由 watcher 重定向到 .dsh-bridge.log)。
1632
+ */
1633
+ function stampConsoleLines() {
1634
+ for (const level of ["log", "info", "warn", "error"]) {
1635
+ const original = console[level]?.bind(console);
1636
+ if (typeof original !== "function") continue;
1637
+ console[level] = (...args) => {
1638
+ try {
1639
+ original(`[${new Date().toISOString()}]`, ...args);
1640
+ } catch {
1641
+ original(...args);
1642
+ }
1643
+ };
1644
+ }
1645
+ }
1646
+
1538
1647
  async function main() {
1648
+ stampConsoleLines();
1539
1649
  // 隧道模式是唯一模式(WebRTC/信令已废弃删除)
1540
1650
  if (!TUNNEL_URL) {
1541
1651
  console.error("[bridge] 缺少 DSH_BRIDGE_TUNNEL_URL:隧道模式是唯一模式(请设 relay-router 地址)");
@@ -951,6 +951,13 @@ class EventSubscriber extends EventEmitter {
951
951
  clearTimeout(entry.settleTimer);
952
952
  entry.settleTimer = null;
953
953
  }
954
+ // ★ 2026-09-25:一次 follow 失败不该把这条会话**永久**钉死。
955
+ // 旧行为是失败即 `#followBlocked.add()`,此后**本代**(直到 mux 重连)再也不订阅它 ——
956
+ // 而「这一轮又跑起来了」(status:true)恰恰是重新订阅的正当理由。
957
+ // 不清掉的话:一次 `session/agent-busy`/`session/not-found` 抖动 = 该会话这一整轮的
958
+ // turn/end 全部丢失 = 完成推送静默消失(与上面定时任务那个自我关停是同一类事故)。
959
+ // 重试天然有界:一次 running 边沿最多触发一次重新订阅,不会打转。
960
+ this.#followBlocked.delete(sessionId);
954
961
  this.#ensureFollow(sessionId);
955
962
  } else {
956
963
  this.#running.delete(sessionId);
@@ -1092,8 +1099,21 @@ class EventSubscriber extends EventEmitter {
1092
1099
  const timer = setTimeout(() => {
1093
1100
  this.#discoverTimer = null;
1094
1101
  if (this.#closed || this.#state !== "ready") return;
1095
- // 没有 follow 流就没有可对账的东西;但**每代**至少发现过一次(连接时就做过了)。
1096
- if (this.#follows.size > 0) void this.discoverRunningSessions().catch(() => {});
1102
+ // ★ 2026-09-25 事故修复:这里原本写成 `if (this.#follows.size > 0)`,注释是
1103
+ // 「没有 follow 流就没有可对账的东西(每代至少发现过一次)」。
1104
+ // 它漏掉了这条定时任务的**另一半职责:发现**。丢一次边沿事件(mux 重连、status:true
1105
+ // 早于我们订阅、帧被丢)就会让 `#follows` 长期为 0,而这条任务又因为「0 个流」把自己关掉
1106
+ // → **从此再也不发现任何会话** → 该会话的 turn/end 永远收不到 → 完成推送**静默消失**。
1107
+ //
1108
+ // 真机现场(业主本人,2026-09-25):任务跑完一条微信都没收到,bridge 日志里也
1109
+ // **没有任何报错**(因为确实什么都没发生)。活体探针(以微信通道同款方式订阅本机)显示:
1110
+ // · 新起的订阅器 **163ms** 就发现并 follow 了正在跑的会话 —— 机制本身是好的;
1111
+ // · 而当时在跑的 bridge 进程到 3080 只有 **2 条** WS(mux + control)、**没有 follow 流**,
1112
+ // 可它明明有一个正在跑长任务的会话 —— 正是这里的自我关停把发现能力锁死了。
1113
+ //
1114
+ // 代价:一次 `session/list`(本机实测 291 个会话约 200ms)。每 120s 一次完全承受得起,
1115
+ // 换来的是「丢了边沿也能在两分钟内自愈」。
1116
+ void this.discoverRunningSessions().catch(() => {});
1097
1117
  this.#scheduleDiscovery();
1098
1118
  }, interval);
1099
1119
  timer.unref?.();
@@ -469,9 +469,12 @@ export function headerValueOf(headers, name) {
469
469
  * @returns {{method:string, path:string, headers:object, bodyB64:string, hasBody:boolean}}
470
470
  */
471
471
  export function decodeHttpRequestPlain(buf) {
472
+ // ⚠️ 不能写 `String(buf)`:手机端 shim 交出来的是 Uint8Array,String() 会得到 "123,34,...",
473
+ // 结果永远是"不是 JSON"(0.6.15 对拍用例抓到的真问题)。
474
+ const raw = Buffer.isBuffer(buf) ? buf : (typeof buf === "string" ? Buffer.from(buf, "utf8") : Buffer.from(buf ?? []));
472
475
  let pt;
473
476
  try {
474
- pt = JSON.parse(Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf));
477
+ pt = JSON.parse(raw.toString("utf8"));
475
478
  } catch {
476
479
  throw new E2eeError("bad_plain", "e2ee: http 请求明文不是 JSON");
477
480
  }
@@ -479,8 +482,8 @@ export function decodeHttpRequestPlain(buf) {
479
482
  const method = typeof pt.m === "string" && pt.m ? pt.m.toUpperCase() : "GET";
480
483
  const path = typeof pt.p === "string" && pt.p ? pt.p : "/";
481
484
  const headers = pt.h && typeof pt.h === "object" ? pt.h : {};
482
- const b = typeof pt.b === "string" ? pt.b : "";
483
- return { method, path, headers, bodyB64: b, hasBody: b !== "" };
485
+ const bodyB64 = typeof pt.b === "string" ? pt.b : "";
486
+ return { method, path, headers, bodyB64, hasBody: bodyB64 !== "" };
484
487
  }
485
488
 
486
489
  /** 编码请求信封明文(测试端/手机侧对称实现用;b 一律为 base64 正文)。 */
@@ -490,13 +493,60 @@ export function encodeHttpRequestPlain({ method = "GET", path = "/", headers = {
490
493
  }
491
494
 
492
495
  /**
493
- * 响应信封明文(§4.3):{ "st":status, "h":{头, 去 content-length/encoding}, "enc":"gzip|", "b":<b64> }
494
- * @returns {{status:number, headers:object, enc:string, bodyBuffer:Buffer}}
496
+ * 响应信封明文(§4.3)。
497
+ *
498
+ * 两种形态(**自动识别**,由首字节判别,解码方无需知道对方是哪个版本):
499
+ *
500
+ * ① 旧/兼容 JSON:`{ "st", "h", "enc", "b":<base64 正文> }` —— 首字节 `{`(0x7B);
501
+ * ② 新二进制框架(0.6.15):`[0x02][4B 大端头长度][头 JSON][正文原始字节]` —— 首字节 0x02。
502
+ *
503
+ * ## 为什么要有 ②(这是"打开慢几分钟"的直接原因之一)
504
+ *
505
+ * 同一条响应在链路上被 base64 编码了**三层**(每层 +33%,叠起来 2.37 倍),其中第一层就是这里:
506
+ * gzip 后的正文塞进 JSON 只能 base64。以实测那条首屏聚合包(gzip 后 5.35 MiB)为例:
507
+ *
508
+ * gzip 正文 5.35 MiB
509
+ * ① base64 进 JSON 7.14 MiB ← 本函数(旧形态)
510
+ * ② AES-GCM 密文 base64url 9.52 MiB
511
+ * ③ 信封 JSON base64 12.69 MiB ← 隧道帧(bridge→中继,见 dsh-bridge.mjs)
512
+ *
513
+ * ②用"头 JSON + 正文原样跟在其后"的框架,**第一层 33% 直接消失**:明文 = 5.35 MiB + 几百字节头。
514
+ * 手机端下载量随之从 1.78× 降到 1.33×(9.52 → 7.14 MiB),中继那侧的计量也同步下降。
515
+ *
516
+ * ## 为什么是"客户端声明才用"
517
+ *
518
+ * 明文框架是**端到端**格式:老的手机客户端(`clients/dsh-web/native.html` 抽出的 WC-CORE、
519
+ * 企业版内置的那份)只认旧 JSON。所以改成**由客户端在请求里声明**(`x-dsh-e2ee-want: bin`),
520
+ * 桥端只对声明过的请求用新框架 —— 老客户端一个字都不用改,也不会收到看不懂的字节。
521
+ *
522
+ * @returns {{status:number, headers:object, enc:string, bodyBuffer:Buffer, bodyB64:string}}
523
+ * `bodyBuffer` 是原始字节(新旧两种形态都填);`bodyB64` 仅旧 JSON 形态填
524
+ * (兼容既有调用方/对拍用例;二进制形态**不**额外做一次 base64,那正是要省掉的开销)。
495
525
  */
496
526
  export function decodeHttpResponsePlain(buf) {
527
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf ?? []);
528
+ if (b.length >= 5 && b[0] === PLAIN_BIN_MAGIC) {
529
+ const len = b.readUInt32BE(1);
530
+ if (len > 0 && 5 + len <= b.length) {
531
+ let head;
532
+ try {
533
+ head = JSON.parse(b.subarray(5, 5 + len).toString("utf8"));
534
+ } catch {
535
+ throw new E2eeError("bad_plain", "e2ee: http 响应明文头不是 JSON");
536
+ }
537
+ if (!head || typeof head !== "object") throw new E2eeError("bad_plain", "e2ee: http 响应明文缺失");
538
+ return {
539
+ status: Number(head.st) || 502,
540
+ headers: head.h && typeof head.h === "object" ? head.h : {},
541
+ enc: typeof head.enc === "string" ? head.enc : "",
542
+ bodyBuffer: b.subarray(5 + len), // 视图,零拷贝
543
+ bodyB64: ""
544
+ };
545
+ }
546
+ }
497
547
  let pt;
498
548
  try {
499
- pt = JSON.parse(Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf));
549
+ pt = JSON.parse(b.toString("utf8"));
500
550
  } catch {
501
551
  throw new E2eeError("bad_plain", "e2ee: http 响应明文不是 JSON");
502
552
  }
@@ -512,11 +562,36 @@ export function decodeHttpResponsePlain(buf) {
512
562
  throw new E2eeError("bad_plain", "e2ee: http 响应 body base64 非法");
513
563
  }
514
564
  }
515
- return { status, headers, enc, bodyBuffer };
565
+ return { status, headers, enc, bodyBuffer, bodyB64: typeof pt.b === "string" ? pt.b : "" };
566
+ }
567
+
568
+ /** 二进制明文框架的魔数(JSON 明文以 `{`=0x7B 开头,0x02 永不冲突)。 */
569
+ export const PLAIN_BIN_MAGIC = 0x02;
570
+ /** 客户端在**加密请求明文**里声明"我的响应明文请用二进制框架"的头(见上方长注释)。 */
571
+ export const PLAIN_WANT_BIN_HEADER = "x-dsh-e2ee-want";
572
+ export const PLAIN_WANT_BIN_VALUE = "bin";
573
+
574
+ /** 请求头里是否声明了"响应用二进制明文框架"(大小写不敏感)。 */
575
+ export function wantsBinaryResponsePlain(headers) {
576
+ const v = headerValueOf(headers, PLAIN_WANT_BIN_HEADER);
577
+ return String(v).toLowerCase().split(/[\s,]+/).includes(PLAIN_WANT_BIN_VALUE);
516
578
  }
517
579
 
518
- /** 编码响应信封明文(status/headers/原始正文;content-encoding 提取到 enc)。 */
519
- export function encodeHttpResponsePlain({ status = 200, headers = {}, bodyBuffer = Buffer.alloc(0) }) {
580
+ /** 从请求头里摘掉那个"内部协商"头(它绝不能转发给上游 dsh web)。返回新的头对象(不改原对象)。 */
581
+ export function stripPlainWantHeader(headers) {
582
+ const out = {};
583
+ for (const [k, v] of Object.entries(headers || {})) {
584
+ if (String(k).toLowerCase() === PLAIN_WANT_BIN_HEADER) continue;
585
+ out[k] = v;
586
+ }
587
+ return out;
588
+ }
589
+
590
+ /**
591
+ * 编码响应信封明文(status/headers/原始正文;content-encoding 提取到 enc)。
592
+ * @param {boolean} [o.binary] true = 用二进制框架(省掉正文那一层 base64;仅客户端声明时使用)
593
+ */
594
+ export function encodeHttpResponsePlain({ status = 200, headers = {}, bodyBuffer = Buffer.alloc(0), binary = false }) {
520
595
  const h = {};
521
596
  let enc = "";
522
597
  for (const [k, v] of Object.entries(headers || {})) {
@@ -527,11 +602,20 @@ export function encodeHttpResponsePlain({ status = 200, headers = {}, bodyBuffer
527
602
  }
528
603
  h[k] = Array.isArray(v) ? v.join(", ") : String(v);
529
604
  }
605
+ const head = { st: Number(status) || 200, h, enc };
606
+ const body = Buffer.isBuffer(bodyBuffer) ? bodyBuffer : Buffer.alloc(0);
607
+ if (binary) {
608
+ const headBuf = Buffer.from(JSON.stringify(head), "utf8");
609
+ const out = Buffer.allocUnsafe(5 + headBuf.length + body.length);
610
+ out[0] = PLAIN_BIN_MAGIC;
611
+ out.writeUInt32BE(headBuf.length, 1);
612
+ headBuf.copy(out, 5);
613
+ body.copy(out, 5 + headBuf.length);
614
+ return out;
615
+ }
530
616
  return Buffer.from(JSON.stringify({
531
- st: Number(status) || 200,
532
- h,
533
- enc,
534
- b: Buffer.isBuffer(bodyBuffer) && bodyBuffer.length ? bodyBuffer.toString("base64") : ""
617
+ ...head,
618
+ b: body.length ? body.toString("base64") : ""
535
619
  }), "utf8");
536
620
  }
537
621
 
@@ -211,12 +211,39 @@
211
211
  };
212
212
 
213
213
  /* ---- http 明文载荷编解码(与 node encode/decodeHttp*Plain 同构) ---- */
214
+ /* 0.6.15:响应明文支持二进制框架(首字节 0x02),省掉"gzip 正文 → base64"那一层(+33%)。
215
+ 由**本客户端主动声明**(请求明文里的 x-dsh-e2ee-want: bin),桥端只对声明过的请求用它 ——
216
+ 老客户端/企业版内置的那份不认识这个头,也就永远收不到看不懂的字节。 */
217
+ var SE_PLAIN_BIN_MAGIC = 2;
218
+ var SE_WANT_BIN_HEADER = "x-dsh-e2ee-want";
214
219
  function seEncodeHttpReqPlain(opt) {
215
220
  var b = opt.bodyBytes && opt.bodyBytes.length ? seBytesToB64(opt.bodyBytes) : "";
216
- return seUtf8(JSON.stringify({ m: String(opt.method || "GET").toUpperCase(), p: opt.path, h: opt.headers || {}, b: b }));
221
+ var src = opt.headers || {};
222
+ var h = {};
223
+ for (var k in src) { if (Object.prototype.hasOwnProperty.call(src, k)) h[k] = src[k]; }
224
+ h[SE_WANT_BIN_HEADER] = "bin"; // 只加在信封明文里(外层 HTTP 头不带它)
225
+ return seUtf8(JSON.stringify({ m: String(opt.method || "GET").toUpperCase(), p: opt.path, h: h, b: b }));
217
226
  }
218
227
  function seDecodeHttpRespPlain(buf) {
219
- var text = buf instanceof Uint8Array ? seUtf8Decode(buf) : String(buf);
228
+ var u8 = buf instanceof Uint8Array ? buf : new Uint8Array(0);
229
+ // ① 二进制框架:[0x02][4B 大端头长度][头 JSON][正文原始字节]
230
+ if (u8.length >= 5 && u8[0] === SE_PLAIN_BIN_MAGIC) {
231
+ var headLen = ((u8[1] << 24) | (u8[2] << 16) | (u8[3] << 8) | u8[4]) >>> 0;
232
+ if (headLen > 0 && 5 + headLen <= u8.length) {
233
+ var head = null;
234
+ try { head = JSON.parse(seUtf8Decode(u8.subarray(5, 5 + headLen))); } catch (e) { throw seErr("bad_plain", "e2ee: http 响应明文头不是 JSON"); }
235
+ if (!head || typeof head !== "object") throw seErr("bad_plain", "e2ee: http 响应明文缺失");
236
+ return {
237
+ status: Number(head.st) || 502,
238
+ headers: head.h && typeof head.h === "object" ? head.h : {},
239
+ enc: typeof head.enc === "string" ? head.enc : "",
240
+ bodyBytes: u8.subarray(5 + headLen),
241
+ bodyB64: ""
242
+ };
243
+ }
244
+ }
245
+ // ② 兼容 JSON:{st,h,enc,b:<base64>}
246
+ var text = buf instanceof Uint8Array ? seUtf8Decode(buf) : String(buf || "");
220
247
  var pt = null;
221
248
  try { pt = JSON.parse(text); } catch (e) { throw seErr("bad_plain", "e2ee: http 响应明文不是 JSON"); }
222
249
  if (!pt || typeof pt !== "object") throw seErr("bad_plain", "e2ee: http 响应明文缺失");
@@ -224,6 +251,7 @@
224
251
  status: Number(pt.st) || 502,
225
252
  headers: pt.h && typeof pt.h === "object" ? pt.h : {},
226
253
  enc: typeof pt.enc === "string" ? pt.enc : "",
254
+ bodyBytes: typeof pt.b === "string" && pt.b ? seB64ToBytes(pt.b) : new Uint8Array(0),
227
255
  bodyB64: typeof pt.b === "string" ? pt.b : ""
228
256
  };
229
257
  }
@@ -455,7 +483,7 @@
455
483
  seNotifyFail("响应明文非法");
456
484
  return seErrorResponse("bad_plain", "⚠ 无法解密:响应明文非法");
457
485
  }
458
- var bodyU8 = rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0);
486
+ var bodyU8 = rp.bodyBytes instanceof Uint8Array ? rp.bodyBytes : (rp.bodyB64 ? seB64ToBytes(rp.bodyB64) : new Uint8Array(0));
459
487
  if (rp.enc === "gzip" && bodyU8.length) {
460
488
  var un = await seGunzip(bodyU8);
461
489
  if (!un) return seErrorResponse("gunzip", "⚠ 无法解压:响应 gzip 解压失败");