@mrrisega/dsh-remote 0.7.6-beta.1 → 0.7.6-beta.2
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.
- package/clients/dsh-remote/dsh-bridge.mjs +540 -49
- package/clients/dsh-remote/e2ee-client.mjs +256 -0
- package/clients/dsh-remote/mobile-adapter.mjs +261 -0
- package/clients/dsh-remote/test/bridge-control-priority.test.mjs +206 -0
- package/clients/dsh-remote/test/bridge-heartbeat-adaptive.test.mjs +110 -0
- package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +26 -8
- package/clients/dsh-remote/test/bridge-ws-open-fast-ack.test.mjs +213 -0
- package/clients/dsh-remote/test/e2ee-session-store.test.mjs +251 -0
- package/clients/dsh-remote/test/mobile-adapter-single-page.test.mjs +328 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +119 -0
- package/packages/dsh-remote-web/lib/index.js +145 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +540 -49
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +256 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +261 -0
- package/packages/dsh-remote-web/test/community-invite-proxy.test.mjs +343 -0
- package/packages/dsh-remote-web/test/community-invite-ui.test.mjs +375 -0
- package/packages/dsh-remote-web/test/purchase-agreement-proxy.test.mjs +198 -0
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +336 -0
|
@@ -29,10 +29,16 @@
|
|
|
29
29
|
* REMOTE_STREAM_MUX_PATH)。线上未出问题是因为本文件对路径**透明透传**、由浏览器决定
|
|
30
30
|
* 请求哪个路径;但错的注释会把后来者带沟里(微信通道订阅事件流时会照着它写)。
|
|
31
31
|
* → { "id", "type":"ws-open", "path", "headers":{...} } ← { "id","type":"ws-open","ok":true|false,"code"?,"reason"? }
|
|
32
|
+
* 【2026-10-23 快速应答】校验通过立刻回 {ok:true,fast:true},随后才连上游;上游建立期间手机发来的
|
|
33
|
+
* ws-msg 先排队、open 后按序补发。上游最终失败 → 回 {type:"ws-close",code:1011,retryable:true}
|
|
34
|
+
* (此时 router 已把手机 WS 交接给上层,不能再回 ws-open 失败帧)。E2EE 会话缺失/未验证仍先拒绝:
|
|
35
|
+
* {ok:false,code:1008,retryable:true,action:"rehandshake"}(手机自动重握手,不逼用户重新解锁)。
|
|
32
36
|
* → { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
33
37
|
* ← { "id", "type":"ws-msg", "data":<文本|base64>, "binary"? }
|
|
34
38
|
* → { "id", "type":"ws-close", "code"?, "reason"? } ← { "id","type":"ws-close","code"?,"reason"? }
|
|
35
39
|
* ws 会话以帧 id 为 key;隧道断开时全部关闭。
|
|
40
|
+
* 发送侧有「控制帧快速通道」(ws-open/ws-close/http-end 等可在大响应分片之间插队,
|
|
41
|
+
* 发送缓冲超过高水位时暂停灌分块) —— 见 makePrioritySender 上方长注释。
|
|
36
42
|
*
|
|
37
43
|
* 环境:
|
|
38
44
|
* DSH_BRIDGE_TUNNEL_URL 必填:relay-router 地址(如 ws://127.0.0.1:13444 或 wss://relay.example.com)
|
|
@@ -44,7 +50,11 @@
|
|
|
44
50
|
* DSH_BRIDGE_TOKEN JWT(直接给 token;优先级高于 手机号/邮箱+密码)
|
|
45
51
|
* DSH_BRIDGE_API 账号 API 地址(默认云端服务地址;自建模式无需设置)
|
|
46
52
|
* DSH_BRIDGE_LOCAL_KEY 开源自部署:访问密钥(设后经 router POST /_login 换本地 JWT,免账号体系)
|
|
47
|
-
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认
|
|
53
|
+
* DSH_BRIDGE_HEARTBEAT_MS 隧道心跳间隔(默认 20000ms)
|
|
54
|
+
* DSH_BRIDGE_HEARTBEAT_ADAPT=0 关闭心跳自适应(退回"固定窗口 2 拍"的旧契约)
|
|
55
|
+
* DSH_BRIDGE_HEARTBEAT_MAX_MISSED 判定窗口上限拍数(默认 4,范围 1-8)
|
|
56
|
+
* DSH_BRIDGE_TUNNEL_BULK_HWM_KB 发送缓冲高水位(默认 64KB;弱网可调低)
|
|
57
|
+
* DSH_BRIDGE_TUNNEL_CHUNK_KB 大帧分片大小(默认 64KB)
|
|
48
58
|
* DSH_MOBILE_ADAPTER 经隧道访问的官方 dsh web 移动端适配注入开关:0 关闭(默认开启,仅 ≤820px 生效)
|
|
49
59
|
*
|
|
50
60
|
* E2EE(Phase-2,见 docs/e2ee-protocol.md):
|
|
@@ -52,6 +62,9 @@
|
|
|
52
62
|
* 不落盘);服务端 /api/e2ee-params 返回 enabled=false / 401 / 无密码 / 派生失败 → 明文 v1 回退。
|
|
53
63
|
* - 开关与状态写入 <relayDir>/.e2ee-state.json {enabled, reason, profile, epoch, caps};
|
|
54
64
|
* DSH_BRIDGE_E2EE=0 或配置 e2ee:false 可本地关闭;DSH_BRIDGE_E2EE_STATE 可覆盖状态文件路径。
|
|
65
|
+
* - 【2026-10-23】会话材料加密落盘 <relayDir>/.e2ee-sessions.enc(storeKey=HKDF(MK)、AES-256-GCM、
|
|
66
|
+
* 0600、原子写),bridge 重启后恢复(24h 滑动 TTL),手机旧票不再变死票;DSH_BRIDGE_E2EE_SESSIONS=0
|
|
67
|
+
* 可关闭持久化,DSH_BRIDGE_E2EE_STORE_DIR 可覆盖目录。方案/信任边界见 e2ee-client.mjs 同段注释。
|
|
55
68
|
* - 帧体带信封标记(content-type: application/vnd.dsh.e2ee-v2 或 x-dsh-e2ee)= 已加密:
|
|
56
69
|
* HTTP 解封→转发上游→响应封回;WS 逐消息封/解。任何解封失败显式
|
|
57
70
|
* 502 + x-dsh-e2ee-error / ws-close 1008,绝不静默降级。
|
|
@@ -110,8 +123,10 @@ for (const k of ["http_proxy", "https_proxy", "all_proxy", "HTTP_PROXY", "HTTPS_
|
|
|
110
123
|
const THIS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
111
124
|
const ROOT = path.join(THIS_DIR, "..", "..");
|
|
112
125
|
// 配置路径:dsh-setup 总会显式传入 DSH_BRIDGE_CONFIG;npm 安装时默认落到 ~/.dsh-remote
|
|
126
|
+
/** 是否是「受管安装」(npm 装到 node_modules / 安装器显式给配置):决定状态/会话库是否落盘。 */
|
|
127
|
+
const IS_MANAGED_INSTALL = ROOT.includes(`${path.sep}node_modules${path.sep}`);
|
|
113
128
|
const DEFAULT_CONFIG = path.join(
|
|
114
|
-
|
|
129
|
+
IS_MANAGED_INSTALL ? path.join(os.homedir(), ".dsh-remote") : ROOT,
|
|
115
130
|
".dsh-config.json"
|
|
116
131
|
);
|
|
117
132
|
const CONFIG_PATH = process.env.DSH_BRIDGE_CONFIG || DEFAULT_CONFIG;
|
|
@@ -123,8 +138,8 @@ const TUNNEL_URL = (process.env.DSH_BRIDGE_TUNNEL_URL || "").replace(/\/+$/, "")
|
|
|
123
138
|
* 为什么从 15s 调到 20s:生产上"30 秒级断连、75% 的隧道会话活不过 60s"(诊断报告 R2),
|
|
124
139
|
* 15s 的心跳 + "丢一次 pong 就断" 让弱网(丢包 5%)下的正常抖动被当成故障,
|
|
125
140
|
* 每次误断都会重连重建隧道 + 打断在途请求。20s 与中继/nginx 的 3600s read timeout 无关,
|
|
126
|
-
* 只影响"多久发现半开连接"
|
|
127
|
-
*
|
|
141
|
+
* 只影响"多久发现半开连接":健康链路最坏 2 拍(≈40s)发现真死连接;自适应放宽后最多 4 拍
|
|
142
|
+
* (≈80s,见 adaptiveHeartbeat),仍远小于移动网络常见的 NAT 空闲超时(通常 2~5 分钟)。
|
|
128
143
|
*/
|
|
129
144
|
export const TUNNEL_HEARTBEAT_DEFAULT_MS = 20_000;
|
|
130
145
|
/** 连续丢多少个 pong 才判定隧道已死(C5:旧实现丢 1 次就 terminate,弱网误判率过高)。 */
|
|
@@ -600,6 +615,177 @@ export function dcSend(dc, obj) {
|
|
|
600
615
|
return makeChunkedSender((s) => dc.send(s))(obj);
|
|
601
616
|
}
|
|
602
617
|
|
|
618
|
+
// ============================================================
|
|
619
|
+
// 发送侧:控制帧快速通道(2026-10-23,生产「手机连不上」主因链 #1)
|
|
620
|
+
// ============================================================
|
|
621
|
+
/* 【事故】手机端首屏要的 /plugins/?? 聚合包最大 16.33MB。旧实现把整包切成 82 个 200KB
|
|
622
|
+
* 分块后**在同一个 tick 里连续 ws.send**:内核发送缓冲一满,剩下的块全部堆进 ws 的
|
|
623
|
+
* 内存队列(手机弱网下这是十几秒的量)。router 在此期间发来的 `ws-open`,bridge 处理和
|
|
624
|
+
* 回包只需几毫秒,但**回包帧被排在 16MB 分块后面** → router 20s 超时
|
|
625
|
+
* (日志 `bridge ws-open 应答超时` / `ws_open_timeout`),页面表现为「一直转圈/连不上」。
|
|
626
|
+
*
|
|
627
|
+
* 【WS 帧有序,无法真的插队到已入队分片之前】所以换思路:**不让队列堆起来**。
|
|
628
|
+
* · 大帧分块进「批量车道」,一片一片发,每片之后让出事件循环(入站 ws-open 因此能被及时
|
|
629
|
+
* 处理,回包立刻进「控制车道」);
|
|
630
|
+
* · 每轮发送前先排空控制车道,并检查 `ws.bufferedAmount`:超过高水位就停手等它排空。
|
|
631
|
+
* 于是控制帧前面最多只有「高水位 + 一片」的待发字节 —— 默认 64KB + 64KB,手机 1Mbps
|
|
632
|
+
* 下 ≈1s,8Mbps 下 ≈0.13s,远小于 router 的 20s,也满足「大响应传输中 1s 内应答」目标。
|
|
633
|
+
* · 小帧(交互消息 / SSE 块)单独一条车道,优先于大帧的下一片(交互不被整帧传输拖住)。
|
|
634
|
+
* · 阈值 / 分片大小可用环境变量调(见 tunnelBulkOptionsFromEnv),弱网部署可再调低。
|
|
635
|
+
*/
|
|
636
|
+
export const TUNNEL_BULK_HWM_DEFAULT = 64 * 1024; // 发送缓冲高水位(字节)
|
|
637
|
+
export const TUNNEL_BULK_CHUNK_DEFAULT = 64 * 1024; // 大帧分片大小(字节)
|
|
638
|
+
export const TUNNEL_BULK_POLL_MS = 10; // 高水位以上时的轮询间隔
|
|
639
|
+
/** 必须能插队的控制帧类型(ws-open 回包是这次事故的正主;ws-close 决定页面多久知道断了)。 */
|
|
640
|
+
export const CONTROL_FRAME_TYPES = Object.freeze(["ws-open", "ws-close", "http-abort", "http-end", "tunnel-register"]);
|
|
641
|
+
const CONTROL_FRAME_SET = new Set(CONTROL_FRAME_TYPES);
|
|
642
|
+
|
|
643
|
+
/** 该帧是否走控制车道(纯函数,发送器与用例共用)。 */
|
|
644
|
+
export function isControlFrame(obj) {
|
|
645
|
+
return Boolean(obj && typeof obj === "object" && CONTROL_FRAME_SET.has(obj.type));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** 从环境变量读批量车道参数(运维可调;非法值回默认)。 */
|
|
649
|
+
export function tunnelBulkOptionsFromEnv(env = process.env) {
|
|
650
|
+
const kb = (name, dflt) => {
|
|
651
|
+
const n = Number(env?.[name]);
|
|
652
|
+
return Number.isFinite(n) && n > 0 ? n * 1024 : dflt;
|
|
653
|
+
};
|
|
654
|
+
return {
|
|
655
|
+
bulkHighWater: Math.round(kb("DSH_BRIDGE_TUNNEL_BULK_HWM_KB", TUNNEL_BULK_HWM_DEFAULT)),
|
|
656
|
+
chunkSize: Math.round(kb("DSH_BRIDGE_TUNNEL_CHUNK_KB", TUNNEL_BULK_CHUNK_DEFAULT))
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* 构造「控制帧优先」的隧道发送器。返回 { send, close, stats, hasBacklog }。
|
|
662
|
+
*
|
|
663
|
+
* send(obj) → boolean:控制帧立即入控制车道并尝试发;其余帧序列化后入批量车道。
|
|
664
|
+
* `true` 表示已受理(与 makeChunkedSender 语义一致:只表达"入队阶段没失败")。
|
|
665
|
+
* 依赖可注入(rawSend / bufferedAmount / schedule / setTimer),便于用假 socket + 可控调度
|
|
666
|
+
* 断言「大响应传输中 ws-open 回包能插队」这条契约(test/bridge-control-priority.test.mjs)。
|
|
667
|
+
*/
|
|
668
|
+
export function makePrioritySender({
|
|
669
|
+
rawSend,
|
|
670
|
+
bufferedAmount = () => 0,
|
|
671
|
+
bulkHighWater = TUNNEL_BULK_HWM_DEFAULT,
|
|
672
|
+
chunkSize = TUNNEL_BULK_CHUNK_DEFAULT,
|
|
673
|
+
pollMs = TUNNEL_BULK_POLL_MS,
|
|
674
|
+
schedule = (fn) => setImmediate(fn),
|
|
675
|
+
setTimer = (fn, ms) => setTimeout(fn, ms)
|
|
676
|
+
} = {}) {
|
|
677
|
+
const controlQ = [];
|
|
678
|
+
let closed = false;
|
|
679
|
+
let pumping = false;
|
|
680
|
+
let scheduled = false;
|
|
681
|
+
let timerPending = false;
|
|
682
|
+
const smallQ = []; // 小帧(可整帧直发):交互消息 / SSE 块 —— 优先于大帧的下一片
|
|
683
|
+
const bigQ = []; // 大帧:{ frames: string[], i }
|
|
684
|
+
const stats = { controlSent: 0, smallSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
|
|
685
|
+
|
|
686
|
+
function enqueueBulk(text) {
|
|
687
|
+
if (text.length <= chunkSize) {
|
|
688
|
+
smallQ.push(text);
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const cid = `c${++chunkSeq}`;
|
|
692
|
+
const n = Math.ceil(text.length / chunkSize);
|
|
693
|
+
const frames = new Array(n);
|
|
694
|
+
for (let i = 0; i < n; i += 1) {
|
|
695
|
+
frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
|
|
696
|
+
}
|
|
697
|
+
stats.bigFrames += 1;
|
|
698
|
+
bigQ.push({ frames, i: 0 });
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function scheduleNext(waitMs) {
|
|
702
|
+
if (closed) return;
|
|
703
|
+
if (waitMs > 0) {
|
|
704
|
+
if (timerPending) return;
|
|
705
|
+
timerPending = true;
|
|
706
|
+
setTimer(() => { timerPending = false; pump(); }, waitMs);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (scheduled) return;
|
|
710
|
+
scheduled = true;
|
|
711
|
+
schedule(() => { scheduled = false; pump(); });
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function pump() {
|
|
715
|
+
if (pumping || closed) return;
|
|
716
|
+
pumping = true;
|
|
717
|
+
let yieldMode = ""; // ""=没有剩余,"schedule"=让出事件循环,"poll"=等缓冲排空
|
|
718
|
+
let budget = 32; // 一轮最多发多少条小帧
|
|
719
|
+
try {
|
|
720
|
+
for (;;) {
|
|
721
|
+
// ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
|
|
722
|
+
while (controlQ.length > 0) {
|
|
723
|
+
const frame = controlQ.shift();
|
|
724
|
+
try { rawSend(frame); stats.controlSent += 1; } catch { closed = true; }
|
|
725
|
+
}
|
|
726
|
+
if (closed) { yieldMode = ""; break; }
|
|
727
|
+
// ② 小帧优先于大帧的下一片:大包传输期间,交互消息 / SSE 块不该排在它后面。
|
|
728
|
+
if (smallQ.length > 0 && budget > 0) {
|
|
729
|
+
try { rawSend(smallQ.shift()); stats.smallSent += 1; } catch { closed = true; break; }
|
|
730
|
+
budget -= 1;
|
|
731
|
+
continue; // 回环顶先查控制车道
|
|
732
|
+
}
|
|
733
|
+
if (smallQ.length > 0) { yieldMode = "schedule"; break; } // 一轮预算用完
|
|
734
|
+
if (bigQ.length === 0) { yieldMode = ""; break; }
|
|
735
|
+
// ③ 背压:已入队字节超过高水位 → 停手等排空,别把控制帧埋进几十秒的队列里。
|
|
736
|
+
let pending = 0;
|
|
737
|
+
try { pending = Number(bufferedAmount()) || 0; } catch { pending = 0; }
|
|
738
|
+
if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
|
|
739
|
+
const item = bigQ[0];
|
|
740
|
+
try { rawSend(item.frames[item.i]); stats.bigChunks += 1; } catch { closed = true; break; }
|
|
741
|
+
item.i += 1;
|
|
742
|
+
if (item.i >= item.frames.length) bigQ.shift();
|
|
743
|
+
yieldMode = "schedule"; // 大帧:一片一让,给入站帧 / 控制帧 / 小帧机会
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
746
|
+
} finally {
|
|
747
|
+
pumping = false;
|
|
748
|
+
if (closed) {
|
|
749
|
+
controlQ.length = 0;
|
|
750
|
+
smallQ.length = 0;
|
|
751
|
+
bigQ.length = 0;
|
|
752
|
+
} else if (yieldMode) {
|
|
753
|
+
scheduleNext(yieldMode === "poll" ? pollMs : 0);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function send(obj) {
|
|
759
|
+
let text;
|
|
760
|
+
try { text = JSON.stringify(obj); } catch { return false; }
|
|
761
|
+
if (closed) { stats.dropped += 1; return false; }
|
|
762
|
+
if (isControlFrame(obj)) controlQ.push(text);
|
|
763
|
+
else enqueueBulk(text);
|
|
764
|
+
pump();
|
|
765
|
+
return true;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/** 隧道断开:丢掉未发内容并停止调度(否则重连后旧分片会发到新连接上)。 */
|
|
769
|
+
function close() {
|
|
770
|
+
closed = true;
|
|
771
|
+
controlQ.length = 0;
|
|
772
|
+
smallQ.length = 0;
|
|
773
|
+
bigQ.length = 0;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
send,
|
|
778
|
+
close,
|
|
779
|
+
stats: () => ({
|
|
780
|
+
...stats,
|
|
781
|
+
queued: smallQ.length + bigQ.length,
|
|
782
|
+
controlQueued: controlQ.length,
|
|
783
|
+
closed
|
|
784
|
+
}),
|
|
785
|
+
hasBacklog: () => smallQ.length > 0 || bigQ.length > 0 || controlQ.length > 0
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
603
789
|
/** 兼容两种调用:handleXxx(dch, frame)(WebRTC) 或 handleXxx(sendFn, frame)(隧道)。 */
|
|
604
790
|
function toSender(dchOrSend) {
|
|
605
791
|
return typeof dchOrSend === "function" ? dchOrSend : (obj) => dcSend(dchOrSend, obj);
|
|
@@ -1384,6 +1570,68 @@ function handleCtrlMessage(send, id, rawText) {
|
|
|
1384
1570
|
// 其余控制消息忽略
|
|
1385
1571
|
}
|
|
1386
1572
|
|
|
1573
|
+
/* ── ws-open 快速应答(2026-10-23,生产「手机连不上」主因链 #1 的另一半)────────────
|
|
1574
|
+
*
|
|
1575
|
+
* 旧契约:收到 ws-open → 连上游 WebSocket → **等上游 open** 才回 ok。
|
|
1576
|
+
* 上游连不上时(桌面端换端口/宿主重启),手机要等到 router 的 20s 超时才看到失败;
|
|
1577
|
+
* 而 bridge 发送队列被大响应占住时(另见 makePrioritySender),连"回 ok"都排不上。
|
|
1578
|
+
*
|
|
1579
|
+
* 新契约:校验通过 → **立刻回 ok** → 再连上游。
|
|
1580
|
+
* · 手机拿到 ok 后立刻发的消息(mux 订阅/首帧)进 pending 队列,上游 open 后按序补发,
|
|
1581
|
+
* 上限 WS_OPEN_PENDING_MAX_FRAMES / BYTES(只是上游建立期间的过渡,正常是几十 ms)。
|
|
1582
|
+
* · 上游最终失败/超时 → 回 ``ws-close``(1011, retryable) —— 此时升级早已完成,
|
|
1583
|
+
* 再回 ws-open 失败帧没有意义(router 已把手机 WS 交给上层)。
|
|
1584
|
+
* · 失败路径照旧触发 refreshUpstream(端口漂移自愈,见 test/bridge-upstream-refresh)。
|
|
1585
|
+
* · E2EE 语义不变:带 &e2ee= 的流仍然**先**校验会话(未验证/不存在 → 1008 + 可自动重握手
|
|
1586
|
+
* 信号),校验通过才 ack,绝不在未验证的会话上转发任何数据(§4.6/§5.2)。
|
|
1587
|
+
*/
|
|
1588
|
+
export const WS_OPEN_UPSTREAM_TIMEOUT_MS = 10_000;
|
|
1589
|
+
export const WS_OPEN_PENDING_MAX_FRAMES = 512;
|
|
1590
|
+
export const WS_OPEN_PENDING_MAX_BYTES = 2 * 1024 * 1024;
|
|
1591
|
+
|
|
1592
|
+
/** ws-open 拒绝帧;extra 里放机器可读信号(手机据此自动重握手,而不是让用户去解锁)。 */
|
|
1593
|
+
function sendWsOpenReject(send, id, code, reason, extra = {}) {
|
|
1594
|
+
send({ id, type: "ws-open", ok: false, code, reason, at: Date.now(), ...extra });
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/** 已 ack 之后的上游失败:只能换成 ws-close 收尾(升级已完成,不能再回 ws-open 失败帧)。 */
|
|
1598
|
+
function failAckedWs(send, id, phase, reason, extra = {}) {
|
|
1599
|
+
send({ id, type: "ws-close", code: 1011, reason: `${phase}: ${reason}`, phase, at: Date.now(), ...extra });
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** 上游 open 前把消息排队;open 后按序补发。overflow 返回 false(调用方关流)。 */
|
|
1603
|
+
function enqueuePendingWsMsg(session, payload, binary) {
|
|
1604
|
+
const bytes = Buffer.isBuffer(payload) ? payload.length : String(payload).length;
|
|
1605
|
+
if (session.pending.length >= WS_OPEN_PENDING_MAX_FRAMES || session.pendingBytes + bytes > WS_OPEN_PENDING_MAX_BYTES) {
|
|
1606
|
+
return false;
|
|
1607
|
+
}
|
|
1608
|
+
session.pending.push({ data: payload, binary });
|
|
1609
|
+
session.pendingBytes += bytes;
|
|
1610
|
+
return true;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/** 把一条消息发到上游;上游还没 open 就排队(见上方 ws-open 快速应答说明)。 */
|
|
1614
|
+
function forwardToUpstream(send, id, session, payload, binary) {
|
|
1615
|
+
const ws = session.ws;
|
|
1616
|
+
if (session.opened && ws && ws.readyState === WebSocket.OPEN) {
|
|
1617
|
+
try {
|
|
1618
|
+
if (binary) ws.send(payload);
|
|
1619
|
+
else ws.send(String(payload));
|
|
1620
|
+
} catch (e) {
|
|
1621
|
+
console.log(`[bridge] ws-send err: ${e.message}`);
|
|
1622
|
+
}
|
|
1623
|
+
return true;
|
|
1624
|
+
}
|
|
1625
|
+
if (session.cancelled || session.failed) return false;
|
|
1626
|
+
if (enqueuePendingWsMsg(session, payload, binary)) return true;
|
|
1627
|
+
console.error(`[bridge] ws-open 待发队列溢出(id=${id}),关闭该流(上游建立过慢)`);
|
|
1628
|
+
session.cancelled = true;
|
|
1629
|
+
try { session.ws?.terminate?.(); } catch { /* 忽略 */ }
|
|
1630
|
+
wsSessions.delete(id);
|
|
1631
|
+
try { failAckedWs(send, id, "upstream_ws_open_slow", "上游连接建立过慢,待发消息超限"); } catch { /* 忽略 */ }
|
|
1632
|
+
return false;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1387
1635
|
export async function handleWsOpen(dchOrSend, frame) {
|
|
1388
1636
|
const send = toSender(dchOrSend);
|
|
1389
1637
|
const { id, path = "/", headers = {} } = frame;
|
|
@@ -1395,32 +1643,91 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1395
1643
|
const tagged = parseWsE2eeParams(path);
|
|
1396
1644
|
if (tagged) {
|
|
1397
1645
|
if (!e2ee.enabled) {
|
|
1398
|
-
send
|
|
1646
|
+
sendWsOpenReject(send, id, 1008, "e2ee_disabled:bridge 未启用端到端加密(请用明文连接)");
|
|
1399
1647
|
return;
|
|
1400
1648
|
}
|
|
1401
|
-
const
|
|
1402
|
-
if (!
|
|
1649
|
+
const esess = e2ee.sessionOf(tagged.sessId);
|
|
1650
|
+
if (!esess || !esess.verified) {
|
|
1651
|
+
// 会话可能刚被重启/被 LRU 挤掉:把「可自动重握手」写成机器可读信号,
|
|
1652
|
+
// 手机侧据此自动重建会话,而不是弹「请重新解锁」让用户输密码(生产 3964 次/设备)。
|
|
1403
1653
|
console.error(`[bridge] e2ee ws 拒绝(会话未解锁/未验证): ${path}`);
|
|
1404
|
-
send
|
|
1654
|
+
sendWsOpenReject(send, id, 1008, "e2ee_session_locked:加密会话已失效,需要重新握手", {
|
|
1655
|
+
retryable: true,
|
|
1656
|
+
action: "rehandshake"
|
|
1657
|
+
});
|
|
1405
1658
|
return;
|
|
1406
1659
|
}
|
|
1407
1660
|
// 探测前不转发任何数据(§4.6/§5.2:探测通过后数据流才可建);此时已验证 → 安全
|
|
1408
1661
|
}
|
|
1409
|
-
|
|
1662
|
+
const previous = wsSessions.get(id);
|
|
1663
|
+
if (previous) {
|
|
1664
|
+
previous.cancelled = true;
|
|
1665
|
+
try { previous.ws?.terminate?.(); } catch { /* 忽略 */ }
|
|
1666
|
+
wsSessions.delete(id);
|
|
1667
|
+
}
|
|
1410
1668
|
const safe = safePath(tagged ? tagged.upstreamPath : path);
|
|
1411
|
-
if (safe === null) { send
|
|
1412
|
-
|
|
1413
|
-
const
|
|
1414
|
-
const session = { ws, opened: false };
|
|
1669
|
+
if (safe === null) { sendWsOpenReject(send, id, 400, "非法路径"); return; }
|
|
1670
|
+
|
|
1671
|
+
const session = { ws: null, opened: false, pending: [], pendingBytes: 0, cancelled: false, failed: false };
|
|
1415
1672
|
if (tagged && e2ee.enabled) {
|
|
1416
1673
|
// 会话必须已 verified(上面已校验),给该流独立的收发密钥与方向计数
|
|
1417
1674
|
session.e2ee = { s: e2ee.sessionOf(tagged.sessId), wsLabel: tagged.wsLabel, p2bLast: -1, b2pLast: -1 };
|
|
1418
1675
|
}
|
|
1419
1676
|
wsSessions.set(id, session);
|
|
1677
|
+
// ★ 快速应答:先回 ok,再连上游(理由见上方长注释)
|
|
1678
|
+
const acked = send({ id, type: "ws-open", ok: true, fast: true });
|
|
1679
|
+
if (acked === false) { wsSessions.delete(id); return; }
|
|
1680
|
+
|
|
1681
|
+
const url = `${UPSTREAM.replace(/^http/, "ws")}${safe}`;
|
|
1682
|
+
let ws;
|
|
1683
|
+
try {
|
|
1684
|
+
ws = new WebSocket(url, { headers: buildWsHeaders(headers), followRedirects: false });
|
|
1685
|
+
} catch (e) {
|
|
1686
|
+
wsSessions.delete(id);
|
|
1687
|
+
session.failed = true;
|
|
1688
|
+
void refreshUpstream("ws-open 上游连接失败");
|
|
1689
|
+
failAckedWs(send, id, "upstream_ws_open_failed", String(e?.message || e), { retryable: true });
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1692
|
+
session.ws = ws;
|
|
1693
|
+
/** 上游没建立就失败:统一收尾(只发一次)。 */
|
|
1694
|
+
const failUpstream = (phase, reason) => {
|
|
1695
|
+
if (session.opened || session.failed || session.cancelled) return;
|
|
1696
|
+
session.failed = true;
|
|
1697
|
+
try { clearTimeout(openTimer); } catch { /* 忽略 */ }
|
|
1698
|
+
session.pending.length = 0;
|
|
1699
|
+
session.pendingBytes = 0;
|
|
1700
|
+
wsSessions.delete(id);
|
|
1701
|
+
// ★ 上游可能已经换端口(桌面端 App 重启会换本地端口,生产日志里抓到过
|
|
1702
|
+
// `upstream_ws_open_failed ... ECONNREFUSED 127.0.0.1:19387`)。
|
|
1703
|
+
// HTTP 失败路径早就会触发重新发现,**WS 失败路径之前漏了** —— 而镜像页常常
|
|
1704
|
+
// 先开 WS(控制通道/数据通道),于是端口变了以后一直失败、页面白屏,直到用户
|
|
1705
|
+
// 手动重开。这里补上:本次失败照旧通知手机,但立即触发重新发现,下一次重试命中新端口。
|
|
1706
|
+
void refreshUpstream("ws-open 上游连接失败");
|
|
1707
|
+
// 已 ack → 用 ws-close 收尾(1011 + retryable),手机侧走正常重连,不必干等 20s 超时。
|
|
1708
|
+
failAckedWs(send, id, phase, reason, { retryable: true });
|
|
1709
|
+
};
|
|
1710
|
+
let openTimer = null;
|
|
1711
|
+
openTimer = setTimeout(() => {
|
|
1712
|
+
failUpstream("upstream_ws_open_timeout", `上游 WebSocket ${Math.round(WS_OPEN_UPSTREAM_TIMEOUT_MS / 1000)}s 未建立`);
|
|
1713
|
+
}, WS_OPEN_UPSTREAM_TIMEOUT_MS);
|
|
1714
|
+
openTimer.unref?.();
|
|
1420
1715
|
ws.on("open", () => {
|
|
1716
|
+
if (session.cancelled || session.failed) { try { ws.terminate(); } catch { /* 忽略 */ } return; }
|
|
1717
|
+
try { clearTimeout(openTimer); } catch { /* 忽略 */ }
|
|
1421
1718
|
session.opened = true;
|
|
1422
|
-
console.log(`[bridge] ws-open ${session.e2ee ? "(e2ee) " : ""}${safe} (id=${id})`);
|
|
1423
|
-
|
|
1719
|
+
console.log(`[bridge] ws-open ${session.e2ee ? "(e2ee) " : ""}${safe} (id=${id}, 快速应答)`);
|
|
1720
|
+
// 上游建立期间手机已经发来的消息按序补发
|
|
1721
|
+
const pending = session.pending;
|
|
1722
|
+
session.pending = [];
|
|
1723
|
+
session.pendingBytes = 0;
|
|
1724
|
+
for (const item of pending) {
|
|
1725
|
+
try {
|
|
1726
|
+
if (item.binary) ws.send(item.data);
|
|
1727
|
+
else ws.send(String(item.data));
|
|
1728
|
+
} catch { /* 上游立刻又出错 → close 路径收尾 */ }
|
|
1729
|
+
}
|
|
1730
|
+
if (pending.length > 0) console.log(`[bridge] ws-open 补发 ${pending.length} 条上游建立期间的消息(id=${id})`);
|
|
1424
1731
|
});
|
|
1425
1732
|
ws.on("message", (data, isBinary) => {
|
|
1426
1733
|
// 上游 → 手机:加密流逐条封信封(b2p 独立计数);明文流原样透传
|
|
@@ -1447,7 +1754,12 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1447
1754
|
send({ id, type: "ws-msg", data: payload, binary: isBinary });
|
|
1448
1755
|
});
|
|
1449
1756
|
ws.on("close", (code, reason) => {
|
|
1450
|
-
if (
|
|
1757
|
+
if (session.cancelled) return; // 手机侧已关 / 被新连接取代 / 队列溢出,已收尾
|
|
1758
|
+
if (!session.opened) {
|
|
1759
|
+
// 快速应答之后才断:走统一失败收尾(ws-close 1011 + 触发上游重发现)
|
|
1760
|
+
failUpstream("upstream_ws_open_failed", `上游 WebSocket 未建立就关闭(code=${code}${reason ? ", " + reason : ""})`);
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1451
1763
|
console.log(`[bridge] ws-close ${path} (id=${id}, code=${code})`);
|
|
1452
1764
|
wsSessions.delete(id);
|
|
1453
1765
|
send({ id, type: "ws-close", code: code ?? 1006, reason: reason?.toString() ?? "" });
|
|
@@ -1455,14 +1767,8 @@ export async function handleWsOpen(dchOrSend, frame) {
|
|
|
1455
1767
|
ws.on("error", (e) => {
|
|
1456
1768
|
console.log(`[bridge] ws-error ${path} (id=${id}): ${e.message || ""}`);
|
|
1457
1769
|
if (!session.opened) {
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
// `upstream_ws_open_failed ... ECONNREFUSED 127.0.0.1:19387`)。
|
|
1461
|
-
// HTTP 失败路径早就会触发重新发现,**WS 失败路径之前漏了** —— 而镜像页常常
|
|
1462
|
-
// 先开 WS(控制通道/数据通道),于是端口变了以后一直 502、页面白屏,直到用户
|
|
1463
|
-
// 手动重开。这里补上:本次失败照旧回 502,但立即触发重新发现,下一次重试就命中新端口。
|
|
1464
|
-
void refreshUpstream("ws-open 上游连接失败");
|
|
1465
|
-
send({ id, type: "ws-open", ok: false, code: 502, reason: String(e.message || "ws error") });
|
|
1770
|
+
// ★ 未建立就失败:触发上游重新发现(端口漂移自愈,见 failUpstream 内注释)。
|
|
1771
|
+
failUpstream("upstream_ws_open_failed", String(e.message || "ws error"));
|
|
1466
1772
|
}
|
|
1467
1773
|
});
|
|
1468
1774
|
}
|
|
@@ -1480,7 +1786,8 @@ export function handleWsMessage(_dchOrSend, frame) {
|
|
|
1480
1786
|
return;
|
|
1481
1787
|
}
|
|
1482
1788
|
const session = wsSessions.get(id);
|
|
1483
|
-
if (!session
|
|
1789
|
+
if (!session) return;
|
|
1790
|
+
const send = toSender(_dchOrSend);
|
|
1484
1791
|
if (session.e2ee) {
|
|
1485
1792
|
// 加密流:逐条解封 → 原文转发上游;AEAD 失败 → 1008 关闭并回告(不静默降级)
|
|
1486
1793
|
try {
|
|
@@ -1490,20 +1797,18 @@ export function handleWsMessage(_dchOrSend, frame) {
|
|
|
1490
1797
|
if (!Number.isInteger(env.c) || env.c <= e2.p2bLast) throw new E2eeError("replay", `e2ee ws 计数重放/乱序 ${env.c}`);
|
|
1491
1798
|
const opened = e2.s.open({ kind: "w", dir: "p2b", env, counter: env.c, wsLabel: e2.wsLabel });
|
|
1492
1799
|
e2.p2bLast = env.c;
|
|
1493
|
-
|
|
1494
|
-
|
|
1800
|
+
// 上游可能还在建立(ws-open 快速应答):forwardToUpstream 会排队、open 后补发
|
|
1801
|
+
forwardToUpstream(send, id, session, opened.t === 1 ? opened.data : opened.data.toString("utf8"), opened.t === 1);
|
|
1495
1802
|
} catch (e) {
|
|
1496
1803
|
console.error(`[bridge] e2ee ws 解封失败(id=${id}): ${e.message || e}(不回退明文,1008 关闭)`);
|
|
1497
|
-
|
|
1804
|
+
const ws = session.ws;
|
|
1805
|
+
try { ws?.close?.(1008, "⚠ e2ee 消息无法解密"); } catch { /* 忽略 */ }
|
|
1498
1806
|
wsSessions.delete(id);
|
|
1499
|
-
try {
|
|
1807
|
+
try { send({ id, type: "ws-close", code: 1008, reason: "⚠ e2ee 消息无法解密" }); } catch { /* 忽略 */ }
|
|
1500
1808
|
}
|
|
1501
1809
|
return;
|
|
1502
1810
|
}
|
|
1503
|
-
|
|
1504
|
-
if (binary) session.ws.send(Buffer.from(data, "base64"));
|
|
1505
|
-
else session.ws.send(String(data));
|
|
1506
|
-
} catch (e) { console.log(`[bridge] ws-send err: ${e.message}`); }
|
|
1811
|
+
forwardToUpstream(send, id, session, binary ? Buffer.from(data, "base64") : String(data), Boolean(binary));
|
|
1507
1812
|
}
|
|
1508
1813
|
|
|
1509
1814
|
export function handleWsClose(_dchOrSend, frame) {
|
|
@@ -1515,13 +1820,16 @@ export function handleWsClose(_dchOrSend, frame) {
|
|
|
1515
1820
|
}
|
|
1516
1821
|
const session = wsSessions.get(id);
|
|
1517
1822
|
if (!session) return;
|
|
1518
|
-
|
|
1823
|
+
session.cancelled = true; // 手机侧主动关:不要再补发 pending / 不要再回 ws-close
|
|
1824
|
+
wsSessions.delete(id);
|
|
1825
|
+
try { session.ws?.close?.(code && typeof code === "number" ? code : 1000, reason || ""); } catch { /* 忽略 */ }
|
|
1519
1826
|
}
|
|
1520
1827
|
|
|
1521
1828
|
/** DataChannel 断开:关闭所有 ws 会话与控制通道。 */
|
|
1522
1829
|
export function closeAllWsSessions() {
|
|
1523
1830
|
for (const session of wsSessions.values()) {
|
|
1524
|
-
|
|
1831
|
+
session.cancelled = true;
|
|
1832
|
+
try { session.ws?.terminate?.(); } catch { /* 忽略 */ }
|
|
1525
1833
|
}
|
|
1526
1834
|
wsSessions.clear();
|
|
1527
1835
|
ctrlConns.clear();
|
|
@@ -1669,6 +1977,116 @@ export function heartbeatStep({ pongReceived, missedPongs = 0 }) {
|
|
|
1669
1977
|
return { missedPongs: missed, terminate, ping: !terminate };
|
|
1670
1978
|
}
|
|
1671
1979
|
|
|
1980
|
+
/* ── 心跳自适应(2026-10-23,生产「DSH 主线程卡顿 → 自杀式重连」)──────────────
|
|
1981
|
+
*
|
|
1982
|
+
* 【问题】固定 20s 心跳 + 固定"丢 2 个 pong 就断":DSH 主线程被大任务/大响应占住时
|
|
1983
|
+
* (pong 由 ws 库在 socket 事件里回,但事件循环被占就排不上队),连续两拍收不到 pong
|
|
1984
|
+
* 就 terminate → 重建隧道 → 手机侧所有在途请求断掉、页面重连,而卡顿本来几十 ms~几秒
|
|
1985
|
+
* 就过去了。生产 13706 次/天 bridge 断开里有一大批是这么来的。
|
|
1986
|
+
*
|
|
1987
|
+
* 【做法】判定窗口按「链路 RTT + 本地事件循环延迟」动态放宽,ping 间隔按 RTT 略微拉长:
|
|
1988
|
+
* · rtt ≥ 1s → 判定窗口 +1 拍(慢链路多给一拍),ping 间隔同时拉长到 1.5x(≤60s)
|
|
1989
|
+
* · 事件循环延迟 ≥500ms → 判定窗口 +1 拍(本机忙,pong 不是"没回来"而是"没轮到处理")
|
|
1990
|
+
* 健康链路(rtt 低、不忙)下窗口仍是 2 拍 —— 与旧契约完全一致,不快也不慢。
|
|
1991
|
+
* 上限 4 拍可配(DSH_BRIDGE_HEARTBEAT_MAX_MISSED),DSH_BRIDGE_HEARTBEAT_ADAPT=0 可退回旧契约。
|
|
1992
|
+
* 最坏发现"真死"的耗时 = 4 拍 ≈ 80s,仍远小于移动网络 NAT 空闲超时(2~5 分钟)。
|
|
1993
|
+
*
|
|
1994
|
+
* 纯函数,便于单测(test/bridge-heartbeat-adaptive.test.mjs)。
|
|
1995
|
+
*/
|
|
1996
|
+
export const HEARTBEAT_RTT_SLOW_MS = 1000; // RTT 超过它视为慢链路
|
|
1997
|
+
export const HEARTBEAT_LAG_BUSY_MS = 500; // 事件循环延迟超过它视为本机繁忙
|
|
1998
|
+
export const HEARTBEAT_MAX_MISSED_CAP_DEFAULT = 4;
|
|
1999
|
+
export const HEARTBEAT_MAX_INTERVAL_MS = 60_000;
|
|
2000
|
+
/** 自适应开关/上限(环境变量;测试与运维可覆盖)。 */
|
|
2001
|
+
export const HEARTBEAT_ADAPTIVE = process.env.DSH_BRIDGE_HEARTBEAT_ADAPT !== "0";
|
|
2002
|
+
export const HEARTBEAT_MAX_MISSED_CAP = (() => {
|
|
2003
|
+
const n = Number(process.env.DSH_BRIDGE_HEARTBEAT_MAX_MISSED);
|
|
2004
|
+
return Number.isFinite(n) && n >= 1 ? Math.min(8, Math.floor(n)) : HEARTBEAT_MAX_MISSED_CAP_DEFAULT;
|
|
2005
|
+
})();
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* 心跳一拍(自适应版)。
|
|
2009
|
+
* @param {object} o
|
|
2010
|
+
* @param {boolean} [o.pongReceived] 上一拍发出后是否收到 pong
|
|
2011
|
+
* @param {number} [o.missedPongs] 此前连续丢了几拍
|
|
2012
|
+
* @param {number|null} [o.rttMs] 最近 RTT(null=还没测到)
|
|
2013
|
+
* @param {number} [o.eventLoopLagMs] 本机事件循环延迟
|
|
2014
|
+
* @param {number} [o.baseIntervalMs] ping 间隔基准
|
|
2015
|
+
* @param {number} [o.baseMaxMissed] 判定窗口基准(旧契约 2)
|
|
2016
|
+
* @param {number} [o.maxMissedCap] 判定窗口上限
|
|
2017
|
+
* @param {boolean} [o.adaptive] false = 完全退回旧契约(interval 不变、窗口=baseMaxMissed)
|
|
2018
|
+
* @returns {{intervalMs:number, maxMissed:number, missedPongs:number, terminate:boolean, ping:boolean, degraded:boolean, reasons:string[]}}
|
|
2019
|
+
*/
|
|
2020
|
+
export function adaptiveHeartbeat({
|
|
2021
|
+
pongReceived = false,
|
|
2022
|
+
missedPongs = 0,
|
|
2023
|
+
rttMs = null,
|
|
2024
|
+
eventLoopLagMs = 0,
|
|
2025
|
+
baseIntervalMs = TUNNEL_HEARTBEAT_DEFAULT_MS,
|
|
2026
|
+
baseMaxMissed = HEARTBEAT_MAX_MISSED_PONGS,
|
|
2027
|
+
maxMissedCap = HEARTBEAT_MAX_MISSED_CAP_DEFAULT,
|
|
2028
|
+
adaptive = true
|
|
2029
|
+
} = {}) {
|
|
2030
|
+
const base = Math.max(100, Number(baseIntervalMs) || TUNNEL_HEARTBEAT_DEFAULT_MS);
|
|
2031
|
+
const baseMax = Math.max(1, Number(baseMaxMissed) || HEARTBEAT_MAX_MISSED_PONGS);
|
|
2032
|
+
const cap = Math.max(baseMax, Math.min(8, Number(maxMissedCap) || HEARTBEAT_MAX_MISSED_CAP_DEFAULT));
|
|
2033
|
+
const missed = pongReceived ? 0 : Math.max(0, missedPongs) + 1;
|
|
2034
|
+
if (!adaptive) {
|
|
2035
|
+
return {
|
|
2036
|
+
intervalMs: base,
|
|
2037
|
+
maxMissed: baseMax,
|
|
2038
|
+
missedPongs: missed,
|
|
2039
|
+
terminate: !pongReceived && missed >= baseMax,
|
|
2040
|
+
ping: !(!pongReceived && missed >= baseMax),
|
|
2041
|
+
degraded: false,
|
|
2042
|
+
reasons: []
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
const reasons = [];
|
|
2046
|
+
let maxMissed = baseMax;
|
|
2047
|
+
let intervalMs = base;
|
|
2048
|
+
const lag = Math.max(0, Number(eventLoopLagMs) || 0);
|
|
2049
|
+
const rtt = rttMs === null || rttMs === undefined ? null : Math.max(0, Number(rttMs) || 0);
|
|
2050
|
+
if (lag >= HEARTBEAT_LAG_BUSY_MS) {
|
|
2051
|
+
maxMissed += 1;
|
|
2052
|
+
reasons.push("event_loop_busy");
|
|
2053
|
+
}
|
|
2054
|
+
if (rtt !== null && rtt >= HEARTBEAT_RTT_SLOW_MS) {
|
|
2055
|
+
maxMissed += 1;
|
|
2056
|
+
intervalMs = Math.min(HEARTBEAT_MAX_INTERVAL_MS, Math.round(base * 1.5));
|
|
2057
|
+
reasons.push("rtt_slow");
|
|
2058
|
+
}
|
|
2059
|
+
maxMissed = Math.min(cap, Math.max(1, maxMissed));
|
|
2060
|
+
const terminate = !pongReceived && missed >= maxMissed;
|
|
2061
|
+
return { intervalMs, maxMissed, missedPongs: missed, terminate, ping: !terminate, degraded: reasons.length > 0, reasons };
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* 事件循环延迟探针(本进程)。1s 一拍,记"实际触发时刻比预期晚多少"。
|
|
2066
|
+
* 只是给心跳判定的输入,不做任何业务动作;unref 不阻塞退出。
|
|
2067
|
+
*/
|
|
2068
|
+
let eventLoopLagMs = 0;
|
|
2069
|
+
let eventLoopLagTimer = null;
|
|
2070
|
+
export function startEventLoopLagMonitor({ intervalMs = 1000 } = {}) {
|
|
2071
|
+
if (eventLoopLagTimer) return () => {};
|
|
2072
|
+
const tick = Math.max(100, Number(intervalMs) || 1000);
|
|
2073
|
+
let expected = Date.now() + tick;
|
|
2074
|
+
eventLoopLagTimer = setInterval(() => {
|
|
2075
|
+
const now = Date.now();
|
|
2076
|
+
eventLoopLagMs = Math.max(0, now - expected);
|
|
2077
|
+
expected = now + tick;
|
|
2078
|
+
}, tick);
|
|
2079
|
+
eventLoopLagTimer.unref?.();
|
|
2080
|
+
return () => {
|
|
2081
|
+
if (eventLoopLagTimer) { try { clearInterval(eventLoopLagTimer); } catch { /* 忽略 */ } }
|
|
2082
|
+
eventLoopLagTimer = null;
|
|
2083
|
+
eventLoopLagMs = 0;
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
/** 当前事件循环延迟(毫秒;未启用探针时为 0)。 */
|
|
2087
|
+
export function currentEventLoopLagMs() { return eventLoopLagMs; }
|
|
2088
|
+
/** 测试钩子:直接设定延迟读数。 */
|
|
2089
|
+
export function __setEventLoopLagForTest(ms) { eventLoopLagMs = Math.max(0, Number(ms) || 0); }
|
|
1672
2090
|
/** 把 DSH_BRIDGE_TUNNEL_URL 归一化为 router 注册端点(缺省补 / _bridge)。 */
|
|
1673
2091
|
function tunnelEndpoint(raw) {
|
|
1674
2092
|
try {
|
|
@@ -1738,10 +2156,20 @@ function connectTunnel(token) {
|
|
|
1738
2156
|
// maxPayload 必须显式给:ws 客户端默认 100MiB,而 router→bridge 的单帧不分块,
|
|
1739
2157
|
// 大文件上传的 base64 请求体可以顶到 89.5MB(见 TUNNEL_WS_MAX_PAYLOAD 注释)。
|
|
1740
2158
|
const ws = new WebSocket(endpoint, TUNNEL_WS_OPTIONS);
|
|
1741
|
-
|
|
2159
|
+
// 控制帧优先(见 makePrioritySender 上方长注释):旧实现把 16MB 响应在同一个 tick 里
|
|
2160
|
+
// 全灌进 ws 队列,ws-open 回包被埋在后面 → router 20s 超时。新发送器给控制帧留快车道,
|
|
2161
|
+
// 并用 bufferedAmount 高水位限制"已入队未发送"的字节量。
|
|
2162
|
+
const sender = makePrioritySender({
|
|
2163
|
+
rawSend: (s) => { if (ws.readyState === WebSocket.OPEN) ws.send(s); },
|
|
2164
|
+
bufferedAmount: () => ws.bufferedAmount,
|
|
2165
|
+
...tunnelBulkOptionsFromEnv()
|
|
2166
|
+
});
|
|
2167
|
+
const send = sender.send;
|
|
1742
2168
|
let heartbeat;
|
|
1743
2169
|
let pongReceived = true;
|
|
1744
2170
|
let missedPongs = 0;
|
|
2171
|
+
let pingSentAt = 0;
|
|
2172
|
+
let rttEma = null;
|
|
1745
2173
|
|
|
1746
2174
|
ws.on("open", () => {
|
|
1747
2175
|
tunnelRetry = 0;
|
|
@@ -1751,22 +2179,45 @@ function connectTunnel(token) {
|
|
|
1751
2179
|
// caps:bridge E2EE 能力上报(§6.2/§7.1;router 纯透传)。enabled=false → 空数组(明文回退)
|
|
1752
2180
|
ws.send(JSON.stringify({ type: "tunnel-register", deviceId: DEVICE_ID, token, name: os.hostname() || "dsh-bridge", caps: e2ee.caps() }));
|
|
1753
2181
|
} catch (e) { console.log(`[bridge] 注册发送失败: ${e.message}`); }
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
2182
|
+
// 心跳用「可重排的 setTimeout 链」,而不是 setInterval:间隔与判定窗口都要按 RTT /
|
|
2183
|
+
// 事件循环延迟逐拍自适应(见 adaptiveHeartbeat 长注释)。
|
|
2184
|
+
const beat = () => {
|
|
2185
|
+
heartbeat = null;
|
|
2186
|
+
const plan = adaptiveHeartbeat({
|
|
2187
|
+
pongReceived,
|
|
2188
|
+
missedPongs,
|
|
2189
|
+
rttMs: rttEma,
|
|
2190
|
+
eventLoopLagMs: currentEventLoopLagMs(),
|
|
2191
|
+
baseIntervalMs: TUNNEL_HEARTBEAT_MS,
|
|
2192
|
+
baseMaxMissed: HEARTBEAT_MAX_MISSED_PONGS,
|
|
2193
|
+
maxMissedCap: HEARTBEAT_MAX_MISSED_CAP,
|
|
2194
|
+
adaptive: HEARTBEAT_ADAPTIVE
|
|
2195
|
+
});
|
|
2196
|
+
missedPongs = plan.missedPongs;
|
|
1759
2197
|
pongReceived = false;
|
|
1760
|
-
if (
|
|
1761
|
-
console.log(`[bridge] 隧道连续 ${missedPongs}
|
|
2198
|
+
if (plan.terminate) {
|
|
2199
|
+
console.log(`[bridge] 隧道连续 ${missedPongs} 次心跳无响应(窗口 ${plan.maxMissed} 拍${plan.reasons.length ? ", " + plan.reasons.join("+") : ""}),主动重连...`);
|
|
1762
2200
|
try { ws.terminate(); } catch { /* 已关闭 */ }
|
|
1763
2201
|
return;
|
|
1764
2202
|
}
|
|
1765
|
-
if (missedPongs > 0)
|
|
1766
|
-
|
|
1767
|
-
|
|
2203
|
+
if (missedPongs > 0) {
|
|
2204
|
+
console.log(`[bridge] 隧道心跳无响应(${missedPongs}/${plan.maxMissed}${plan.reasons.length ? ", " + plan.reasons.join("+") : ""}),继续探测...`);
|
|
2205
|
+
}
|
|
2206
|
+
pingSentAt = Date.now();
|
|
2207
|
+
try { ws.ping(); } catch { try { ws.terminate(); } catch { /* 已关闭 */ } return; }
|
|
2208
|
+
heartbeat = setTimeout(beat, plan.intervalMs);
|
|
2209
|
+
};
|
|
2210
|
+
heartbeat = setTimeout(beat, TUNNEL_HEARTBEAT_MS);
|
|
2211
|
+
});
|
|
2212
|
+
ws.on("pong", () => {
|
|
2213
|
+
pongReceived = true;
|
|
2214
|
+
missedPongs = 0;
|
|
2215
|
+
if (pingSentAt > 0) {
|
|
2216
|
+
const rtt = Date.now() - pingSentAt;
|
|
2217
|
+
pingSentAt = 0;
|
|
2218
|
+
rttEma = rttEma === null ? rtt : Math.round(rttEma * 0.7 + rtt * 0.3);
|
|
2219
|
+
}
|
|
1768
2220
|
});
|
|
1769
|
-
ws.on("pong", () => { pongReceived = true; missedPongs = 0; });
|
|
1770
2221
|
ws.on("message", (raw) => {
|
|
1771
2222
|
const receive = makeFrameReceiver();
|
|
1772
2223
|
try {
|
|
@@ -1791,7 +2242,9 @@ function connectTunnel(token) {
|
|
|
1791
2242
|
});
|
|
1792
2243
|
ws.on("close", (code, reason) => {
|
|
1793
2244
|
routerCaps.clear(); // 换中继/断线:能力重新协商,期间一律退回保守行为
|
|
1794
|
-
|
|
2245
|
+
if (heartbeat) { try { clearTimeout(heartbeat); } catch { /* 忽略 */ } }
|
|
2246
|
+
heartbeat = null;
|
|
2247
|
+
sender.close(); // 丢掉未发分片,别让重连后的新连接接到旧响应的剩余部分
|
|
1795
2248
|
console.log(`[bridge] 隧道断开(code=${code}${reason ? ", " + reason : ""})`);
|
|
1796
2249
|
closeAllWsSessions();
|
|
1797
2250
|
// C2 + C5:4000(被同 deviceId 的新连接顶掉)走长退避/退出;其余走 2/4/8/10s + 抖动。
|
|
@@ -1837,7 +2290,30 @@ async function initE2ee(token) {
|
|
|
1837
2290
|
writeE2eeStateFile(stateFileDir, { enabled: st.enabled, reason: st.reason, profile: st.profile, epoch: st.epoch, caps: e2ee.caps() });
|
|
1838
2291
|
}
|
|
1839
2292
|
if (st.enabled) {
|
|
1840
|
-
|
|
2293
|
+
// ── E2EE 会话持久化(2026-10-23)────────────────────────────────────────────
|
|
2294
|
+
// 生产事故:bridge 重启/崩一次,手机端保存的 sessId 就全成死票(每个请求 502
|
|
2295
|
+
// unknown_session、每条数据 WS 1008),用户必须回设备列表重新解锁(实测某设备 3964 次)。
|
|
2296
|
+
// 方案:把会话材料(SHK/saltH/verified/ctrl 计数)用 MK 派生的 storeKey 做 AES-256-GCM
|
|
2297
|
+
// 加密后落 relay 数据目录 .e2ee-sessions.enc(0600,原子写),启动时解密恢复 ——
|
|
2298
|
+
// 文件里没有 MK/密码/密钥,明文不存在于磁盘。详见 e2ee-client.mjs「会话持久化」段。
|
|
2299
|
+
// 落盘条件与状态文件同源:受管安装或显式给了配置目录(仓库里直接跑不写工作区)。
|
|
2300
|
+
const storeDir = process.env.DSH_BRIDGE_E2EE_STORE_DIR
|
|
2301
|
+
|| ((process.env.DSH_BRIDGE_CONFIG || process.env.DSH_BRIDGE_E2EE_STATE || IS_MANAGED_INSTALL) ? stateFileDir : "");
|
|
2302
|
+
const storeEnabled = process.env.DSH_BRIDGE_E2EE_SESSIONS !== "0";
|
|
2303
|
+
const store = e2ee.attachStore(storeDir, { enabled: storeEnabled });
|
|
2304
|
+
if (store.restored > 0) {
|
|
2305
|
+
console.log(`[bridge] 🔓 已恢复 ${store.restored} 个 E2EE 会话(加密落盘;安全策略=v2:全部标记「需重新握手」,不复用旧计数)`);
|
|
2306
|
+
}
|
|
2307
|
+
if (store.discarded) {
|
|
2308
|
+
// 丢弃 = 手机手里的旧票必然对不上:后续 ws-open/HTTP 会带 retryable 的 rehandshake 信号,
|
|
2309
|
+
// 手机自动重建会话而不是让用户去输密码。
|
|
2310
|
+
console.log(`[bridge] E2EE 会话存储已作废(${store.discarded}),手机端将自动重新握手`);
|
|
2311
|
+
}
|
|
2312
|
+
if (storeDir && storeEnabled) {
|
|
2313
|
+
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存,会话库加密落盘`);
|
|
2314
|
+
} else {
|
|
2315
|
+
console.log(`[bridge] 🔒 E2EE 已启用(caps=[${e2ee.caps().join(",")}], profile=${st.profile}, epoch=${st.epoch});MK 仅驻内存(会话库未落盘)`);
|
|
2316
|
+
}
|
|
1841
2317
|
} else {
|
|
1842
2318
|
console.log(`[bridge] e2ee 未启用(${st.reason}) → 明文 v1 路径照常(能力不上报)`);
|
|
1843
2319
|
}
|
|
@@ -2215,11 +2691,18 @@ async function main() {
|
|
|
2215
2691
|
if (shuttingDown) return;
|
|
2216
2692
|
shuttingDown = true;
|
|
2217
2693
|
console.log(`[bridge] 收到 ${sig},正在优雅退出...`);
|
|
2694
|
+
// 退出前把 E2EE 会话库刷一次(滑动 TTL 的 usedAt 要落盘;崩溃也能恢复到最多 60s 前)
|
|
2695
|
+
try { e2ee.flushStore(); } catch { /* 已内部兜底 */ }
|
|
2218
2696
|
await stopWeChat();
|
|
2219
2697
|
process.exit(0);
|
|
2220
2698
|
};
|
|
2221
2699
|
process.on("SIGTERM", () => { shutdown("SIGTERM"); });
|
|
2222
2700
|
process.on("SIGINT", () => { shutdown("SIGINT"); });
|
|
2701
|
+
// Windows 的 TerminateProcess 不会跑上面的信号处理,但 exit 钩子可以(同步操作)——
|
|
2702
|
+
// 会话库是同步写,放在这里能在被强杀前尽量落一次盘。
|
|
2703
|
+
process.on("exit", () => { try { e2ee.flushStore(); e2ee.disposeStore(); } catch { /* 忽略 */ } });
|
|
2704
|
+
// 事件循环延迟探针:心跳自适应要知道"本机是不是忙"(见 adaptiveHeartbeat 长注释)。
|
|
2705
|
+
startEventLoopLagMonitor();
|
|
2223
2706
|
// 【Windows 的关键一条】Windows 上 process.kill() 是**无条件 TerminateProcess**:
|
|
2224
2707
|
// 上面的 SIGTERM/SIGINT 处理函数在 Windows 上**永远不会被执行**,
|
|
2225
2708
|
// 于是 notifystop(微信通道下线通知)永远发不出去,腾讯侧一直以为通道还在线。
|
|
@@ -2246,6 +2729,14 @@ async function main() {
|
|
|
2246
2729
|
// test hooks:镜像页加载看门狗的内部状态(给用例断言"后续请求会撤掉它",见 test/bridge-html-inject.test.mjs)
|
|
2247
2730
|
export function __mirrorLoadWatchState() { return mirrorLoadWatch ? { ...mirrorLoadWatch } : null; }
|
|
2248
2731
|
export function __armMirrorLoadWatch(path) { armMirrorLoadWatch(path); }
|
|
2732
|
+
// test hooks:注入 E2EE 服务(生产由 initE2ee 设置;ws-open 会话校验/快速应答用例用)
|
|
2733
|
+
export function __setE2eeForTest(svc) { e2ee = svc; return e2ee; }
|
|
2734
|
+
// test hooks:当前 ws 会话数与某条会话的内部状态(快速应答/待发队列用例用)
|
|
2735
|
+
export function __wsSessionState(id) {
|
|
2736
|
+
const s = wsSessions.get(id);
|
|
2737
|
+
if (!s) return null;
|
|
2738
|
+
return { opened: s.opened === true, pending: s.pending ? s.pending.length : 0, cancelled: s.cancelled === true, failed: s.failed === true };
|
|
2739
|
+
}
|
|
2249
2740
|
|
|
2250
2741
|
// 直接运行(node dsh-bridge.mjs)时启动服务;被测试 import 时只导出协议函数。
|
|
2251
2742
|
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|