@agatx/serenada-core 0.6.10
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/dist/ConsoleLogger.d.ts +6 -0
- package/dist/ConsoleLogger.d.ts.map +1 -0
- package/dist/ConsoleLogger.js +21 -0
- package/dist/ConsoleLogger.js.map +1 -0
- package/dist/RoomWatcher.d.ts +34 -0
- package/dist/RoomWatcher.d.ts.map +1 -0
- package/dist/RoomWatcher.js +103 -0
- package/dist/RoomWatcher.js.map +1 -0
- package/dist/SerenadaCore.d.ts +47 -0
- package/dist/SerenadaCore.d.ts.map +1 -0
- package/dist/SerenadaCore.js +141 -0
- package/dist/SerenadaCore.js.map +1 -0
- package/dist/SerenadaDiagnostics.d.ts +49 -0
- package/dist/SerenadaDiagnostics.d.ts.map +1 -0
- package/dist/SerenadaDiagnostics.js +421 -0
- package/dist/SerenadaDiagnostics.js.map +1 -0
- package/dist/SerenadaServerProvider.d.ts +48 -0
- package/dist/SerenadaServerProvider.d.ts.map +1 -0
- package/dist/SerenadaServerProvider.js +296 -0
- package/dist/SerenadaServerProvider.js.map +1 -0
- package/dist/SerenadaSession.d.ts +180 -0
- package/dist/SerenadaSession.d.ts.map +1 -0
- package/dist/SerenadaSession.js +1082 -0
- package/dist/SerenadaSession.js.map +1 -0
- package/dist/SignalingProvider.d.ts +132 -0
- package/dist/SignalingProvider.d.ts.map +1 -0
- package/dist/SignalingProvider.js +50 -0
- package/dist/SignalingProvider.js.map +1 -0
- package/dist/api/roomApi.d.ts +2 -0
- package/dist/api/roomApi.d.ts.map +1 -0
- package/dist/api/roomApi.js +14 -0
- package/dist/api/roomApi.js.map +1 -0
- package/dist/cameraModes.d.ts +13 -0
- package/dist/cameraModes.d.ts.map +1 -0
- package/dist/cameraModes.js +35 -0
- package/dist/cameraModes.js.map +1 -0
- package/dist/configValidation.d.ts +10 -0
- package/dist/configValidation.d.ts.map +1 -0
- package/dist/configValidation.js +24 -0
- package/dist/configValidation.js.map +1 -0
- package/dist/constants.d.ts +33 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +65 -0
- package/dist/constants.js.map +1 -0
- package/dist/formatError.d.ts +3 -0
- package/dist/formatError.d.ts.map +1 -0
- package/dist/formatError.js +7 -0
- package/dist/formatError.js.map +1 -0
- package/dist/iceServers.d.ts +2 -0
- package/dist/iceServers.d.ts.map +1 -0
- package/dist/iceServers.js +21 -0
- package/dist/iceServers.js.map +1 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +44 -0
- package/dist/index.js.map +1 -0
- package/dist/layout/computeLayout.d.ts +81 -0
- package/dist/layout/computeLayout.d.ts.map +1 -0
- package/dist/layout/computeLayout.js +380 -0
- package/dist/layout/computeLayout.js.map +1 -0
- package/dist/media/AudioLevelMonitor.d.ts +51 -0
- package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
- package/dist/media/AudioLevelMonitor.js +179 -0
- package/dist/media/AudioLevelMonitor.js.map +1 -0
- package/dist/media/MediaEngine.d.ts +137 -0
- package/dist/media/MediaEngine.d.ts.map +1 -0
- package/dist/media/MediaEngine.js +1224 -0
- package/dist/media/MediaEngine.js.map +1 -0
- package/dist/media/callStats.d.ts +16 -0
- package/dist/media/callStats.d.ts.map +1 -0
- package/dist/media/callStats.js +214 -0
- package/dist/media/callStats.js.map +1 -0
- package/dist/media/localVideoRecovery.d.ts +16 -0
- package/dist/media/localVideoRecovery.d.ts.map +1 -0
- package/dist/media/localVideoRecovery.js +14 -0
- package/dist/media/localVideoRecovery.js.map +1 -0
- package/dist/recoveryStorage.d.ts +33 -0
- package/dist/recoveryStorage.d.ts.map +1 -0
- package/dist/recoveryStorage.js +88 -0
- package/dist/recoveryStorage.js.map +1 -0
- package/dist/serverUrls.d.ts +8 -0
- package/dist/serverUrls.d.ts.map +1 -0
- package/dist/serverUrls.js +65 -0
- package/dist/serverUrls.js.map +1 -0
- package/dist/signaling/SignalingEngine.d.ts +126 -0
- package/dist/signaling/SignalingEngine.d.ts.map +1 -0
- package/dist/signaling/SignalingEngine.js +720 -0
- package/dist/signaling/SignalingEngine.js.map +1 -0
- package/dist/signaling/payloads.d.ts +76 -0
- package/dist/signaling/payloads.d.ts.map +1 -0
- package/dist/signaling/payloads.js +160 -0
- package/dist/signaling/payloads.js.map +1 -0
- package/dist/signaling/roomStatuses.d.ts +9 -0
- package/dist/signaling/roomStatuses.d.ts.map +1 -0
- package/dist/signaling/roomStatuses.js +71 -0
- package/dist/signaling/roomStatuses.js.map +1 -0
- package/dist/signaling/transportConfig.d.ts +3 -0
- package/dist/signaling/transportConfig.d.ts.map +1 -0
- package/dist/signaling/transportConfig.js +27 -0
- package/dist/signaling/transportConfig.js.map +1 -0
- package/dist/signaling/transports/index.d.ts +13 -0
- package/dist/signaling/transports/index.d.ts.map +1 -0
- package/dist/signaling/transports/index.js +11 -0
- package/dist/signaling/transports/index.js.map +1 -0
- package/dist/signaling/transports/sse.d.ts +26 -0
- package/dist/signaling/transports/sse.d.ts.map +1 -0
- package/dist/signaling/transports/sse.js +131 -0
- package/dist/signaling/transports/sse.js.map +1 -0
- package/dist/signaling/transports/types.d.ts +17 -0
- package/dist/signaling/transports/types.d.ts.map +1 -0
- package/dist/signaling/transports/types.js +2 -0
- package/dist/signaling/transports/types.js.map +1 -0
- package/dist/signaling/transports/ws.d.ts +21 -0
- package/dist/signaling/transports/ws.d.ts.map +1 -0
- package/dist/signaling/transports/ws.js +93 -0
- package/dist/signaling/transports/ws.js.map +1 -0
- package/dist/signaling/types.d.ts +53 -0
- package/dist/signaling/types.d.ts.map +1 -0
- package/dist/signaling/types.js +2 -0
- package/dist/signaling/types.js.map +1 -0
- package/dist/types.d.ts +279 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +43 -0
- package/src/ConsoleLogger.ts +14 -0
- package/src/RoomWatcher.ts +127 -0
- package/src/SerenadaCore.ts +163 -0
- package/src/SerenadaDiagnostics.ts +485 -0
- package/src/SerenadaServerProvider.ts +362 -0
- package/src/SerenadaSession.ts +1258 -0
- package/src/SignalingProvider.ts +207 -0
- package/src/api/roomApi.ts +16 -0
- package/src/cameraModes.ts +34 -0
- package/src/configValidation.ts +35 -0
- package/src/constants.ts +77 -0
- package/src/formatError.ts +5 -0
- package/src/iceServers.ts +20 -0
- package/src/index.ts +155 -0
- package/src/layout/computeLayout.ts +639 -0
- package/src/media/AudioLevelMonitor.ts +190 -0
- package/src/media/MediaEngine.ts +1183 -0
- package/src/media/callStats.ts +260 -0
- package/src/media/localVideoRecovery.ts +39 -0
- package/src/recoveryStorage.ts +101 -0
- package/src/serverUrls.ts +69 -0
- package/src/signaling/SignalingEngine.ts +762 -0
- package/src/signaling/payloads.ts +215 -0
- package/src/signaling/roomStatuses.ts +89 -0
- package/src/signaling/transportConfig.ts +30 -0
- package/src/signaling/transports/index.ts +26 -0
- package/src/signaling/transports/sse.ts +146 -0
- package/src/signaling/transports/types.ts +19 -0
- package/src/signaling/transports/ws.ts +108 -0
- package/src/signaling/types.ts +68 -0
- package/src/types.ts +299 -0
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
import type { ReconnectOutcome, RoomState, SignalingMessage } from './types.js';
|
|
2
|
+
import type { RoomStatuses } from './roomStatuses.js';
|
|
3
|
+
import type { SignalingTransport, TransportKind } from './transports/types.js';
|
|
4
|
+
import type { SerenadaLogger } from '../types.js';
|
|
5
|
+
import { createSignalingTransport } from './transports/index.js';
|
|
6
|
+
import { mergeRoomStatusesPayload, mergeRoomStatusUpdatePayload } from './roomStatuses.js';
|
|
7
|
+
import {
|
|
8
|
+
parseJoinedPayload,
|
|
9
|
+
parseRoomStatePayload,
|
|
10
|
+
parseErrorPayload,
|
|
11
|
+
parseTurnRefreshedPayload,
|
|
12
|
+
parseRelayFailedPayload,
|
|
13
|
+
parseNegotiationDirtyPayload,
|
|
14
|
+
} from './payloads.js';
|
|
15
|
+
import { formatError } from '../formatError.js';
|
|
16
|
+
import { saveRecoveryRecord, clearRecoveryRecord } from '../recoveryStorage.js';
|
|
17
|
+
import {
|
|
18
|
+
RECONNECT_BACKOFF_BASE_MS,
|
|
19
|
+
RECONNECT_BACKOFF_CAP_MS,
|
|
20
|
+
PING_INTERVAL_MS,
|
|
21
|
+
PONG_MISS_THRESHOLD,
|
|
22
|
+
WS_FALLBACK_CONSECUTIVE_FAILURES,
|
|
23
|
+
JOIN_CONNECT_KICKSTART_MS,
|
|
24
|
+
JOIN_RECOVERY_MS,
|
|
25
|
+
JOIN_HARD_TIMEOUT_MS,
|
|
26
|
+
TURN_REFRESH_TRIGGER_RATIO,
|
|
27
|
+
SUSPEND_HARD_EVICTION_TIMEOUT_MS,
|
|
28
|
+
} from '../constants.js';
|
|
29
|
+
|
|
30
|
+
export interface SignalingEngineConfig {
|
|
31
|
+
wsUrl: string;
|
|
32
|
+
httpBaseUrl: string;
|
|
33
|
+
transports?: TransportKind[];
|
|
34
|
+
logger?: SerenadaLogger;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type SignalingStateListener = () => void;
|
|
38
|
+
export type SignalingMessageListener = (msg: SignalingMessage) => void;
|
|
39
|
+
|
|
40
|
+
export class SignalingEngine {
|
|
41
|
+
// Public state
|
|
42
|
+
isConnected = false;
|
|
43
|
+
activeTransport: TransportKind | null = null;
|
|
44
|
+
clientId: string | null = null;
|
|
45
|
+
roomState: RoomState | null = null;
|
|
46
|
+
turnToken: string | null = null;
|
|
47
|
+
turnTokenTTLMs: number | null = null;
|
|
48
|
+
error: { code: string; message: string; reason?: string } | null = null;
|
|
49
|
+
roomStatuses: RoomStatuses = {};
|
|
50
|
+
/** Most-recent reconnect outcome reported by the server in `joined`. */
|
|
51
|
+
lastReconnectOutcome: ReconnectOutcome | null = null;
|
|
52
|
+
/**
|
|
53
|
+
* Highest room-state epoch observed so far. Advances monotonically (per
|
|
54
|
+
* room) on every membership change. Used by MediaEngine to gate ICE
|
|
55
|
+
* restart on an authoritative post-reconnect snapshot.
|
|
56
|
+
*/
|
|
57
|
+
lastEpoch: number | null = null;
|
|
58
|
+
/** Epoch at the moment the transport was last observed disconnected. */
|
|
59
|
+
epochAtDisconnect: number | null = null;
|
|
60
|
+
/**
|
|
61
|
+
* Unix-ms timestamp of the first successful `joined` for the current
|
|
62
|
+
* session. Stable across reconnects so the persisted recovery record
|
|
63
|
+
* carries the original join time, not the latest reattach time.
|
|
64
|
+
*/
|
|
65
|
+
private sessionStartTs: number | null = null;
|
|
66
|
+
/**
|
|
67
|
+
* True from disconnect until we've seen a fresh authoritative snapshot
|
|
68
|
+
* for the current room on the new transport. Consumers should suppress
|
|
69
|
+
* ICE restart while this flag is set.
|
|
70
|
+
*/
|
|
71
|
+
awaitingPostReconnectSnapshot = false;
|
|
72
|
+
|
|
73
|
+
// Config
|
|
74
|
+
private wsUrl: string;
|
|
75
|
+
private httpBaseUrl: string;
|
|
76
|
+
private transportOrder: TransportKind[];
|
|
77
|
+
|
|
78
|
+
// Internal state
|
|
79
|
+
private transport: SignalingTransport | null = null;
|
|
80
|
+
private transportIndex = 0;
|
|
81
|
+
private transportConnectedOnce: Record<TransportKind, boolean> = { ws: false, sse: false };
|
|
82
|
+
private transportId = 0;
|
|
83
|
+
private currentRoomId: string | null = null;
|
|
84
|
+
private pendingJoin: string | null = null;
|
|
85
|
+
private lastClientId: string | null = null;
|
|
86
|
+
private needsRejoin = false;
|
|
87
|
+
private reconnectToken: string | null = null;
|
|
88
|
+
private reconnectTokenRoomId: string | null = null;
|
|
89
|
+
private lastPongAt = Date.now();
|
|
90
|
+
private missedPongs = 0;
|
|
91
|
+
private wsConsecutiveFailures = 0;
|
|
92
|
+
private sseSid: string | null = null;
|
|
93
|
+
private joinAttemptId = 0;
|
|
94
|
+
private joinAcked = false;
|
|
95
|
+
private joinKickstartTimer: number | null = null;
|
|
96
|
+
private joinRecoveryTimer: number | null = null;
|
|
97
|
+
private joinHardTimeout: number | null = null;
|
|
98
|
+
private turnRefreshTimer: number | null = null;
|
|
99
|
+
private pingInterval: number | null = null;
|
|
100
|
+
private reconnectTimeout: number | null = null;
|
|
101
|
+
private reconnectAttempts = 0;
|
|
102
|
+
private closedByDestroy = false;
|
|
103
|
+
private connecting = false;
|
|
104
|
+
private lastCreateMaxParticipants: number | undefined = undefined;
|
|
105
|
+
private lastDisplayName: string | undefined = undefined;
|
|
106
|
+
private lastPeerId: string | undefined = undefined;
|
|
107
|
+
|
|
108
|
+
// Logger
|
|
109
|
+
private logger?: SerenadaLogger;
|
|
110
|
+
|
|
111
|
+
// Listeners
|
|
112
|
+
private messageListeners: SignalingMessageListener[] = [];
|
|
113
|
+
private stateListeners: SignalingStateListener[] = [];
|
|
114
|
+
|
|
115
|
+
constructor(config: SignalingEngineConfig) {
|
|
116
|
+
this.wsUrl = config.wsUrl;
|
|
117
|
+
this.httpBaseUrl = config.httpBaseUrl;
|
|
118
|
+
this.transportOrder = config.transports ?? ['ws', 'sse'];
|
|
119
|
+
this.logger = config.logger;
|
|
120
|
+
this.loadReconnectStorage();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
connect(): void {
|
|
124
|
+
this.closedByDestroy = false;
|
|
125
|
+
this.transportIndex = 0;
|
|
126
|
+
this.transportConnectedOnce = { ws: false, sse: false };
|
|
127
|
+
this.doConnect(0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
destroy(): void {
|
|
131
|
+
this.closedByDestroy = true;
|
|
132
|
+
this.clearReconnectTimeout();
|
|
133
|
+
this.clearJoinTimers();
|
|
134
|
+
this.clearPingInterval();
|
|
135
|
+
this.clearTurnRefreshTimer();
|
|
136
|
+
this.awaitingPostReconnectSnapshot = false;
|
|
137
|
+
this.epochAtDisconnect = null;
|
|
138
|
+
if (this.transport) {
|
|
139
|
+
this.transport.close();
|
|
140
|
+
this.transport = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
sendMessage(type: string, payload?: Record<string, unknown>, to?: string): void {
|
|
145
|
+
if (this.transport && this.transport.isOpen()) {
|
|
146
|
+
const msg: SignalingMessage = {
|
|
147
|
+
v: 1,
|
|
148
|
+
type,
|
|
149
|
+
rid: this.currentRoomId || undefined,
|
|
150
|
+
cid: this.clientId || undefined,
|
|
151
|
+
to,
|
|
152
|
+
payload
|
|
153
|
+
};
|
|
154
|
+
this.transport.send(msg);
|
|
155
|
+
} else {
|
|
156
|
+
this.logger?.log('warning', 'Signaling', 'Transport not connected');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
joinRoom(roomId: string, options?: { createMaxParticipants?: number; displayName?: string; peerId?: string }): void {
|
|
161
|
+
this.logger?.log('debug', 'Signaling', `joinRoom call for ${roomId}`);
|
|
162
|
+
this.error = null;
|
|
163
|
+
this.clearJoinTimers();
|
|
164
|
+
this.needsRejoin = false;
|
|
165
|
+
this.currentRoomId = roomId;
|
|
166
|
+
this.joinAttemptId += 1;
|
|
167
|
+
const attemptId = this.joinAttemptId;
|
|
168
|
+
this.joinAcked = false;
|
|
169
|
+
|
|
170
|
+
if (options?.createMaxParticipants !== undefined) {
|
|
171
|
+
this.lastCreateMaxParticipants = options.createMaxParticipants;
|
|
172
|
+
}
|
|
173
|
+
if (options?.displayName !== undefined) {
|
|
174
|
+
this.lastDisplayName = options.displayName;
|
|
175
|
+
}
|
|
176
|
+
if (options?.peerId !== undefined) {
|
|
177
|
+
this.lastPeerId = options.peerId;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (this.transport && this.transport.isOpen()) {
|
|
181
|
+
const payload: Record<string, unknown> = {
|
|
182
|
+
capabilities: { trickleIce: true, maxParticipants: 4 },
|
|
183
|
+
createMaxParticipants: options?.createMaxParticipants ?? this.lastCreateMaxParticipants ?? 4,
|
|
184
|
+
};
|
|
185
|
+
const displayName = options?.displayName ?? this.lastDisplayName;
|
|
186
|
+
if (displayName !== undefined) {
|
|
187
|
+
payload.displayName = displayName;
|
|
188
|
+
}
|
|
189
|
+
const peerId = options?.peerId ?? this.lastPeerId;
|
|
190
|
+
if (peerId !== undefined) {
|
|
191
|
+
payload.peerId = peerId;
|
|
192
|
+
}
|
|
193
|
+
const reconnectCid = this.clientId || this.lastClientId;
|
|
194
|
+
if (reconnectCid) {
|
|
195
|
+
payload.reconnectCid = reconnectCid;
|
|
196
|
+
if (this.reconnectToken && this.reconnectTokenRoomId === roomId) {
|
|
197
|
+
payload.reconnectToken = this.reconnectToken;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const doSendJoin = () => {
|
|
202
|
+
if (this.joinAttemptId !== attemptId) return;
|
|
203
|
+
this.sendMessage('join', payload);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
doSendJoin();
|
|
207
|
+
|
|
208
|
+
this.joinKickstartTimer = window.setTimeout(() => {
|
|
209
|
+
this.joinKickstartTimer = null;
|
|
210
|
+
if (this.joinAttemptId !== attemptId || this.joinAcked) return;
|
|
211
|
+
this.logger?.log('debug', 'Signaling', 'Join kickstart: re-sending join');
|
|
212
|
+
doSendJoin();
|
|
213
|
+
}, JOIN_CONNECT_KICKSTART_MS);
|
|
214
|
+
|
|
215
|
+
this.joinRecoveryTimer = window.setTimeout(() => {
|
|
216
|
+
this.joinRecoveryTimer = null;
|
|
217
|
+
if (this.joinAttemptId !== attemptId || this.joinAcked) return;
|
|
218
|
+
this.logger?.log('debug', 'Signaling', 'Join recovery: re-sending join');
|
|
219
|
+
doSendJoin();
|
|
220
|
+
}, JOIN_RECOVERY_MS);
|
|
221
|
+
|
|
222
|
+
this.joinHardTimeout = window.setTimeout(() => {
|
|
223
|
+
this.joinHardTimeout = null;
|
|
224
|
+
if (this.joinAttemptId !== attemptId || this.joinAcked) return;
|
|
225
|
+
this.logger?.log('error', 'Signaling', 'Join hard timeout reached');
|
|
226
|
+
this.clearJoinTimers();
|
|
227
|
+
this.error = { code: 'JOIN_TIMEOUT', message: 'Join timed out' };
|
|
228
|
+
this.notifyStateChange();
|
|
229
|
+
}, JOIN_HARD_TIMEOUT_MS);
|
|
230
|
+
} else {
|
|
231
|
+
this.logger?.log('debug', 'Signaling', 'Transport not ready, buffering join');
|
|
232
|
+
this.pendingJoin = roomId;
|
|
233
|
+
}
|
|
234
|
+
this.notifyStateChange();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
leaveRoom(options?: { preserveReconnectState?: boolean }): void {
|
|
238
|
+
const preserveReconnectState = options?.preserveReconnectState === true;
|
|
239
|
+
this.clearJoinTimers();
|
|
240
|
+
this.sendMessage('leave');
|
|
241
|
+
this.currentRoomId = null;
|
|
242
|
+
this.needsRejoin = false;
|
|
243
|
+
if (preserveReconnectState) {
|
|
244
|
+
this.lastClientId = this.clientId;
|
|
245
|
+
} else {
|
|
246
|
+
this.lastClientId = null;
|
|
247
|
+
this.clearReconnectStorage();
|
|
248
|
+
}
|
|
249
|
+
this.clientId = null;
|
|
250
|
+
this.roomState = null;
|
|
251
|
+
this.turnToken = null;
|
|
252
|
+
this.turnTokenTTLMs = null;
|
|
253
|
+
this.turnTokenExpiresAtMs = null;
|
|
254
|
+
this.lastReconnectOutcome = null;
|
|
255
|
+
this.lastEpoch = null;
|
|
256
|
+
this.awaitingPostReconnectSnapshot = false;
|
|
257
|
+
this.epochAtDisconnect = null;
|
|
258
|
+
this.notifyStateChange();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
endRoom(): void {
|
|
262
|
+
this.clearJoinTimers();
|
|
263
|
+
this.sendMessage('end_room');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
watchRooms(rids: string[]): void {
|
|
267
|
+
this.sendMessage('watch_rooms', { rids });
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
clearError(): void {
|
|
271
|
+
this.error = null;
|
|
272
|
+
this.notifyStateChange();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
subscribeToMessages(cb: SignalingMessageListener): () => void {
|
|
276
|
+
this.messageListeners.push(cb);
|
|
277
|
+
return () => {
|
|
278
|
+
this.messageListeners = this.messageListeners.filter(l => l !== cb);
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
onStateChange(cb: SignalingStateListener): () => void {
|
|
283
|
+
this.stateListeners.push(cb);
|
|
284
|
+
return () => {
|
|
285
|
+
this.stateListeners = this.stateListeners.filter(l => l !== cb);
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
get currentRoom(): string | null {
|
|
290
|
+
return this.currentRoomId;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// --- Private methods ---
|
|
294
|
+
|
|
295
|
+
private handleIncomingMessage(msg: SignalingMessage): void {
|
|
296
|
+
switch (msg.type) {
|
|
297
|
+
case 'joined': {
|
|
298
|
+
if (msg.cid) this.clientId = msg.cid;
|
|
299
|
+
const joined = parseJoinedPayload(msg.payload);
|
|
300
|
+
if (!joined) break;
|
|
301
|
+
this.clearJoinTimers();
|
|
302
|
+
this.joinAcked = true;
|
|
303
|
+
this.lastReconnectOutcome = joined.reconnect ?? null;
|
|
304
|
+
if (joined.epoch !== undefined) {
|
|
305
|
+
this.lastEpoch = joined.epoch;
|
|
306
|
+
}
|
|
307
|
+
this.roomState = {
|
|
308
|
+
hostCid: joined.hostCid,
|
|
309
|
+
participants: joined.participants,
|
|
310
|
+
maxParticipants: joined.maxParticipants,
|
|
311
|
+
epoch: joined.epoch,
|
|
312
|
+
};
|
|
313
|
+
if (joined.turnToken) {
|
|
314
|
+
this.turnToken = joined.turnToken;
|
|
315
|
+
}
|
|
316
|
+
if (joined.turnTokenTTLMs) {
|
|
317
|
+
this.turnTokenTTLMs = joined.turnTokenTTLMs;
|
|
318
|
+
this.scheduleTurnRefresh();
|
|
319
|
+
}
|
|
320
|
+
if (joined.reconnectToken) {
|
|
321
|
+
this.reconnectToken = joined.reconnectToken;
|
|
322
|
+
this.reconnectTokenRoomId = msg.rid || this.currentRoomId;
|
|
323
|
+
this.persistReconnectStorage();
|
|
324
|
+
}
|
|
325
|
+
this.persistClientId();
|
|
326
|
+
if (this.sessionStartTs === null) {
|
|
327
|
+
this.sessionStartTs = Date.now();
|
|
328
|
+
}
|
|
329
|
+
this.persistRecoveryRecord(joined.reconnectTokenTTLMs);
|
|
330
|
+
this.logger?.log(
|
|
331
|
+
'debug',
|
|
332
|
+
'Signaling',
|
|
333
|
+
`joined outcome=${joined.reconnect ?? 'fresh'} epoch=${joined.epoch ?? 'n/a'}`
|
|
334
|
+
);
|
|
335
|
+
// joined alone is not the authoritative post-reconnect
|
|
336
|
+
// snapshot — wait for the dedicated room_state that the
|
|
337
|
+
// server emits immediately after.
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
case 'turn-refreshed': {
|
|
341
|
+
const turnRefreshed = parseTurnRefreshedPayload(msg.payload);
|
|
342
|
+
if (turnRefreshed) {
|
|
343
|
+
this.turnToken = turnRefreshed.turnToken;
|
|
344
|
+
if (turnRefreshed.turnTokenTTLMs) {
|
|
345
|
+
this.turnTokenTTLMs = turnRefreshed.turnTokenTTLMs;
|
|
346
|
+
this.scheduleTurnRefresh();
|
|
347
|
+
}
|
|
348
|
+
this.logger?.log('debug', 'Signaling', 'TURN credentials refreshed');
|
|
349
|
+
}
|
|
350
|
+
break;
|
|
351
|
+
}
|
|
352
|
+
case 'pong':
|
|
353
|
+
this.lastPongAt = Date.now();
|
|
354
|
+
this.missedPongs = 0;
|
|
355
|
+
// Pong is internal bookkeeping — skip notifyStateChange to avoid unnecessary rebuilds
|
|
356
|
+
[...this.messageListeners].forEach(listener => listener(msg));
|
|
357
|
+
return;
|
|
358
|
+
case 'room_state': {
|
|
359
|
+
const roomState = parseRoomStatePayload(msg.payload);
|
|
360
|
+
if (roomState) {
|
|
361
|
+
this.roomState = roomState;
|
|
362
|
+
if (roomState.epoch !== undefined) {
|
|
363
|
+
this.lastEpoch = roomState.epoch;
|
|
364
|
+
}
|
|
365
|
+
// First room_state seen after a transport reconnect is the
|
|
366
|
+
// authoritative sync point. Clear the gate so MediaEngine
|
|
367
|
+
// can proceed with renegotiation against confirmed state.
|
|
368
|
+
if (this.awaitingPostReconnectSnapshot) {
|
|
369
|
+
this.awaitingPostReconnectSnapshot = false;
|
|
370
|
+
this.epochAtDisconnect = null;
|
|
371
|
+
this.logger?.log(
|
|
372
|
+
'debug',
|
|
373
|
+
'Signaling',
|
|
374
|
+
`Post-reconnect snapshot received (epoch=${roomState.epoch ?? 'n/a'})`
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
break;
|
|
379
|
+
}
|
|
380
|
+
case 'room_ended':
|
|
381
|
+
this.resetForTerminal();
|
|
382
|
+
break;
|
|
383
|
+
case 'negotiation_dirty':
|
|
384
|
+
case 'relay_failed': {
|
|
385
|
+
// Validate payload shape and drop anything malformed before
|
|
386
|
+
// it reaches downstream listeners. MediaEngine handles the
|
|
387
|
+
// actual renegotiation/back-off behavior off the listener
|
|
388
|
+
// stream.
|
|
389
|
+
const parsed = msg.type === 'relay_failed'
|
|
390
|
+
? parseRelayFailedPayload(msg.payload)
|
|
391
|
+
: parseNegotiationDirtyPayload(msg.payload);
|
|
392
|
+
if (!parsed) {
|
|
393
|
+
this.logger?.log('warning', 'Signaling', `Ignoring malformed ${msg.type} payload`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
case 'room_statuses':
|
|
399
|
+
if (msg.payload) {
|
|
400
|
+
this.roomStatuses = mergeRoomStatusesPayload(this.roomStatuses, msg.payload);
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
403
|
+
case 'room_status_update':
|
|
404
|
+
if (msg.payload) {
|
|
405
|
+
this.roomStatuses = mergeRoomStatusUpdatePayload(this.roomStatuses, msg.payload);
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
case 'error': {
|
|
409
|
+
const errorPayload = parseErrorPayload(msg.payload);
|
|
410
|
+
if (errorPayload) {
|
|
411
|
+
this.error = errorPayload;
|
|
412
|
+
// Terminal errors that invalidate persisted reconnect
|
|
413
|
+
// state. The session is over for this CID — clear the
|
|
414
|
+
// token so a future join can't try to reclaim it.
|
|
415
|
+
if (
|
|
416
|
+
errorPayload.code === 'ROOM_ENDED' ||
|
|
417
|
+
errorPayload.code === 'INVALID_RECONNECT_TOKEN'
|
|
418
|
+
) {
|
|
419
|
+
this.resetForTerminal();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
break;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
this.notifyStateChange();
|
|
427
|
+
[...this.messageListeners].forEach(listener => listener(msg));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
private doConnect(index?: number): void {
|
|
431
|
+
if (this.closedByDestroy) return;
|
|
432
|
+
if (this.connecting) return;
|
|
433
|
+
|
|
434
|
+
const targetIndex = index ?? this.transportIndex;
|
|
435
|
+
const targetKind = this.transportOrder[targetIndex];
|
|
436
|
+
if (!targetKind) return;
|
|
437
|
+
this.transportIndex = targetIndex;
|
|
438
|
+
this.connecting = true;
|
|
439
|
+
|
|
440
|
+
if (this.transport) {
|
|
441
|
+
if (this.transport.getSessionId) {
|
|
442
|
+
this.sseSid = this.transport.getSessionId();
|
|
443
|
+
}
|
|
444
|
+
this.transport.close();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const connectionId = this.transportId + 1;
|
|
448
|
+
this.transportId = connectionId;
|
|
449
|
+
|
|
450
|
+
const transport = createSignalingTransport(targetKind, {
|
|
451
|
+
onOpen: () => {
|
|
452
|
+
if (connectionId !== this.transportId) return;
|
|
453
|
+
this.connecting = false;
|
|
454
|
+
this.reconnectAttempts = 0;
|
|
455
|
+
if (targetKind === 'ws') {
|
|
456
|
+
this.wsConsecutiveFailures = 0;
|
|
457
|
+
}
|
|
458
|
+
const wasConnected = this.isConnected;
|
|
459
|
+
this.isConnected = true;
|
|
460
|
+
this.activeTransport = targetKind;
|
|
461
|
+
this.transportConnectedOnce[targetKind] = true;
|
|
462
|
+
this.startPingInterval();
|
|
463
|
+
if (!wasConnected) {
|
|
464
|
+
if (this.pendingJoin) {
|
|
465
|
+
const roomId = this.pendingJoin;
|
|
466
|
+
this.pendingJoin = null;
|
|
467
|
+
this.joinRoom(roomId);
|
|
468
|
+
} else if (this.needsRejoin && this.currentRoomId) {
|
|
469
|
+
this.logger?.log('debug', 'Signaling', `Auto-rejoining room ${this.currentRoomId}`);
|
|
470
|
+
this.needsRejoin = false;
|
|
471
|
+
this.joinRoom(this.currentRoomId);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
this.notifyStateChange();
|
|
475
|
+
},
|
|
476
|
+
onClose: (reason, err) => {
|
|
477
|
+
if (connectionId !== this.transportId) return;
|
|
478
|
+
this.connecting = false;
|
|
479
|
+
if (this.closedByDestroy) return;
|
|
480
|
+
this.logger?.log('error', 'Signaling', `Disconnected via ${reason}${err ? `: ${formatError(err)}` : ''}`);
|
|
481
|
+
this.isConnected = false;
|
|
482
|
+
this.activeTransport = null;
|
|
483
|
+
this.clearPingInterval();
|
|
484
|
+
if (targetKind === 'ws') {
|
|
485
|
+
this.wsConsecutiveFailures++;
|
|
486
|
+
}
|
|
487
|
+
if (this.clientId) {
|
|
488
|
+
this.lastClientId = this.clientId;
|
|
489
|
+
}
|
|
490
|
+
this.transport = null;
|
|
491
|
+
this.needsRejoin = !!this.currentRoomId;
|
|
492
|
+
// We may miss membership transitions while disconnected. Set
|
|
493
|
+
// the gate so consumers wait for an authoritative
|
|
494
|
+
// post-reconnect snapshot before scheduling renegotiation.
|
|
495
|
+
if (this.currentRoomId) {
|
|
496
|
+
this.epochAtDisconnect = this.lastEpoch;
|
|
497
|
+
this.awaitingPostReconnectSnapshot = true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (this.shouldFallback(targetKind, reason) && this.tryNextTransport(reason)) {
|
|
501
|
+
this.notifyStateChange();
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
this.scheduleReconnect();
|
|
506
|
+
this.notifyStateChange();
|
|
507
|
+
},
|
|
508
|
+
onMessage: (msg) => {
|
|
509
|
+
if (connectionId !== this.transportId) return;
|
|
510
|
+
this.handleIncomingMessage(msg);
|
|
511
|
+
}
|
|
512
|
+
}, {
|
|
513
|
+
wsUrl: this.wsUrl,
|
|
514
|
+
httpBaseUrl: this.httpBaseUrl,
|
|
515
|
+
sseSid: this.sseSid || undefined,
|
|
516
|
+
logger: this.logger,
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
this.transport = transport;
|
|
520
|
+
try {
|
|
521
|
+
transport.connect();
|
|
522
|
+
} catch (err) {
|
|
523
|
+
this.connecting = false;
|
|
524
|
+
this.logger?.log('error', 'Signaling', `Transport connect() threw: ${formatError(err)}`);
|
|
525
|
+
this.scheduleReconnect();
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
private shouldFallback(kind: TransportKind, reason: string): boolean {
|
|
530
|
+
if (this.transportOrder.length <= 1) return false;
|
|
531
|
+
if (this.transportIndex >= this.transportOrder.length - 1) return false;
|
|
532
|
+
if (reason === 'unsupported' || reason === 'timeout') return true;
|
|
533
|
+
if (!this.transportConnectedOnce[kind]) return true;
|
|
534
|
+
if (kind === 'ws' && this.wsConsecutiveFailures >= WS_FALLBACK_CONSECUTIVE_FAILURES) {
|
|
535
|
+
this.logger?.log('warning', 'Signaling', `${this.wsConsecutiveFailures} consecutive WS failures, allowing SSE fallback`);
|
|
536
|
+
return true;
|
|
537
|
+
}
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
private tryNextTransport(reason: string): boolean {
|
|
542
|
+
const nextIndex = this.transportIndex + 1;
|
|
543
|
+
if (nextIndex >= this.transportOrder.length) return false;
|
|
544
|
+
this.logger?.log('warning', 'Signaling', `${this.transportOrder[this.transportIndex]} failed (${reason}), trying ${this.transportOrder[nextIndex]}`);
|
|
545
|
+
this.reconnectAttempts = 0;
|
|
546
|
+
this.doConnect(nextIndex);
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private scheduleReconnect(): void {
|
|
551
|
+
if (this.closedByDestroy) return;
|
|
552
|
+
if (this.reconnectTimeout !== null) return;
|
|
553
|
+
const attempt = this.reconnectAttempts + 1;
|
|
554
|
+
this.reconnectAttempts = attempt;
|
|
555
|
+
const backoff = Math.min(RECONNECT_BACKOFF_BASE_MS * Math.pow(2, attempt - 1), RECONNECT_BACKOFF_CAP_MS);
|
|
556
|
+
|
|
557
|
+
this.reconnectTimeout = window.setTimeout(() => {
|
|
558
|
+
this.reconnectTimeout = null;
|
|
559
|
+
this.transportIndex = 0;
|
|
560
|
+
this.transportConnectedOnce = { ws: false, sse: false };
|
|
561
|
+
this.doConnect(0);
|
|
562
|
+
}, backoff);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private startPingInterval(): void {
|
|
566
|
+
this.clearPingInterval();
|
|
567
|
+
this.lastPongAt = Date.now();
|
|
568
|
+
this.missedPongs = 0;
|
|
569
|
+
|
|
570
|
+
this.pingInterval = window.setInterval(() => {
|
|
571
|
+
const elapsed = Date.now() - this.lastPongAt;
|
|
572
|
+
if (elapsed > PING_INTERVAL_MS) {
|
|
573
|
+
this.missedPongs++;
|
|
574
|
+
if (this.missedPongs >= PONG_MISS_THRESHOLD) {
|
|
575
|
+
this.logger?.log('warning', 'Signaling', `${this.missedPongs} missed pongs, treating connection as dead`);
|
|
576
|
+
this.missedPongs = 0;
|
|
577
|
+
if (this.transport) {
|
|
578
|
+
if (this.transport.forceClose) {
|
|
579
|
+
this.transport.forceClose('ping-timeout');
|
|
580
|
+
} else {
|
|
581
|
+
this.transport.close();
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
this.sendMessage('ping', { ts: Date.now() });
|
|
588
|
+
}, PING_INTERVAL_MS);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private turnRefreshGate: (() => Promise<boolean>) | null = null;
|
|
592
|
+
// Absolute timestamp (epoch ms) at which the current TURN credential expires.
|
|
593
|
+
// Set when TTL is installed; used to compute "remaining until expiry" so the
|
|
594
|
+
// skip-path reschedule has a real safety buffer on repeat skips.
|
|
595
|
+
private turnTokenExpiresAtMs: number | null = null;
|
|
596
|
+
|
|
597
|
+
setTurnRefreshGate(gate: (() => Promise<boolean>) | null): void {
|
|
598
|
+
this.turnRefreshGate = gate;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private scheduleTurnRefresh(delayOverrideMs?: number): void {
|
|
602
|
+
this.clearTurnRefreshTimer();
|
|
603
|
+
if (!this.isConnected || !this.turnTokenTTLMs || !this.currentRoomId) return;
|
|
604
|
+
if (delayOverrideMs === undefined) {
|
|
605
|
+
// Initial schedule after fresh creds: trigger at `ratio * TTL`.
|
|
606
|
+
this.turnTokenExpiresAtMs = Date.now() + this.turnTokenTTLMs;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const refreshDelay = delayOverrideMs ?? this.turnTokenTTLMs * TURN_REFRESH_TRIGGER_RATIO;
|
|
610
|
+
this.logger?.log('debug', 'Signaling', `Scheduling TURN refresh in ${Math.round(refreshDelay / 1000)}s`);
|
|
611
|
+
this.turnRefreshTimer = window.setTimeout(() => {
|
|
612
|
+
this.turnRefreshTimer = null;
|
|
613
|
+
void this.maybeSendTurnRefresh();
|
|
614
|
+
}, refreshDelay);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private async maybeSendTurnRefresh(): Promise<void> {
|
|
618
|
+
if (!this.isConnected || !this.currentRoomId) return;
|
|
619
|
+
if (this.turnRefreshGate) {
|
|
620
|
+
let shouldRefresh = true;
|
|
621
|
+
try {
|
|
622
|
+
shouldRefresh = await this.turnRefreshGate();
|
|
623
|
+
} catch { /* gate failure → default to refreshing */ }
|
|
624
|
+
if (!shouldRefresh) {
|
|
625
|
+
this.logger?.log('debug', 'Signaling', 'Skipping turn-refresh: all peer paths direct');
|
|
626
|
+
// Reschedule at a fraction of the remaining lifetime so a late
|
|
627
|
+
// path failover to relay still has time to refresh before the
|
|
628
|
+
// current credentials expire. Using `remaining * ratio` gives
|
|
629
|
+
// an exponential approach to expiry on repeat skips.
|
|
630
|
+
if (this.turnTokenExpiresAtMs !== null) {
|
|
631
|
+
const remainingMs = this.turnTokenExpiresAtMs - Date.now();
|
|
632
|
+
if (remainingMs > 0) {
|
|
633
|
+
this.scheduleTurnRefresh(remainingMs * TURN_REFRESH_TRIGGER_RATIO);
|
|
634
|
+
}
|
|
635
|
+
// remainingMs <= 0 → creds already expired; stop polling.
|
|
636
|
+
// A later relay transition is out of our hands; the call
|
|
637
|
+
// was direct when signaling gave us a chance to refresh.
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
this.logger?.log('debug', 'Signaling', 'Sending turn-refresh request');
|
|
643
|
+
this.sendMessage('turn-refresh');
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
private clearJoinTimers(): void {
|
|
647
|
+
if (this.joinKickstartTimer !== null) { window.clearTimeout(this.joinKickstartTimer); this.joinKickstartTimer = null; }
|
|
648
|
+
if (this.joinRecoveryTimer !== null) { window.clearTimeout(this.joinRecoveryTimer); this.joinRecoveryTimer = null; }
|
|
649
|
+
if (this.joinHardTimeout !== null) { window.clearTimeout(this.joinHardTimeout); this.joinHardTimeout = null; }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private clearPingInterval(): void {
|
|
653
|
+
if (this.pingInterval !== null) { window.clearInterval(this.pingInterval); this.pingInterval = null; }
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
private clearTurnRefreshTimer(): void {
|
|
657
|
+
if (this.turnRefreshTimer !== null) { window.clearTimeout(this.turnRefreshTimer); this.turnRefreshTimer = null; }
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private clearReconnectTimeout(): void {
|
|
661
|
+
if (this.reconnectTimeout !== null) { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; }
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
private notifyStateChange(): void {
|
|
665
|
+
[...this.stateListeners].forEach(l => l());
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Drops in-room state, persisted reconnect authority, and the
|
|
669
|
+
// post-reconnect gate. Called for any terminal event that means the
|
|
670
|
+
// current session is over and should not influence a future join:
|
|
671
|
+
// server `room_ended`, terminal error codes, etc.
|
|
672
|
+
private resetForTerminal(): void {
|
|
673
|
+
this.clearJoinTimers();
|
|
674
|
+
this.roomState = null;
|
|
675
|
+
this.currentRoomId = null;
|
|
676
|
+
this.needsRejoin = false;
|
|
677
|
+
this.clearReconnectStorage();
|
|
678
|
+
this.lastReconnectOutcome = null;
|
|
679
|
+
this.lastEpoch = null;
|
|
680
|
+
this.awaitingPostReconnectSnapshot = false;
|
|
681
|
+
this.epochAtDisconnect = null;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Session storage helpers
|
|
685
|
+
private readonly storageKeyClientId = 'serenada.reconnectCid';
|
|
686
|
+
private readonly storageKeyReconnectToken = 'serenada.reconnectToken';
|
|
687
|
+
private readonly storageKeyReconnectTokenRoom = 'serenada.reconnectTokenRoom';
|
|
688
|
+
|
|
689
|
+
private loadReconnectStorage(): void {
|
|
690
|
+
try {
|
|
691
|
+
const stored = window.sessionStorage.getItem(this.storageKeyClientId);
|
|
692
|
+
if (stored && !this.lastClientId) this.lastClientId = stored;
|
|
693
|
+
const storedToken = window.sessionStorage.getItem(this.storageKeyReconnectToken);
|
|
694
|
+
if (storedToken && !this.reconnectToken) this.reconnectToken = storedToken;
|
|
695
|
+
const storedTokenRoom = window.sessionStorage.getItem(this.storageKeyReconnectTokenRoom);
|
|
696
|
+
if (storedTokenRoom && !this.reconnectTokenRoomId) this.reconnectTokenRoomId = storedTokenRoom;
|
|
697
|
+
} catch (err) {
|
|
698
|
+
this.logger?.log('warning', 'Signaling', `Failed to load reconnectCid: ${err}`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private persistClientId(): void {
|
|
703
|
+
if (this.clientId) {
|
|
704
|
+
try { window.sessionStorage.setItem(this.storageKeyClientId, this.clientId); }
|
|
705
|
+
catch (err) { this.logger?.log('warning', 'Signaling', `Failed to persist reconnectCid: ${err}`); }
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
private persistReconnectStorage(): void {
|
|
710
|
+
try {
|
|
711
|
+
if (this.reconnectToken) {
|
|
712
|
+
window.sessionStorage.setItem(this.storageKeyReconnectToken, this.reconnectToken);
|
|
713
|
+
}
|
|
714
|
+
if (this.reconnectTokenRoomId) {
|
|
715
|
+
window.sessionStorage.setItem(this.storageKeyReconnectTokenRoom, this.reconnectTokenRoomId);
|
|
716
|
+
}
|
|
717
|
+
} catch (err) {
|
|
718
|
+
this.logger?.log('warning', 'Signaling', `Failed to persist reconnectToken: ${err}`);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
private clearReconnectStorage(): void {
|
|
723
|
+
try {
|
|
724
|
+
window.sessionStorage.removeItem(this.storageKeyClientId);
|
|
725
|
+
window.sessionStorage.removeItem(this.storageKeyReconnectToken);
|
|
726
|
+
window.sessionStorage.removeItem(this.storageKeyReconnectTokenRoom);
|
|
727
|
+
} catch (err) {
|
|
728
|
+
this.logger?.log('warning', 'Signaling', `Failed to clear reconnectCid: ${err}`);
|
|
729
|
+
}
|
|
730
|
+
this.reconnectToken = null;
|
|
731
|
+
this.reconnectTokenRoomId = null;
|
|
732
|
+
this.sessionStartTs = null;
|
|
733
|
+
clearRecoveryRecord();
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Snapshots the in-memory reconnect state into the cross-launch
|
|
737
|
+
// recovery store so a relaunched tab can offer a "Rejoin call?" prompt.
|
|
738
|
+
// No-op when we don't have full credentials yet (e.g. first transport
|
|
739
|
+
// open before the server has answered with `joined`).
|
|
740
|
+
private persistRecoveryRecord(reconnectTokenTTLMs: number | undefined): void {
|
|
741
|
+
if (!this.currentRoomId || !this.clientId || !this.reconnectToken || !this.sessionStartTs) {
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
// Fall back to the cross-platform suspendHardEvictionTimeout when
|
|
745
|
+
// the server didn't surface a token TTL. The Go server's reconnect
|
|
746
|
+
// token TTL is bound to suspendHardEvictionTimeout (see
|
|
747
|
+
// signaling.go: reconnectTokenTTL = suspendHardEvictionTimeout), so
|
|
748
|
+
// mirroring that constant keeps Web aligned with iOS/Android and
|
|
749
|
+
// the server without local drift.
|
|
750
|
+
const ttl = reconnectTokenTTLMs && reconnectTokenTTLMs > 0
|
|
751
|
+
? reconnectTokenTTLMs
|
|
752
|
+
: SUSPEND_HARD_EVICTION_TIMEOUT_MS;
|
|
753
|
+
saveRecoveryRecord({
|
|
754
|
+
roomId: this.currentRoomId,
|
|
755
|
+
cid: this.clientId,
|
|
756
|
+
reconnectToken: this.reconnectToken,
|
|
757
|
+
lastEpoch: this.lastEpoch,
|
|
758
|
+
sessionStartTs: this.sessionStartTs,
|
|
759
|
+
expiresAtMs: Date.now() + ttl,
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|