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