im-rtc-call-engine 1.0.0
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/LICENSE +21 -0
- package/dist/bytes.d.ts +11 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +20 -0
- package/dist/bytes.js.map +1 -0
- package/dist/callOptions.d.ts +27 -0
- package/dist/callOptions.d.ts.map +1 -0
- package/dist/callOptions.js +19 -0
- package/dist/callOptions.js.map +1 -0
- package/dist/deviceId.d.ts +10 -0
- package/dist/deviceId.d.ts.map +1 -0
- package/dist/deviceId.js +55 -0
- package/dist/deviceId.js.map +1 -0
- package/dist/engine.d.ts +302 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +489 -0
- package/dist/engine.js.map +1 -0
- package/dist/engineBus.d.ts +16 -0
- package/dist/engineBus.d.ts.map +1 -0
- package/dist/engineBus.js +89 -0
- package/dist/engineBus.js.map +1 -0
- package/dist/engineWiring.d.ts +40 -0
- package/dist/engineWiring.d.ts.map +1 -0
- package/dist/engineWiring.js +57 -0
- package/dist/engineWiring.js.map +1 -0
- package/dist/errors.d.ts +185 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +253 -0
- package/dist/errors.js.map +1 -0
- package/dist/eventBus.d.ts +24 -0
- package/dist/eventBus.d.ts.map +1 -0
- package/dist/eventBus.js +50 -0
- package/dist/eventBus.js.map +1 -0
- package/dist/events.d.ts +220 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +35 -0
- package/dist/events.js.map +1 -0
- package/dist/frameLoop.d.ts +93 -0
- package/dist/frameLoop.d.ts.map +1 -0
- package/dist/frameLoop.js +346 -0
- package/dist/frameLoop.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +64 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +101 -0
- package/dist/logger.js.map +1 -0
- package/dist/media/engineMediaApi.d.ts +40 -0
- package/dist/media/engineMediaApi.d.ts.map +1 -0
- package/dist/media/engineMediaApi.js +71 -0
- package/dist/media/engineMediaApi.js.map +1 -0
- package/dist/media/firstFrameGate.d.ts +47 -0
- package/dist/media/firstFrameGate.d.ts.map +1 -0
- package/dist/media/firstFrameGate.js +117 -0
- package/dist/media/firstFrameGate.js.map +1 -0
- package/dist/media/mediaAdapter.d.ts +135 -0
- package/dist/media/mediaAdapter.d.ts.map +1 -0
- package/dist/media/mediaAdapter.js +2 -0
- package/dist/media/mediaAdapter.js.map +1 -0
- package/dist/media/mediaBridge.d.ts +99 -0
- package/dist/media/mediaBridge.d.ts.map +1 -0
- package/dist/media/mediaBridge.js +186 -0
- package/dist/media/mediaBridge.js.map +1 -0
- package/dist/media/mediaPlane.d.ts +58 -0
- package/dist/media/mediaPlane.d.ts.map +1 -0
- package/dist/media/mediaPlane.js +119 -0
- package/dist/media/mediaPlane.js.map +1 -0
- package/dist/media/videoProfile.d.ts +79 -0
- package/dist/media/videoProfile.d.ts.map +1 -0
- package/dist/media/videoProfile.js +46 -0
- package/dist/media/videoProfile.js.map +1 -0
- package/dist/media/videoProfile.test.d.ts +2 -0
- package/dist/media/videoProfile.test.d.ts.map +1 -0
- package/dist/media/videoProfile.test.js +26 -0
- package/dist/media/videoProfile.test.js.map +1 -0
- package/dist/media/viewRegistry.d.ts +75 -0
- package/dist/media/viewRegistry.d.ts.map +1 -0
- package/dist/media/viewRegistry.js +130 -0
- package/dist/media/viewRegistry.js.map +1 -0
- package/dist/media/webrtcAdapter.d.ts +158 -0
- package/dist/media/webrtcAdapter.d.ts.map +1 -0
- package/dist/media/webrtcAdapter.js +502 -0
- package/dist/media/webrtcAdapter.js.map +1 -0
- package/dist/protocolId.d.ts +14 -0
- package/dist/protocolId.d.ts.map +1 -0
- package/dist/protocolId.js +109 -0
- package/dist/protocolId.js.map +1 -0
- package/dist/reasons.d.ts +63 -0
- package/dist/reasons.d.ts.map +1 -0
- package/dist/reasons.js +81 -0
- package/dist/reasons.js.map +1 -0
- package/dist/redact.d.ts +19 -0
- package/dist/redact.d.ts.map +1 -0
- package/dist/redact.js +50 -0
- package/dist/redact.js.map +1 -0
- package/dist/signaling/backoff.d.ts +17 -0
- package/dist/signaling/backoff.d.ts.map +1 -0
- package/dist/signaling/backoff.js +23 -0
- package/dist/signaling/backoff.js.map +1 -0
- package/dist/signaling/candidate.d.ts +19 -0
- package/dist/signaling/candidate.d.ts.map +1 -0
- package/dist/signaling/candidate.js +22 -0
- package/dist/signaling/candidate.js.map +1 -0
- package/dist/signaling/caseMapping.d.ts +19 -0
- package/dist/signaling/caseMapping.d.ts.map +1 -0
- package/dist/signaling/caseMapping.js +34 -0
- package/dist/signaling/caseMapping.js.map +1 -0
- package/dist/signaling/connection.d.ts +110 -0
- package/dist/signaling/connection.d.ts.map +1 -0
- package/dist/signaling/connection.js +365 -0
- package/dist/signaling/connection.js.map +1 -0
- package/dist/signaling/connectionFactory.d.ts +55 -0
- package/dist/signaling/connectionFactory.d.ts.map +1 -0
- package/dist/signaling/connectionFactory.js +29 -0
- package/dist/signaling/connectionFactory.js.map +1 -0
- package/dist/signaling/connectionTypes.d.ts +94 -0
- package/dist/signaling/connectionTypes.d.ts.map +1 -0
- package/dist/signaling/connectionTypes.js +2 -0
- package/dist/signaling/connectionTypes.js.map +1 -0
- package/dist/signaling/discipline.d.ts +6 -0
- package/dist/signaling/discipline.d.ts.map +1 -0
- package/dist/signaling/discipline.js +100 -0
- package/dist/signaling/discipline.js.map +1 -0
- package/dist/signaling/enums.d.ts +53 -0
- package/dist/signaling/enums.d.ts.map +1 -0
- package/dist/signaling/enums.js +52 -0
- package/dist/signaling/enums.js.map +1 -0
- package/dist/signaling/envelope.d.ts +31 -0
- package/dist/signaling/envelope.d.ts.map +1 -0
- package/dist/signaling/envelope.js +91 -0
- package/dist/signaling/envelope.js.map +1 -0
- package/dist/signaling/fieldSpec.d.ts +101 -0
- package/dist/signaling/fieldSpec.d.ts.map +1 -0
- package/dist/signaling/fieldSpec.js +130 -0
- package/dist/signaling/fieldSpec.js.map +1 -0
- package/dist/signaling/frameSender.d.ts +34 -0
- package/dist/signaling/frameSender.d.ts.map +1 -0
- package/dist/signaling/frameSender.js +57 -0
- package/dist/signaling/frameSender.js.map +1 -0
- package/dist/signaling/frames.call.d.ts +299 -0
- package/dist/signaling/frames.call.d.ts.map +1 -0
- package/dist/signaling/frames.call.js +149 -0
- package/dist/signaling/frames.call.js.map +1 -0
- package/dist/signaling/frames.room.d.ts +566 -0
- package/dist/signaling/frames.room.d.ts.map +1 -0
- package/dist/signaling/frames.room.js +165 -0
- package/dist/signaling/frames.room.js.map +1 -0
- package/dist/signaling/frames.sys.d.ts +145 -0
- package/dist/signaling/frames.sys.d.ts.map +1 -0
- package/dist/signaling/frames.sys.js +53 -0
- package/dist/signaling/frames.sys.js.map +1 -0
- package/dist/signaling/handshakeGiveUp.d.ts +36 -0
- package/dist/signaling/handshakeGiveUp.d.ts.map +1 -0
- package/dist/signaling/handshakeGiveUp.js +48 -0
- package/dist/signaling/handshakeGiveUp.js.map +1 -0
- package/dist/signaling/heartbeat.d.ts +34 -0
- package/dist/signaling/heartbeat.d.ts.map +1 -0
- package/dist/signaling/heartbeat.js +52 -0
- package/dist/signaling/heartbeat.js.map +1 -0
- package/dist/signaling/pendingRequests.d.ts +33 -0
- package/dist/signaling/pendingRequests.d.ts.map +1 -0
- package/dist/signaling/pendingRequests.js +57 -0
- package/dist/signaling/pendingRequests.js.map +1 -0
- package/dist/signaling/reconnector.d.ts +43 -0
- package/dist/signaling/reconnector.d.ts.map +1 -0
- package/dist/signaling/reconnector.js +76 -0
- package/dist/signaling/reconnector.js.map +1 -0
- package/dist/signaling/registry.d.ts +100 -0
- package/dist/signaling/registry.d.ts.map +1 -0
- package/dist/signaling/registry.js +217 -0
- package/dist/signaling/registry.js.map +1 -0
- package/dist/signaling/resumeDeadline.d.ts +48 -0
- package/dist/signaling/resumeDeadline.d.ts.map +1 -0
- package/dist/signaling/resumeDeadline.js +89 -0
- package/dist/signaling/resumeDeadline.js.map +1 -0
- package/dist/signaling/tokenExpiry.d.ts +89 -0
- package/dist/signaling/tokenExpiry.d.ts.map +1 -0
- package/dist/signaling/tokenExpiry.js +106 -0
- package/dist/signaling/tokenExpiry.js.map +1 -0
- package/dist/signaling/webSocket.d.ts +59 -0
- package/dist/signaling/webSocket.d.ts.map +1 -0
- package/dist/signaling/webSocket.js +50 -0
- package/dist/signaling/webSocket.js.map +1 -0
- package/dist/state/callMachine.d.ts +56 -0
- package/dist/state/callMachine.d.ts.map +1 -0
- package/dist/state/callMachine.js +176 -0
- package/dist/state/callMachine.js.map +1 -0
- package/dist/state/callRecv.d.ts +12 -0
- package/dist/state/callRecv.d.ts.map +1 -0
- package/dist/state/callRecv.js +255 -0
- package/dist/state/callRecv.js.map +1 -0
- package/dist/state/engineMachine.d.ts +41 -0
- package/dist/state/engineMachine.d.ts.map +1 -0
- package/dist/state/engineMachine.js +210 -0
- package/dist/state/engineMachine.js.map +1 -0
- package/dist/state/forceEnd.d.ts +44 -0
- package/dist/state/forceEnd.d.ts.map +1 -0
- package/dist/state/forceEnd.js +96 -0
- package/dist/state/forceEnd.js.map +1 -0
- package/dist/state/roomMachine.d.ts +92 -0
- package/dist/state/roomMachine.d.ts.map +1 -0
- package/dist/state/roomMachine.js +319 -0
- package/dist/state/roomMachine.js.map +1 -0
- package/dist/state/roomRecv.d.ts +7 -0
- package/dist/state/roomRecv.d.ts.map +1 -0
- package/dist/state/roomRecv.js +209 -0
- package/dist/state/roomRecv.js.map +1 -0
- package/dist/state/types.d.ts +61 -0
- package/dist/state/types.d.ts.map +1 -0
- package/dist/state/types.js +30 -0
- package/dist/state/types.js.map +1 -0
- package/dist/version.d.ts +11 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +11 -0
- package/dist/version.js.map +1 -0
- package/package.json +35 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { logger } from '../logger.js';
|
|
2
|
+
/** 协议 §1.4 的恢复窗口:30 秒。**四端同一个值**,服务端的 `ResumeWindow` 也是它。 */
|
|
3
|
+
export const RESUME_WINDOW_SEC = 30;
|
|
4
|
+
/** 服务端判一条连接死掉要连续几个心跳周期收不到东西(§1.3)。 */
|
|
5
|
+
export const SERVER_DEATH_PINGS = 3;
|
|
6
|
+
/** 余量:跨过服务端窗口到期那一刻再收场,别跟它抢同一秒。 */
|
|
7
|
+
export const GIVE_UP_GRACE_SEC = 5;
|
|
8
|
+
/*
|
|
9
|
+
断开多久之后可以断定「服务端那一侧的会话没了」。
|
|
10
|
+
|
|
11
|
+
# 为什么不是恢复窗口那 30 秒
|
|
12
|
+
|
|
13
|
+
服务端的 30 秒**不是从我们断开的那一刻算起的**,是从**它自己察觉**的那一刻算起。
|
|
14
|
+
而它靠读超时察觉:连续 3 个心跳周期收不到任何东西才判死(§1.3)。
|
|
15
|
+
我们断开时距离上一帧最多一个心跳周期,所以最晚的到期时刻是
|
|
16
|
+
`断开 + 3×ping + 30s`——按默认 15 秒心跳就是 45 + 30 = 75 秒,再加一点余量。
|
|
17
|
+
|
|
18
|
+
# 为什么必须取上界
|
|
19
|
+
|
|
20
|
+
取短了就会撒谎:真机 2026-09-08 实测,断开 14 秒后重连**成功恢复**,通话好端端地继续。
|
|
21
|
+
在那之前宣布「通话已结束」是把一通还能救回来的电话杀掉,而且服务端还认为我们在房里,
|
|
22
|
+
房间会挂着一个幽灵成员。**宁可让用户多看几十秒「正在重连」,也不能提前下结论。**
|
|
23
|
+
*/
|
|
24
|
+
export function giveUpDelayMs(pingIntervalSec) {
|
|
25
|
+
return (SERVER_DEATH_PINGS * pingIntervalSec + RESUME_WINDOW_SEC + GIVE_UP_GRACE_SEC) * 1000;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* 「服务端已经彻底放弃这条会话」的倒计时(协议 §1.4)。
|
|
29
|
+
*
|
|
30
|
+
* # 为什么需要它
|
|
31
|
+
*
|
|
32
|
+
* 客户端本地放弃的唯一入口原本是「重连上了但 `resumed=false`」——**它要求先连回来**。
|
|
33
|
+
* 网络一直不回来的话那一刻永远不会到,界面就永远停在「正在重连」,
|
|
34
|
+
* 而且**连挂断都点不动**(挂断只产出一帧发不出去的 `call.hangup`,
|
|
35
|
+
* 本地状态按 §4.2 铁律 1 一动不动)。真机 2026-09-08 的 iOS 端就是这一幕。
|
|
36
|
+
*
|
|
37
|
+
* # 从 Connection 里拆出来
|
|
38
|
+
*
|
|
39
|
+
* 和 `Reconnector` 同一个理由:体量红线(CONVENTIONS §2),
|
|
40
|
+
* 也让「什么时候该放弃」这条规则有独立的测试面。
|
|
41
|
+
*/
|
|
42
|
+
export class ResumeDeadline {
|
|
43
|
+
onUnrecoverable;
|
|
44
|
+
timer = null;
|
|
45
|
+
pingIntervalSec = 15;
|
|
46
|
+
constructor(onUnrecoverable) {
|
|
47
|
+
this.onUnrecoverable = onUnrecoverable;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* 握手成功:撤掉倒计时,并记下服务端给的心跳周期(上界要拿它推算)。
|
|
51
|
+
*
|
|
52
|
+
* **不管 `resumed` 是真是假都要撤** —— 服务端已经给出裁决,
|
|
53
|
+
* `resumed=false` 那条自有 `handleHelloOk` 去收场,不该再由倒计时补一刀。
|
|
54
|
+
*/
|
|
55
|
+
connected(pingIntervalSec) {
|
|
56
|
+
this.cancel();
|
|
57
|
+
this.pingIntervalSec = pingIntervalSec;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 起倒计时。
|
|
61
|
+
*
|
|
62
|
+
* **只在第一次断开时起**:每一次重连失败都会走到调用点,每次都重排的话
|
|
63
|
+
* 截止时刻就一直往后挪、永远不会到——而那正是它要治的病。
|
|
64
|
+
* 起点是第一次断开的那一刻,与服务端算的是同一笔账。
|
|
65
|
+
*/
|
|
66
|
+
arm() {
|
|
67
|
+
if (this.timer !== null)
|
|
68
|
+
return;
|
|
69
|
+
const delayMs = giveUpDelayMs(this.pingIntervalSec);
|
|
70
|
+
logger.info('恢复窗口倒计时已起', { delayMs });
|
|
71
|
+
this.timer = setTimeout(() => {
|
|
72
|
+
this.timer = null;
|
|
73
|
+
logger.warn('断开已超过恢复窗口,会话不可恢复', {});
|
|
74
|
+
this.onUnrecoverable();
|
|
75
|
+
}, delayMs);
|
|
76
|
+
}
|
|
77
|
+
/** 撤销。连上了、或宿主 logout 了都要调。幂等。 */
|
|
78
|
+
cancel() {
|
|
79
|
+
if (this.timer !== null) {
|
|
80
|
+
clearTimeout(this.timer);
|
|
81
|
+
this.timer = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** armed 供测试与诊断观察。 */
|
|
85
|
+
get armed() {
|
|
86
|
+
return this.timer !== null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=resumeDeadline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resumeDeadline.js","sourceRoot":"","sources":["../../src/signaling/resumeDeadline.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,cAAc,CAAC;AAEtC,6DAA6D;AAC7D,MAAM,CAAC,MAAM,iBAAiB,GAAG,EAAE,CAAC;AAEpC,sCAAsC;AACtC,MAAM,CAAC,MAAM,kBAAkB,GAAG,CAAC,CAAC;AAEpC,kCAAkC;AAClC,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,CAAC;AAEnC;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,aAAa,CAAC,eAAuB;IACnD,OAAO,CAAC,kBAAkB,GAAG,eAAe,GAAG,iBAAiB,GAAG,iBAAiB,CAAC,GAAG,IAAI,CAAC;AAC/F,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,OAAO,cAAc;IAII;IAHrB,KAAK,GAAyC,IAAI,CAAC;IACnD,eAAe,GAAG,EAAE,CAAC;IAE7B,YAA6B,eAA2B;QAA3B,oBAAe,GAAf,eAAe,CAAY;IAAG,CAAC;IAE5D;;;;;OAKG;IACH,SAAS,CAAC,eAAuB;QAC/B,IAAI,CAAC,MAAM,EAAE,CAAC;QACd,IAAI,CAAC,eAAe,GAAG,eAAe,CAAC;IACzC,CAAC;IAED;;;;;;OAMG;IACH,GAAG;QACD,IAAI,IAAI,CAAC,KAAK,KAAK,IAAI;YAAE,OAAO;QAChC,MAAM,OAAO,GAAG,aAAa,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC;QACpD,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC;QACtC,IAAI,CAAC,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC3B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;YAClB,MAAM,CAAC,IAAI,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC;YACpC,IAAI,CAAC,eAAe,EAAE,CAAC;QACzB,CAAC,EAAE,OAAO,CAAC,CAAC;IACd,CAAC;IAED,iCAAiC;IACjC,MAAM;QACJ,IAAI,IAAI,CAAC,KAAK,KAAK,IAAI,EAAE,CAAC;YACxB,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACzB,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC;QACpB,CAAC;IACH,CAAC;IAED,sBAAsB;IACtB,IAAI,KAAK;QACP,OAAO,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC;IAC7B,CAAC;CACF"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 接入票到期前的主动换票定时器。
|
|
3
|
+
*
|
|
4
|
+
* # 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 服务端只在 `sys.hello` 握手时验一次票,之后永不复查。所以票过期**不会**断开已建立的
|
|
7
|
+
* 连接——真正出问题的时刻是**过期之后的第一次重连**:那一次握手撞上 `4401`,
|
|
8
|
+
* 按退避档重试三次,然后 `kickedOut`,用户被踢回登录页。
|
|
9
|
+
*
|
|
10
|
+
* 而这条路径是**被动**的:用户先经历一次掉线加三轮重试,宿主才知道要换票。
|
|
11
|
+
* 有了 `sys.hello.ok` 下发的 `token_expires_at_ms`,我们可以在到期**前**提醒宿主,
|
|
12
|
+
* 让它静默换票,用户全程无感。
|
|
13
|
+
*
|
|
14
|
+
* 见 im-rtc-server/docs/design/TOKEN_LIFECYCLE_DESIGN.md §6-①②。
|
|
15
|
+
*
|
|
16
|
+
* # 为什么单独一个模块
|
|
17
|
+
*
|
|
18
|
+
* 它是纯逻辑(时刻计算 + 一个定时器),可以脱离 WebSocket 直接单测;
|
|
19
|
+
* 而 connection.ts 已经贴着 400 行的体量红线(CONVENTIONS §2)。
|
|
20
|
+
*/
|
|
21
|
+
/** TimerHandle 是宿主环境的定时器句柄。Node 与浏览器的类型不同,这里不关心具体是什么。 */
|
|
22
|
+
export type TimerHandle = ReturnType<typeof setTimeout>;
|
|
23
|
+
/**
|
|
24
|
+
* DEFAULT_LEAD_MS 是提前量:到期前多久提醒宿主换票。
|
|
25
|
+
*
|
|
26
|
+
* 60 秒的依据是「够宿主打一次自家后台的换票接口,且不至于早到让宿主觉得莫名其妙」。
|
|
27
|
+
* 比这个再短的话,一次慢请求就跨过了到期时刻。
|
|
28
|
+
*/
|
|
29
|
+
export declare const DEFAULT_LEAD_MS = 60000;
|
|
30
|
+
/**
|
|
31
|
+
* MAX_TIMER_DELAY_MS 是 `setTimeout` 延时的 32 位上限(2^31-1 ≈ 24.8 天)。
|
|
32
|
+
*
|
|
33
|
+
* 超过它的值不会「等很久」,而是**立刻触发**——长有效期的票会因此每次握手都误报一次。
|
|
34
|
+
* 见 {@link TokenExpiryTimer.armStep}。
|
|
35
|
+
*/
|
|
36
|
+
export declare const MAX_TIMER_DELAY_MS = 2147483647;
|
|
37
|
+
/** TokenExpiryOptions 是构造参数。带 Fn 的都是为了测试可注入。 */
|
|
38
|
+
export interface TokenExpiryOptions {
|
|
39
|
+
/** 到期前多久触发。默认 {@link DEFAULT_LEAD_MS}。 */
|
|
40
|
+
leadMs?: number;
|
|
41
|
+
/** 触发时调它。**同一张票只会触发一次**。 */
|
|
42
|
+
onWillExpire: (info: {
|
|
43
|
+
expiresAtMs: number;
|
|
44
|
+
}) => void;
|
|
45
|
+
now?: () => number;
|
|
46
|
+
setTimer?: (fn: () => void, ms: number) => TimerHandle;
|
|
47
|
+
clearTimer?: (handle: TimerHandle) => void;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* TokenExpiryTimer 盯着当前这张票的到期时刻,在到期前触发一次提醒。
|
|
51
|
+
*
|
|
52
|
+
* 生命周期:每次握手成功 `arm(hello.tokenExpiresAtMs)`;宿主换票后
|
|
53
|
+
* `arm(newExpiresAtMs)` 重新武装;连接关闭 `disarm()`。
|
|
54
|
+
*/
|
|
55
|
+
export declare class TokenExpiryTimer {
|
|
56
|
+
private handle;
|
|
57
|
+
private readonly leadMs;
|
|
58
|
+
private readonly onWillExpire;
|
|
59
|
+
private readonly now;
|
|
60
|
+
private readonly setTimer;
|
|
61
|
+
private readonly clearTimer;
|
|
62
|
+
constructor(options: TokenExpiryOptions);
|
|
63
|
+
/**
|
|
64
|
+
* arm 按新的到期时刻重新武装。
|
|
65
|
+
*
|
|
66
|
+
* - `expiresAtMs <= 0` = 服务端说「未知」(协议 §1.2)→ **解除武装,不报错**。
|
|
67
|
+
* 这条路径退化成打这个补丁之前的被动行为,是刻意的降级而不是故障。
|
|
68
|
+
* - 已经进入提前量窗口(含已过期)→ **立刻触发一次**,而不是静默跳过。
|
|
69
|
+
* 票只剩 10 秒时更需要提醒宿主,不是更不需要。
|
|
70
|
+
*/
|
|
71
|
+
arm(expiresAtMs: number): void;
|
|
72
|
+
/**
|
|
73
|
+
* armStep 排一段定时;**超过 32 位上限就先睡满一段再续排**。
|
|
74
|
+
*
|
|
75
|
+
* `setTimeout` 的延时是 32 位有符号整数:传超过 2^31-1 ms(约 24.8 天)的值,
|
|
76
|
+
* 浏览器与 Node 都会**溢出成立刻触发**。一枚有效期 30 天的票算出来的延时正好越界,
|
|
77
|
+
* 于是每次握手成功都马上抛一条 `tokenWillExpire`——宿主老老实实去后台换一次票,
|
|
78
|
+
* 下次重连再来一遍,而真正该在到期前 60 秒响的那一次**反而没有了**。
|
|
79
|
+
*
|
|
80
|
+
* 分段续排比钳到上限对:钳完就在第 24.8 天误报,而分段是「睡满一段,醒来重算」,
|
|
81
|
+
* 剩多久算多久。(iOS 的 Int64 毫秒、Android 的 Long 都没有这个坎,只有 JS 有。)
|
|
82
|
+
*/
|
|
83
|
+
private armStep;
|
|
84
|
+
/** disarm 解除武装。重复调用安全。 */
|
|
85
|
+
disarm(): void;
|
|
86
|
+
/** isArmed 供测试与自检用。 */
|
|
87
|
+
get isArmed(): boolean;
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=tokenExpiry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenExpiry.d.ts","sourceRoot":"","sources":["../../src/signaling/tokenExpiry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,yDAAyD;AACzD,MAAM,MAAM,WAAW,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAExD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,QAAS,CAAC;AAEtC;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,aAAgB,CAAC;AAEhD,gDAAgD;AAChD,MAAM,WAAW,kBAAkB;IACjC,0CAA0C;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4BAA4B;IAC5B,YAAY,EAAE,CAAC,IAAI,EAAE;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC;IACtD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,WAAW,CAAC;IACvD,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,WAAW,KAAK,IAAI,CAAC;CAC5C;AAED;;;;;GAKG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,MAAM,CAA4B;IAC1C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAA0C;IACvE,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAA8C;IACvE,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAgC;gBAE/C,OAAO,EAAE,kBAAkB;IAQvC;;;;;;;OAOG;IACH,GAAG,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAM9B;;;;;;;;;;OAUG;IACH,OAAO,CAAC,OAAO;IAef,0BAA0B;IAC1B,MAAM,IAAI,IAAI;IAMd,uBAAuB;IACvB,IAAI,OAAO,IAAI,OAAO,CAErB;CACF"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 接入票到期前的主动换票定时器。
|
|
3
|
+
*
|
|
4
|
+
* # 为什么需要它
|
|
5
|
+
*
|
|
6
|
+
* 服务端只在 `sys.hello` 握手时验一次票,之后永不复查。所以票过期**不会**断开已建立的
|
|
7
|
+
* 连接——真正出问题的时刻是**过期之后的第一次重连**:那一次握手撞上 `4401`,
|
|
8
|
+
* 按退避档重试三次,然后 `kickedOut`,用户被踢回登录页。
|
|
9
|
+
*
|
|
10
|
+
* 而这条路径是**被动**的:用户先经历一次掉线加三轮重试,宿主才知道要换票。
|
|
11
|
+
* 有了 `sys.hello.ok` 下发的 `token_expires_at_ms`,我们可以在到期**前**提醒宿主,
|
|
12
|
+
* 让它静默换票,用户全程无感。
|
|
13
|
+
*
|
|
14
|
+
* 见 im-rtc-server/docs/design/TOKEN_LIFECYCLE_DESIGN.md §6-①②。
|
|
15
|
+
*
|
|
16
|
+
* # 为什么单独一个模块
|
|
17
|
+
*
|
|
18
|
+
* 它是纯逻辑(时刻计算 + 一个定时器),可以脱离 WebSocket 直接单测;
|
|
19
|
+
* 而 connection.ts 已经贴着 400 行的体量红线(CONVENTIONS §2)。
|
|
20
|
+
*/
|
|
21
|
+
/**
|
|
22
|
+
* DEFAULT_LEAD_MS 是提前量:到期前多久提醒宿主换票。
|
|
23
|
+
*
|
|
24
|
+
* 60 秒的依据是「够宿主打一次自家后台的换票接口,且不至于早到让宿主觉得莫名其妙」。
|
|
25
|
+
* 比这个再短的话,一次慢请求就跨过了到期时刻。
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_LEAD_MS = 60_000;
|
|
28
|
+
/**
|
|
29
|
+
* MAX_TIMER_DELAY_MS 是 `setTimeout` 延时的 32 位上限(2^31-1 ≈ 24.8 天)。
|
|
30
|
+
*
|
|
31
|
+
* 超过它的值不会「等很久」,而是**立刻触发**——长有效期的票会因此每次握手都误报一次。
|
|
32
|
+
* 见 {@link TokenExpiryTimer.armStep}。
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
|
35
|
+
/**
|
|
36
|
+
* TokenExpiryTimer 盯着当前这张票的到期时刻,在到期前触发一次提醒。
|
|
37
|
+
*
|
|
38
|
+
* 生命周期:每次握手成功 `arm(hello.tokenExpiresAtMs)`;宿主换票后
|
|
39
|
+
* `arm(newExpiresAtMs)` 重新武装;连接关闭 `disarm()`。
|
|
40
|
+
*/
|
|
41
|
+
export class TokenExpiryTimer {
|
|
42
|
+
handle = null;
|
|
43
|
+
leadMs;
|
|
44
|
+
onWillExpire;
|
|
45
|
+
now;
|
|
46
|
+
setTimer;
|
|
47
|
+
clearTimer;
|
|
48
|
+
constructor(options) {
|
|
49
|
+
this.leadMs = options.leadMs ?? DEFAULT_LEAD_MS;
|
|
50
|
+
this.onWillExpire = options.onWillExpire;
|
|
51
|
+
this.now = options.now ?? (() => Date.now());
|
|
52
|
+
this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
53
|
+
this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* arm 按新的到期时刻重新武装。
|
|
57
|
+
*
|
|
58
|
+
* - `expiresAtMs <= 0` = 服务端说「未知」(协议 §1.2)→ **解除武装,不报错**。
|
|
59
|
+
* 这条路径退化成打这个补丁之前的被动行为,是刻意的降级而不是故障。
|
|
60
|
+
* - 已经进入提前量窗口(含已过期)→ **立刻触发一次**,而不是静默跳过。
|
|
61
|
+
* 票只剩 10 秒时更需要提醒宿主,不是更不需要。
|
|
62
|
+
*/
|
|
63
|
+
arm(expiresAtMs) {
|
|
64
|
+
this.disarm();
|
|
65
|
+
if (expiresAtMs <= 0)
|
|
66
|
+
return;
|
|
67
|
+
this.armStep(expiresAtMs);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* armStep 排一段定时;**超过 32 位上限就先睡满一段再续排**。
|
|
71
|
+
*
|
|
72
|
+
* `setTimeout` 的延时是 32 位有符号整数:传超过 2^31-1 ms(约 24.8 天)的值,
|
|
73
|
+
* 浏览器与 Node 都会**溢出成立刻触发**。一枚有效期 30 天的票算出来的延时正好越界,
|
|
74
|
+
* 于是每次握手成功都马上抛一条 `tokenWillExpire`——宿主老老实实去后台换一次票,
|
|
75
|
+
* 下次重连再来一遍,而真正该在到期前 60 秒响的那一次**反而没有了**。
|
|
76
|
+
*
|
|
77
|
+
* 分段续排比钳到上限对:钳完就在第 24.8 天误报,而分段是「睡满一段,醒来重算」,
|
|
78
|
+
* 剩多久算多久。(iOS 的 Int64 毫秒、Android 的 Long 都没有这个坎,只有 JS 有。)
|
|
79
|
+
*/
|
|
80
|
+
armStep(expiresAtMs) {
|
|
81
|
+
const remaining = expiresAtMs - this.leadMs - this.now();
|
|
82
|
+
const delay = Math.min(Math.max(0, remaining), MAX_TIMER_DELAY_MS);
|
|
83
|
+
// 用 setTimer(…, 0) 而不是同步调用:arm 是在握手成功的路径上调的,
|
|
84
|
+
// 同步回调会让宿主的 updateToken 重入到还没走完的连接流程里。
|
|
85
|
+
this.handle = this.setTimer(() => {
|
|
86
|
+
this.handle = null;
|
|
87
|
+
if (remaining > MAX_TIMER_DELAY_MS) {
|
|
88
|
+
this.armStep(expiresAtMs);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.onWillExpire({ expiresAtMs });
|
|
92
|
+
}, delay);
|
|
93
|
+
}
|
|
94
|
+
/** disarm 解除武装。重复调用安全。 */
|
|
95
|
+
disarm() {
|
|
96
|
+
if (this.handle === null)
|
|
97
|
+
return;
|
|
98
|
+
this.clearTimer(this.handle);
|
|
99
|
+
this.handle = null;
|
|
100
|
+
}
|
|
101
|
+
/** isArmed 供测试与自检用。 */
|
|
102
|
+
get isArmed() {
|
|
103
|
+
return this.handle !== null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=tokenExpiry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tokenExpiry.js","sourceRoot":"","sources":["../../src/signaling/tokenExpiry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAKH;;;;;GAKG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC;AAEtC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,aAAa,CAAC;AAahD;;;;;GAKG;AACH,MAAM,OAAO,gBAAgB;IACnB,MAAM,GAAuB,IAAI,CAAC;IACzB,MAAM,CAAS;IACf,YAAY,CAA0C;IACtD,GAAG,CAAe;IAClB,QAAQ,CAA8C;IACtD,UAAU,CAAgC;IAE3D,YAAY,OAA2B;QACrC,IAAI,CAAC,MAAM,GAAG,OAAO,CAAC,MAAM,IAAI,eAAe,CAAC;QAChD,IAAI,CAAC,YAAY,GAAG,OAAO,CAAC,YAAY,CAAC;QACzC,IAAI,CAAC,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAW,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QACrD,IAAI,CAAC,QAAQ,GAAG,OAAO,CAAC,QAAQ,IAAI,CAAC,CAAC,EAAE,EAAE,EAAE,EAAe,EAAE,CAAC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,CAAC;QAClF,IAAI,CAAC,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,CAAC,CAAC,MAAM,EAAQ,EAAE,CAAC,YAAY,CAAC,MAAM,CAAC,CAAC,CAAC;IACnF,CAAC;IAED;;;;;;;OAOG;IACH,GAAG,CAAC,WAAmB;QACrB,IAAI,CAAC,MAAM,EAAE,CAAC;QACd,IAAI,WAAW,IAAI,CAAC;YAAE,OAAO;QAC7B,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;IAC5B,CAAC;IAED;;;;;;;;;;OAUG;IACK,OAAO,CAAC,WAAmB;QACjC,MAAM,SAAS,GAAG,WAAW,GAAG,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;QACzD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,SAAS,CAAC,EAAE,kBAAkB,CAAC,CAAC;QACnE,6CAA6C;QAC7C,uCAAuC;QACvC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE;YAC/B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;YACnB,IAAI,SAAS,GAAG,kBAAkB,EAAE,CAAC;gBACnC,IAAI,CAAC,OAAO,CAAC,WAAW,CAAC,CAAC;gBAC1B,OAAO;YACT,CAAC;YACD,IAAI,CAAC,YAAY,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC;QACrC,CAAC,EAAE,KAAK,CAAC,CAAC;IACZ,CAAC;IAED,0BAA0B;IAC1B,MAAM;QACJ,IAAI,IAAI,CAAC,MAAM,KAAK,IAAI;YAAE,OAAO;QACjC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QAC7B,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC;IACrB,CAAC;IAED,uBAAuB;IACvB,IAAI,OAAO;QACT,OAAO,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC;IAC9B,CAAC;CACF"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket 的最小接口。
|
|
3
|
+
*
|
|
4
|
+
* engine **必须能在无 DOM 的环境构造**(CONVENTIONS §1)——一致性向量与时序测试
|
|
5
|
+
* 都跑在 Node 里。所以这里只声明用得到的那几个成员,测试注入假实现,
|
|
6
|
+
* 浏览器里注入原生 WebSocket。
|
|
7
|
+
*/
|
|
8
|
+
/** WebSocketLike 是 engine 用到的 WebSocket 子集。 */
|
|
9
|
+
export interface WebSocketLike {
|
|
10
|
+
send(data: string): void;
|
|
11
|
+
close(code?: number, reason?: string): void;
|
|
12
|
+
readyState: number;
|
|
13
|
+
onopen: (() => void) | null;
|
|
14
|
+
onclose: ((event: {
|
|
15
|
+
code: number;
|
|
16
|
+
reason: string;
|
|
17
|
+
}) => void) | null;
|
|
18
|
+
onerror: ((event: unknown) => void) | null;
|
|
19
|
+
onmessage: ((event: {
|
|
20
|
+
data: unknown;
|
|
21
|
+
}) => void) | null;
|
|
22
|
+
}
|
|
23
|
+
/** WebSocketFactory 按 URL 造一条连接。 */
|
|
24
|
+
export type WebSocketFactory = (url: string) => WebSocketLike;
|
|
25
|
+
/** WS_OPEN 是 WebSocket.OPEN 的值,避免在无 DOM 环境引用全局常量。 */
|
|
26
|
+
export declare const WS_OPEN = 1;
|
|
27
|
+
/** CloseCode 是协议约定的 WS 关闭码(RTC_PROTOCOL.md §1.5)。 */
|
|
28
|
+
export declare const CloseCode: {
|
|
29
|
+
/** 正常关闭。**我们自己 logout 时发的就是它**;别人发过来的不算数,见 shouldReconnect。 */
|
|
30
|
+
readonly normal: 1000;
|
|
31
|
+
/** 服务端下线/重启。立即重连。 */
|
|
32
|
+
readonly goingAway: 1001;
|
|
33
|
+
/** 信封非法/帧超长/协议版本不支持。**不重连**,属实现 bug。 */
|
|
34
|
+
readonly badProtocol: 4400;
|
|
35
|
+
/** 未鉴权/鉴权超时/token 无效。换新 token 后重连。 */
|
|
36
|
+
readonly unauthorized: 4401;
|
|
37
|
+
/** 被踢(同 uid 同 device_id 在别处登录)。**不重连**。 */
|
|
38
|
+
readonly kickedOut: 4403;
|
|
39
|
+
/** 频率超限。退避加倍后重连。 */
|
|
40
|
+
readonly rateLimited: 4429;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* shouldReconnect 按关闭码判断要不要重连。
|
|
44
|
+
*
|
|
45
|
+
* 4400 与 4403 **绝不重连**:前者是我们自己的实现 bug,重连只会再撞一次;
|
|
46
|
+
* 后者是被踢,重连等于跟另一台设备打架。
|
|
47
|
+
*
|
|
48
|
+
* **1000 不在这张表里**——「是不是我们自己要走的」看的是意图,不是关闭码。
|
|
49
|
+
* `close()` 会先把 state 置成 `closed`,`connection.ts` 那句
|
|
50
|
+
* `this.state !== 'closed'` 已经把 logout 挡住了;再按码挡一次,挡掉的只剩
|
|
51
|
+
* **别人发过来的 1000**:浏览器掐掉后台标签页的连接、代理/系统休眠断链,
|
|
52
|
+
* 用的都是这个码。服务端从不主动发 1000(重启走 1001 goingAway),
|
|
53
|
+
* 所以这里收到的 1000 一定不是「对面让我别回来了」。
|
|
54
|
+
* 症状是一个后台标签页显示「连接已断开(1000),不会自动回」,必须手动刷新。
|
|
55
|
+
*/
|
|
56
|
+
export declare function shouldReconnect(code: number): boolean;
|
|
57
|
+
/** browserWebSocketFactory 是浏览器环境的默认工厂。 */
|
|
58
|
+
export declare const browserWebSocketFactory: WebSocketFactory;
|
|
59
|
+
//# sourceMappingURL=webSocket.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webSocket.d.ts","sourceRoot":"","sources":["../../src/signaling/webSocket.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,+CAA+C;AAC/C,MAAM,WAAW,aAAa;IAC5B,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5C,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,CAAC,MAAM,IAAI,CAAC,GAAG,IAAI,CAAC;IAC5B,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IACpE,OAAO,EAAE,CAAC,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3C,SAAS,EAAE,CAAC,CAAC,KAAK,EAAE;QAAE,IAAI,EAAE,OAAO,CAAA;KAAE,KAAK,IAAI,CAAC,GAAG,IAAI,CAAC;CACxD;AAED,oCAAoC;AACpC,MAAM,MAAM,gBAAgB,GAAG,CAAC,GAAG,EAAE,MAAM,KAAK,aAAa,CAAC;AAE9D,qDAAqD;AACrD,eAAO,MAAM,OAAO,IAAI,CAAC;AAEzB,qDAAqD;AACrD,eAAO,MAAM,SAAS;IACpB,+DAA+D;;IAE/D,qBAAqB;;IAErB,wCAAwC;;IAExC,sCAAsC;;IAEtC,2CAA2C;;IAE3C,oBAAoB;;CAEZ,CAAC;AAEX;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAErD;AAED,2CAA2C;AAC3C,eAAO,MAAM,uBAAuB,EAAE,gBAMrC,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocket 的最小接口。
|
|
3
|
+
*
|
|
4
|
+
* engine **必须能在无 DOM 的环境构造**(CONVENTIONS §1)——一致性向量与时序测试
|
|
5
|
+
* 都跑在 Node 里。所以这里只声明用得到的那几个成员,测试注入假实现,
|
|
6
|
+
* 浏览器里注入原生 WebSocket。
|
|
7
|
+
*/
|
|
8
|
+
/** WS_OPEN 是 WebSocket.OPEN 的值,避免在无 DOM 环境引用全局常量。 */
|
|
9
|
+
export const WS_OPEN = 1;
|
|
10
|
+
/** CloseCode 是协议约定的 WS 关闭码(RTC_PROTOCOL.md §1.5)。 */
|
|
11
|
+
export const CloseCode = {
|
|
12
|
+
/** 正常关闭。**我们自己 logout 时发的就是它**;别人发过来的不算数,见 shouldReconnect。 */
|
|
13
|
+
normal: 1000,
|
|
14
|
+
/** 服务端下线/重启。立即重连。 */
|
|
15
|
+
goingAway: 1001,
|
|
16
|
+
/** 信封非法/帧超长/协议版本不支持。**不重连**,属实现 bug。 */
|
|
17
|
+
badProtocol: 4400,
|
|
18
|
+
/** 未鉴权/鉴权超时/token 无效。换新 token 后重连。 */
|
|
19
|
+
unauthorized: 4401,
|
|
20
|
+
/** 被踢(同 uid 同 device_id 在别处登录)。**不重连**。 */
|
|
21
|
+
kickedOut: 4403,
|
|
22
|
+
/** 频率超限。退避加倍后重连。 */
|
|
23
|
+
rateLimited: 4429,
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* shouldReconnect 按关闭码判断要不要重连。
|
|
27
|
+
*
|
|
28
|
+
* 4400 与 4403 **绝不重连**:前者是我们自己的实现 bug,重连只会再撞一次;
|
|
29
|
+
* 后者是被踢,重连等于跟另一台设备打架。
|
|
30
|
+
*
|
|
31
|
+
* **1000 不在这张表里**——「是不是我们自己要走的」看的是意图,不是关闭码。
|
|
32
|
+
* `close()` 会先把 state 置成 `closed`,`connection.ts` 那句
|
|
33
|
+
* `this.state !== 'closed'` 已经把 logout 挡住了;再按码挡一次,挡掉的只剩
|
|
34
|
+
* **别人发过来的 1000**:浏览器掐掉后台标签页的连接、代理/系统休眠断链,
|
|
35
|
+
* 用的都是这个码。服务端从不主动发 1000(重启走 1001 goingAway),
|
|
36
|
+
* 所以这里收到的 1000 一定不是「对面让我别回来了」。
|
|
37
|
+
* 症状是一个后台标签页显示「连接已断开(1000),不会自动回」,必须手动刷新。
|
|
38
|
+
*/
|
|
39
|
+
export function shouldReconnect(code) {
|
|
40
|
+
return code !== CloseCode.badProtocol && code !== CloseCode.kickedOut;
|
|
41
|
+
}
|
|
42
|
+
/** browserWebSocketFactory 是浏览器环境的默认工厂。 */
|
|
43
|
+
export const browserWebSocketFactory = (url) => {
|
|
44
|
+
const Ctor = globalThis.WebSocket;
|
|
45
|
+
if (Ctor === undefined) {
|
|
46
|
+
throw new Error('当前环境没有 WebSocket;请通过 webSocketFactory 注入实现');
|
|
47
|
+
}
|
|
48
|
+
return new Ctor(url);
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=webSocket.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"webSocket.js","sourceRoot":"","sources":["../../src/signaling/webSocket.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAgBH,qDAAqD;AACrD,MAAM,CAAC,MAAM,OAAO,GAAG,CAAC,CAAC;AAEzB,qDAAqD;AACrD,MAAM,CAAC,MAAM,SAAS,GAAG;IACvB,+DAA+D;IAC/D,MAAM,EAAE,IAAI;IACZ,qBAAqB;IACrB,SAAS,EAAE,IAAI;IACf,wCAAwC;IACxC,WAAW,EAAE,IAAI;IACjB,sCAAsC;IACtC,YAAY,EAAE,IAAI;IAClB,2CAA2C;IAC3C,SAAS,EAAE,IAAI;IACf,oBAAoB;IACpB,WAAW,EAAE,IAAI;CACT,CAAC;AAEX;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,eAAe,CAAC,IAAY;IAC1C,OAAO,IAAI,KAAK,SAAS,CAAC,WAAW,IAAI,IAAI,KAAK,SAAS,CAAC,SAAS,CAAC;AACxE,CAAC;AAED,2CAA2C;AAC3C,MAAM,CAAC,MAAM,uBAAuB,GAAqB,CAAC,GAAG,EAAE,EAAE;IAC/D,MAAM,IAAI,GAAI,UAAiE,CAAC,SAAS,CAAC;IAC1F,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,MAAM,IAAI,KAAK,CAAC,4CAA4C,CAAC,CAAC;IAChE,CAAC;IACD,OAAO,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC;AACvB,CAAC,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { MediaType } from '../signaling/enums.js';
|
|
2
|
+
import type { EmittedEvent, MachineInput, MachineOutput, OutgoingFrame } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* 通话状态机:RTC_PROTOCOL.md §5.1 的 TS 实现。
|
|
5
|
+
*
|
|
6
|
+
* 一致性向量:`im-rtc-server/docs/conformance/call_fsm.json`,四端跑同一份。
|
|
7
|
+
*
|
|
8
|
+
* # 三条容易写错的地方
|
|
9
|
+
*
|
|
10
|
+
* 1. **没有 `ended` 状态**——`ended` 是事件不是状态。草图 §09 里那个「停 1.5s」的
|
|
11
|
+
* 方框是 uikit 的展示状态,由 uikit 自己持有(不变量 I5)。
|
|
12
|
+
* 2. **便利事件只在 1v1 抛**(`onCallCancelled/Rejected/Busy/NoAnswer`)。群通话里
|
|
13
|
+
* 某人拒接只抛 `onUserReject`——否则会违反「便利事件后必定跟 onCallEnd」(I7)。
|
|
14
|
+
* 3. **状态只由信令帧与宿主调用驱动,禁止由定时器改状态**(I4)。
|
|
15
|
+
* 本地振铃倒计时只改 UI,超时由服务端裁决。
|
|
16
|
+
*/
|
|
17
|
+
/** CallState 是通话状态。**没有 ended**,见文件头。 */
|
|
18
|
+
export type CallState = 'idle' | 'inviting' | 'ringing' | 'accepting' | 'connecting' | 'connected';
|
|
19
|
+
/** CallRole 是本端在这通电话里的角色。 */
|
|
20
|
+
export type CallRole = 'caller' | 'callee' | '';
|
|
21
|
+
/** CallContext 是状态机持有的全部数据。 */
|
|
22
|
+
export interface CallContext {
|
|
23
|
+
readonly state: CallState;
|
|
24
|
+
readonly callId: string;
|
|
25
|
+
readonly roomId: string;
|
|
26
|
+
readonly roomToken: string;
|
|
27
|
+
readonly mediaType: MediaType;
|
|
28
|
+
readonly isGroup: boolean;
|
|
29
|
+
readonly role: CallRole;
|
|
30
|
+
/** 通话时长的起点,来自服务端。**客户端不自己算时长**(I8)。 */
|
|
31
|
+
readonly connectedAtMs: number;
|
|
32
|
+
/**
|
|
33
|
+
* 拨出中**还没拿到 call_id** 就按了取消:先记下,`call.invite.ok` 一到就补发 `call.cancel`(见 callRecv.ts)。
|
|
34
|
+
*
|
|
35
|
+
* 不这样的话那一帧带着空 call_id 上线路,服务端回 1401,宿主平白多收一条 error
|
|
36
|
+
* (2026-09-15 10:09 demo-react 真机)。与 iOS `IMCallContext.cancelPending` 同形。
|
|
37
|
+
*/
|
|
38
|
+
readonly cancelPending: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* 本通电话记下的群号 / user_data——主叫来自 `call()` 的选项,被叫来自 `call.incoming`。
|
|
41
|
+
*
|
|
42
|
+
* **只当 `call.connected` 的兜底用**(HOST_INTEGRATION_DESIGN §3.3):`onCallBegin` 优先取
|
|
43
|
+
* `call.connected` 里的值,为空才回落到这里,兼容尚未升级的旧服务端。
|
|
44
|
+
*/
|
|
45
|
+
readonly chatGroupId: string;
|
|
46
|
+
readonly userData: string;
|
|
47
|
+
}
|
|
48
|
+
/** initialCallContext 是 idle 态的初值。 */
|
|
49
|
+
export declare const initialCallContext: CallContext;
|
|
50
|
+
/** out 构造一次状态转移的产物。callRecv.ts 也用它。 */
|
|
51
|
+
export declare function out(state: CallContext, send?: OutgoingFrame[], emit?: EmittedEvent[]): MachineOutput<CallContext>;
|
|
52
|
+
/** reduceCall 是通话状态机的唯一入口。 */
|
|
53
|
+
export declare function reduceCall(ctx: CallContext, input: MachineInput): MachineOutput<CallContext>;
|
|
54
|
+
/** synthesizeNetworkEnd 是不变量 I8 的那个**唯一例外**。 */
|
|
55
|
+
export declare function synthesizeNetworkEnd(ctx: CallContext, nowMs: number): MachineOutput<CallContext>;
|
|
56
|
+
//# sourceMappingURL=callMachine.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"callMachine.d.ts","sourceRoot":"","sources":["../../src/state/callMachine.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAEvD,OAAO,KAAK,EAAE,YAAY,EAAE,YAAY,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAI3F;;;;;;;;;;;;;GAaG;AAEH,yCAAyC;AACzC,MAAM,MAAM,SAAS,GAAG,MAAM,GAAG,UAAU,GAAG,SAAS,GAAG,WAAW,GAAG,YAAY,GAAG,WAAW,CAAC;AAEnG,6BAA6B;AAC7B,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,QAAQ,GAAG,EAAE,CAAC;AAEhD,+BAA+B;AAC/B,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,SAAS,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,SAAS,EAAE,SAAS,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC;IACxB,uCAAuC;IACvC,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B;;;;;OAKG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,sCAAsC;AACtC,eAAO,MAAM,kBAAkB,EAAE,WAYhC,CAAC;AAGF,uCAAuC;AACvC,wBAAgB,GAAG,CACjB,KAAK,EAAE,WAAW,EAClB,IAAI,GAAE,aAAa,EAAO,EAC1B,IAAI,GAAE,YAAY,EAAO,GACxB,aAAa,CAAC,WAAW,CAAC,CAE5B;AAED,8BAA8B;AAC9B,wBAAgB,UAAU,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,YAAY,GAAG,aAAa,CAAC,WAAW,CAAC,CAS5F;AA+ID,gDAAgD;AAChD,wBAAgB,oBAAoB,CAAC,GAAG,EAAE,WAAW,EAAE,KAAK,EAAE,MAAM,GAAG,aAAa,CAAC,WAAW,CAAC,CAehG"}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { ErrorCode, errorName } from '../errors.js';
|
|
2
|
+
import { CallEndReason } from '../reasons.js';
|
|
3
|
+
import { FrameType } from '../signaling/registry.js';
|
|
4
|
+
import { reduceRecv } from './callRecv.js';
|
|
5
|
+
import { bool, num, str, strArray } from './types.js';
|
|
6
|
+
/** initialCallContext 是 idle 态的初值。 */
|
|
7
|
+
export const initialCallContext = {
|
|
8
|
+
state: 'idle',
|
|
9
|
+
callId: '',
|
|
10
|
+
roomId: '',
|
|
11
|
+
roomToken: '',
|
|
12
|
+
mediaType: 'audio',
|
|
13
|
+
isGroup: false,
|
|
14
|
+
role: '',
|
|
15
|
+
connectedAtMs: 0,
|
|
16
|
+
cancelPending: false,
|
|
17
|
+
chatGroupId: '',
|
|
18
|
+
userData: '',
|
|
19
|
+
};
|
|
20
|
+
/** out 构造一次状态转移的产物。callRecv.ts 也用它。 */
|
|
21
|
+
export function out(state, send = [], emit = []) {
|
|
22
|
+
return { state, send, emit };
|
|
23
|
+
}
|
|
24
|
+
/** reduceCall 是通话状态机的唯一入口。 */
|
|
25
|
+
export function reduceCall(ctx, input) {
|
|
26
|
+
switch (input.kind) {
|
|
27
|
+
case 'act':
|
|
28
|
+
return reduceAct(ctx, input.op, input.args ?? {});
|
|
29
|
+
case 'recv':
|
|
30
|
+
return reduceRecv(ctx, input.type, input.data);
|
|
31
|
+
case 'internal':
|
|
32
|
+
return reduceInternal(ctx, input.name);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function reduceInternal(ctx, name) {
|
|
36
|
+
// 媒体就绪:room.join.ok 到手 + sub PC 的 ICE 连通(§5.1)。
|
|
37
|
+
if (name === 'media_ready' && ctx.state === 'connecting') {
|
|
38
|
+
return out({ ...ctx, state: 'connected' });
|
|
39
|
+
}
|
|
40
|
+
/*
|
|
41
|
+
**`call.invite` 被服务端拒了要回 idle**,与 `join_failed` 同一个道理。
|
|
42
|
+
|
|
43
|
+
不退的话通话机永远停在 `inviting`:界面上是「正在呼叫…」转个不停,
|
|
44
|
+
而那通电话服务端根本没建;随后每一次挂断都发向一个不存在的 call,
|
|
45
|
+
换回 `1401 call_not_found`,**永远退不出去**。
|
|
46
|
+
(实测:群呼把主叫自己也放进了 callee_ids,服务端回 1004,
|
|
47
|
+
然后连点五次挂断全是 1401。)
|
|
48
|
+
|
|
49
|
+
抛 `onCallEnd` 而不是只清状态:它是所有结束分支的唯一出口(设计 §7.5),
|
|
50
|
+
界面只认这一个信号来收场子。reason 用 `error`——这通电话从未建立,
|
|
51
|
+
hangup/cancel/reject 哪个都不是实情。
|
|
52
|
+
*/
|
|
53
|
+
if (name === 'call_failed' && ctx.state !== 'idle') {
|
|
54
|
+
return out(initialCallContext, [], [{
|
|
55
|
+
cb: 'onCallEnd',
|
|
56
|
+
args: {
|
|
57
|
+
call_id: ctx.callId,
|
|
58
|
+
reason: CallEndReason.error,
|
|
59
|
+
duration_sec: 0,
|
|
60
|
+
ended_by: '',
|
|
61
|
+
},
|
|
62
|
+
}]);
|
|
63
|
+
}
|
|
64
|
+
return out(ctx);
|
|
65
|
+
}
|
|
66
|
+
function reduceAct(ctx, op, args) {
|
|
67
|
+
switch (op) {
|
|
68
|
+
case 'call':
|
|
69
|
+
return startCall(ctx, args);
|
|
70
|
+
case 'accept':
|
|
71
|
+
return acceptCall(ctx);
|
|
72
|
+
case 'reject':
|
|
73
|
+
// reject 只发帧,状态由随后的 call.ended 推进——服务端才是裁决方。
|
|
74
|
+
return ctx.state === 'ringing'
|
|
75
|
+
? out(ctx, [{ type: FrameType.callReject, data: { call_id: ctx.callId } }])
|
|
76
|
+
: invalidState(ctx);
|
|
77
|
+
case 'cancel':
|
|
78
|
+
if (ctx.state !== 'inviting')
|
|
79
|
+
return invalidState(ctx);
|
|
80
|
+
// 还没拿到 call_id(invite.ok 在路上):这一帧发出去只会换回 1401。先挂起,invite.ok 一到就补发(callRecv.ts)。
|
|
81
|
+
if (ctx.callId === '')
|
|
82
|
+
return out({ ...ctx, cancelPending: true });
|
|
83
|
+
return out(ctx, [{ type: FrameType.callCancel, data: { call_id: ctx.callId } }]);
|
|
84
|
+
case 'hangup':
|
|
85
|
+
return ctx.state === 'connected' || ctx.state === 'connecting'
|
|
86
|
+
? out(ctx, [{ type: FrameType.callHangup, data: { call_id: ctx.callId } }])
|
|
87
|
+
: invalidState(ctx);
|
|
88
|
+
case 'invite_more':
|
|
89
|
+
return inviteMore(ctx, args);
|
|
90
|
+
case 'join_call':
|
|
91
|
+
return joinOngoingCall(ctx, args);
|
|
92
|
+
default:
|
|
93
|
+
return invalidState(ctx);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function startCall(ctx, args) {
|
|
97
|
+
if (ctx.state !== 'idle')
|
|
98
|
+
return invalidState(ctx);
|
|
99
|
+
const calleeIds = strArray(args, 'callee_ids');
|
|
100
|
+
const mediaType = str(args, 'media_type') === 'video' ? 'video' : 'audio';
|
|
101
|
+
const isGroup = bool(args, 'is_group');
|
|
102
|
+
const chatGroupId = str(args, 'chat_group_id');
|
|
103
|
+
const userData = str(args, 'user_data');
|
|
104
|
+
/*
|
|
105
|
+
**省略 = 协议默认值**(newFrameData 的规矩,见 registry.ts):只在宿主真的给了值时才
|
|
106
|
+
把键放进去,交给 frameSender → encodeFields 那一层去填协议默认值。显式写空串/0 会把
|
|
107
|
+
「没传」和「传了空/0」混为一谈——`timeout_sec` 尤其致命:写 0 会被 §2.6 的钳制夹到
|
|
108
|
+
下限 5s,而不是协议默认的 30s。
|
|
109
|
+
*/
|
|
110
|
+
const data = { callee_ids: calleeIds, media_type: mediaType, is_group: isGroup };
|
|
111
|
+
if (chatGroupId !== '')
|
|
112
|
+
data['chat_group_id'] = chatGroupId;
|
|
113
|
+
if (userData !== '')
|
|
114
|
+
data['user_data'] = userData;
|
|
115
|
+
if (Object.hasOwn(args, 'timeout_sec'))
|
|
116
|
+
data['timeout_sec'] = num(args, 'timeout_sec');
|
|
117
|
+
return out({ ...ctx, state: 'inviting', role: 'caller', mediaType, isGroup, chatGroupId, userData }, [{ type: FrameType.callInvite, data }]);
|
|
118
|
+
}
|
|
119
|
+
function acceptCall(ctx) {
|
|
120
|
+
// 第二次 accept 必须**本地**拦下,不能发上去让服务端回 1405(不变量 R1 的同款理由)。
|
|
121
|
+
if (ctx.state !== 'ringing')
|
|
122
|
+
return invalidState(ctx);
|
|
123
|
+
return out({ ...ctx, state: 'accepting' }, [
|
|
124
|
+
{ type: FrameType.callAccept, data: { call_id: ctx.callId } },
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
function inviteMore(ctx, args) {
|
|
128
|
+
if (ctx.state !== 'connected' && ctx.state !== 'connecting')
|
|
129
|
+
return invalidState(ctx);
|
|
130
|
+
return out(ctx, [
|
|
131
|
+
{
|
|
132
|
+
type: FrameType.callInviteMore,
|
|
133
|
+
data: { call_id: ctx.callId, callee_ids: strArray(args, 'callee_ids') },
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* joinOngoingCall 是「群成员看到『进行中』主动加入」(§4.1)。
|
|
139
|
+
*
|
|
140
|
+
* **「怎么知道有通话在进行中」不在本协议里**——那是宿主拿 webhook `call.started`
|
|
141
|
+
* 自己发广播的事。engine 只负责把 call_id 送上去。
|
|
142
|
+
*/
|
|
143
|
+
function joinOngoingCall(ctx, args) {
|
|
144
|
+
if (ctx.state !== 'idle')
|
|
145
|
+
return invalidState(ctx);
|
|
146
|
+
const callId = str(args, 'call_id');
|
|
147
|
+
return out({ ...ctx, state: 'accepting', role: 'callee', callId, isGroup: true }, [
|
|
148
|
+
{ type: FrameType.callJoin, data: { call_id: callId } },
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
function invalidState(ctx) {
|
|
152
|
+
return out(ctx, [], [
|
|
153
|
+
{
|
|
154
|
+
cb: 'onError',
|
|
155
|
+
args: { code: ErrorCode.invalidState, name: errorName(ErrorCode.invalidState) },
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
}
|
|
159
|
+
/** synthesizeNetworkEnd 是不变量 I8 的那个**唯一例外**。 */
|
|
160
|
+
export function synthesizeNetworkEnd(ctx, nowMs) {
|
|
161
|
+
if (ctx.state === 'idle')
|
|
162
|
+
return out(ctx);
|
|
163
|
+
const durationSec = ctx.connectedAtMs > 0 ? Math.max(0, Math.floor((nowMs - ctx.connectedAtMs) / 1000)) : 0;
|
|
164
|
+
return out({ ...initialCallContext }, [], [
|
|
165
|
+
{
|
|
166
|
+
cb: 'onCallEnd',
|
|
167
|
+
args: {
|
|
168
|
+
call_id: ctx.callId,
|
|
169
|
+
reason: CallEndReason.network,
|
|
170
|
+
duration_sec: durationSec,
|
|
171
|
+
ended_by: '',
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=callMachine.js.map
|