@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.
Files changed (156) hide show
  1. package/dist/ConsoleLogger.d.ts +6 -0
  2. package/dist/ConsoleLogger.d.ts.map +1 -0
  3. package/dist/ConsoleLogger.js +21 -0
  4. package/dist/ConsoleLogger.js.map +1 -0
  5. package/dist/RoomWatcher.d.ts +34 -0
  6. package/dist/RoomWatcher.d.ts.map +1 -0
  7. package/dist/RoomWatcher.js +103 -0
  8. package/dist/RoomWatcher.js.map +1 -0
  9. package/dist/SerenadaCore.d.ts +47 -0
  10. package/dist/SerenadaCore.d.ts.map +1 -0
  11. package/dist/SerenadaCore.js +141 -0
  12. package/dist/SerenadaCore.js.map +1 -0
  13. package/dist/SerenadaDiagnostics.d.ts +49 -0
  14. package/dist/SerenadaDiagnostics.d.ts.map +1 -0
  15. package/dist/SerenadaDiagnostics.js +421 -0
  16. package/dist/SerenadaDiagnostics.js.map +1 -0
  17. package/dist/SerenadaServerProvider.d.ts +48 -0
  18. package/dist/SerenadaServerProvider.d.ts.map +1 -0
  19. package/dist/SerenadaServerProvider.js +296 -0
  20. package/dist/SerenadaServerProvider.js.map +1 -0
  21. package/dist/SerenadaSession.d.ts +180 -0
  22. package/dist/SerenadaSession.d.ts.map +1 -0
  23. package/dist/SerenadaSession.js +1082 -0
  24. package/dist/SerenadaSession.js.map +1 -0
  25. package/dist/SignalingProvider.d.ts +132 -0
  26. package/dist/SignalingProvider.d.ts.map +1 -0
  27. package/dist/SignalingProvider.js +50 -0
  28. package/dist/SignalingProvider.js.map +1 -0
  29. package/dist/api/roomApi.d.ts +2 -0
  30. package/dist/api/roomApi.d.ts.map +1 -0
  31. package/dist/api/roomApi.js +14 -0
  32. package/dist/api/roomApi.js.map +1 -0
  33. package/dist/cameraModes.d.ts +13 -0
  34. package/dist/cameraModes.d.ts.map +1 -0
  35. package/dist/cameraModes.js +35 -0
  36. package/dist/cameraModes.js.map +1 -0
  37. package/dist/configValidation.d.ts +10 -0
  38. package/dist/configValidation.d.ts.map +1 -0
  39. package/dist/configValidation.js +24 -0
  40. package/dist/configValidation.js.map +1 -0
  41. package/dist/constants.d.ts +33 -0
  42. package/dist/constants.d.ts.map +1 -0
  43. package/dist/constants.js +65 -0
  44. package/dist/constants.js.map +1 -0
  45. package/dist/formatError.d.ts +3 -0
  46. package/dist/formatError.d.ts.map +1 -0
  47. package/dist/formatError.js +7 -0
  48. package/dist/formatError.js.map +1 -0
  49. package/dist/iceServers.d.ts +2 -0
  50. package/dist/iceServers.d.ts.map +1 -0
  51. package/dist/iceServers.js +21 -0
  52. package/dist/iceServers.js.map +1 -0
  53. package/dist/index.d.ts +55 -0
  54. package/dist/index.d.ts.map +1 -0
  55. package/dist/index.js +44 -0
  56. package/dist/index.js.map +1 -0
  57. package/dist/layout/computeLayout.d.ts +81 -0
  58. package/dist/layout/computeLayout.d.ts.map +1 -0
  59. package/dist/layout/computeLayout.js +380 -0
  60. package/dist/layout/computeLayout.js.map +1 -0
  61. package/dist/media/AudioLevelMonitor.d.ts +51 -0
  62. package/dist/media/AudioLevelMonitor.d.ts.map +1 -0
  63. package/dist/media/AudioLevelMonitor.js +179 -0
  64. package/dist/media/AudioLevelMonitor.js.map +1 -0
  65. package/dist/media/MediaEngine.d.ts +137 -0
  66. package/dist/media/MediaEngine.d.ts.map +1 -0
  67. package/dist/media/MediaEngine.js +1224 -0
  68. package/dist/media/MediaEngine.js.map +1 -0
  69. package/dist/media/callStats.d.ts +16 -0
  70. package/dist/media/callStats.d.ts.map +1 -0
  71. package/dist/media/callStats.js +214 -0
  72. package/dist/media/callStats.js.map +1 -0
  73. package/dist/media/localVideoRecovery.d.ts +16 -0
  74. package/dist/media/localVideoRecovery.d.ts.map +1 -0
  75. package/dist/media/localVideoRecovery.js +14 -0
  76. package/dist/media/localVideoRecovery.js.map +1 -0
  77. package/dist/recoveryStorage.d.ts +33 -0
  78. package/dist/recoveryStorage.d.ts.map +1 -0
  79. package/dist/recoveryStorage.js +88 -0
  80. package/dist/recoveryStorage.js.map +1 -0
  81. package/dist/serverUrls.d.ts +8 -0
  82. package/dist/serverUrls.d.ts.map +1 -0
  83. package/dist/serverUrls.js +65 -0
  84. package/dist/serverUrls.js.map +1 -0
  85. package/dist/signaling/SignalingEngine.d.ts +126 -0
  86. package/dist/signaling/SignalingEngine.d.ts.map +1 -0
  87. package/dist/signaling/SignalingEngine.js +720 -0
  88. package/dist/signaling/SignalingEngine.js.map +1 -0
  89. package/dist/signaling/payloads.d.ts +76 -0
  90. package/dist/signaling/payloads.d.ts.map +1 -0
  91. package/dist/signaling/payloads.js +160 -0
  92. package/dist/signaling/payloads.js.map +1 -0
  93. package/dist/signaling/roomStatuses.d.ts +9 -0
  94. package/dist/signaling/roomStatuses.d.ts.map +1 -0
  95. package/dist/signaling/roomStatuses.js +71 -0
  96. package/dist/signaling/roomStatuses.js.map +1 -0
  97. package/dist/signaling/transportConfig.d.ts +3 -0
  98. package/dist/signaling/transportConfig.d.ts.map +1 -0
  99. package/dist/signaling/transportConfig.js +27 -0
  100. package/dist/signaling/transportConfig.js.map +1 -0
  101. package/dist/signaling/transports/index.d.ts +13 -0
  102. package/dist/signaling/transports/index.d.ts.map +1 -0
  103. package/dist/signaling/transports/index.js +11 -0
  104. package/dist/signaling/transports/index.js.map +1 -0
  105. package/dist/signaling/transports/sse.d.ts +26 -0
  106. package/dist/signaling/transports/sse.d.ts.map +1 -0
  107. package/dist/signaling/transports/sse.js +131 -0
  108. package/dist/signaling/transports/sse.js.map +1 -0
  109. package/dist/signaling/transports/types.d.ts +17 -0
  110. package/dist/signaling/transports/types.d.ts.map +1 -0
  111. package/dist/signaling/transports/types.js +2 -0
  112. package/dist/signaling/transports/types.js.map +1 -0
  113. package/dist/signaling/transports/ws.d.ts +21 -0
  114. package/dist/signaling/transports/ws.d.ts.map +1 -0
  115. package/dist/signaling/transports/ws.js +93 -0
  116. package/dist/signaling/transports/ws.js.map +1 -0
  117. package/dist/signaling/types.d.ts +53 -0
  118. package/dist/signaling/types.d.ts.map +1 -0
  119. package/dist/signaling/types.js +2 -0
  120. package/dist/signaling/types.js.map +1 -0
  121. package/dist/types.d.ts +279 -0
  122. package/dist/types.d.ts.map +1 -0
  123. package/dist/types.js +3 -0
  124. package/dist/types.js.map +1 -0
  125. package/package.json +43 -0
  126. package/src/ConsoleLogger.ts +14 -0
  127. package/src/RoomWatcher.ts +127 -0
  128. package/src/SerenadaCore.ts +163 -0
  129. package/src/SerenadaDiagnostics.ts +485 -0
  130. package/src/SerenadaServerProvider.ts +362 -0
  131. package/src/SerenadaSession.ts +1258 -0
  132. package/src/SignalingProvider.ts +207 -0
  133. package/src/api/roomApi.ts +16 -0
  134. package/src/cameraModes.ts +34 -0
  135. package/src/configValidation.ts +35 -0
  136. package/src/constants.ts +77 -0
  137. package/src/formatError.ts +5 -0
  138. package/src/iceServers.ts +20 -0
  139. package/src/index.ts +155 -0
  140. package/src/layout/computeLayout.ts +639 -0
  141. package/src/media/AudioLevelMonitor.ts +190 -0
  142. package/src/media/MediaEngine.ts +1183 -0
  143. package/src/media/callStats.ts +260 -0
  144. package/src/media/localVideoRecovery.ts +39 -0
  145. package/src/recoveryStorage.ts +101 -0
  146. package/src/serverUrls.ts +69 -0
  147. package/src/signaling/SignalingEngine.ts +762 -0
  148. package/src/signaling/payloads.ts +215 -0
  149. package/src/signaling/roomStatuses.ts +89 -0
  150. package/src/signaling/transportConfig.ts +30 -0
  151. package/src/signaling/transports/index.ts +26 -0
  152. package/src/signaling/transports/sse.ts +146 -0
  153. package/src/signaling/transports/types.ts +19 -0
  154. package/src/signaling/transports/ws.ts +108 -0
  155. package/src/signaling/types.ts +68 -0
  156. 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
+ }