@mrrisega/dsh-remote 0.6.14 → 0.6.16

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 (35) hide show
  1. package/clients/dsh-remote/dsh-bridge.mjs +64 -13
  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 +190 -108
  6. package/clients/dsh-remote/test/dsh-events.test.mjs +89 -0
  7. package/clients/dsh-remote/test/e2ee-bridge.test.mjs +73 -3
  8. package/clients/dsh-remote/test/e2ee-client.test.mjs +42 -1
  9. package/clients/dsh-remote/test/e2ee-shim.test.mjs +10 -1
  10. package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +45 -1
  11. package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +325 -0
  12. package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +3 -1
  13. package/clients/dsh-remote/test/wechat-runtime.test.mjs +268 -1
  14. package/clients/dsh-remote/upstream-discovery.mjs +186 -30
  15. package/clients/dsh-remote/wechat-channel.mjs +86 -3
  16. package/clients/dsh-remote/wechat-runtime.mjs +253 -10
  17. package/dsh-setup.mjs +161 -7
  18. package/package.json +1 -1
  19. package/packages/dsh-remote-web/lib/client.js +31 -1
  20. package/packages/dsh-remote-web/lib/index.js +392 -49
  21. package/packages/dsh-remote-web/package.json +1 -1
  22. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +64 -13
  23. package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +22 -2
  24. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +97 -13
  25. package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +31 -3
  26. package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +190 -108
  27. package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +186 -30
  28. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +86 -3
  29. package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +253 -10
  30. package/packages/dsh-remote-web/runtime/dsh-setup.mjs +161 -7
  31. package/packages/dsh-remote-web/test/doctor-cli.test.mjs +117 -0
  32. package/packages/dsh-remote-web/test/linux-bridge.test.mjs +346 -0
  33. package/packages/dsh-remote-web/test/picker-pin.test.mjs +80 -27
  34. package/packages/dsh-remote-web/test/self-manage.test.mjs +12 -0
  35. package/clients/dsh-remote/test/boot-tip.test.mjs +0 -158
@@ -89,6 +89,8 @@ import {
89
89
  encodeHttpResponsePlain,
90
90
  hasEnvelopeMarker,
91
91
  parseWsE2eeParams,
92
+ stripPlainWantHeader,
93
+ wantsBinaryResponsePlain,
92
94
  writeE2eeStateFile
93
95
  } from "./e2ee-client.mjs";
94
96
  // 微信机器人通道(编排层):绑定控制面 + 出站通知路由 + 入站长轮询。
@@ -800,6 +802,34 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
800
802
  };
801
803
  }
802
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
+
803
833
  export async function handleHttpFrame(dchOrSend, frame) {
804
834
  const send = toSender(dchOrSend);
805
835
  const { id, method = "GET", path = "/", headers = {}, body, bodyBase64: isB64 } = frame;
@@ -827,22 +857,19 @@ export async function handleHttpFrame(dchOrSend, frame) {
827
857
  const session = e2ee.guardHttpRequest(String(env?.s || ""), env);
828
858
  const opened = session.open({ kind: "http", dir: "p2b", env, counter: env?.c ?? 0 });
829
859
  const req = decodeHttpRequestPlain(opened.data);
830
- 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);
831
867
  const bodyBuffer = Buffer.from(reply.body, "base64");
832
- const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer });
868
+ const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer, binary: wantBinPlain });
833
869
  // 响应压缩已发生在 doHttp(明文侧,gzip 在加密前 §4.7);信封用 http-resp kind
834
870
  const respEnv = session.seal({ kind: "http-resp", dir: "b2p", counter: env?.c ?? 0, data: plain, reqNonceB64: String(env?.n || "") });
835
- send({
836
- id,
837
- type: "http",
838
- status: 200, // 外层一律 200,真实状态在信封明文 st 里(§4.3)
839
- headers: {
840
- "content-type": ENVELOPE_CONTENT_TYPE,
841
- "x-dsh-e2ee": `v=2;s=${env.s};k=http-resp`
842
- },
843
- body: Buffer.from(JSON.stringify(respEnv)).toString("base64"),
844
- bodyBase64: true
845
- });
871
+ // ★ 0.6.15:**不再**把整个信封 JSON 再 base64 一次(第三层)。理由见 e2eeHttpResponseFrame。
872
+ send(e2eeHttpResponseFrame(id, env.s, respEnv));
846
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)`);
847
874
  return;
848
875
  } catch (e) {
@@ -1594,7 +1621,31 @@ async function runTunnel() {
1594
1621
  }, 1000);
1595
1622
  }
1596
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
+
1597
1647
  async function main() {
1648
+ stampConsoleLines();
1598
1649
  // 隧道模式是唯一模式(WebRTC/信令已废弃删除)
1599
1650
  if (!TUNNEL_URL) {
1600
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 解压失败");