@unboundcx/video-sdk-client 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28,6 +28,11 @@ export class VideoMeetingClient extends EventEmitter {
28
28
  * @param {Object} options
29
29
  * @param {string} [options.serverUrl] - WebSocket server URL (optional if using joinFromApiResponse)
30
30
  * @param {boolean} [options.debug=false] - Enable debug logging
31
+ * @param {Object} [options.sdk] - UnboundSDK instance, injected by
32
+ * `sdk.video.createMeetingClient()`. Required for transparent reassignment
33
+ * recovery (calls `sdk.video.joinRoom()`) and for `endSession()` to invoke
34
+ * `sdk.video.endSession()`. Without it, consumers must handle both paths
35
+ * themselves.
31
36
  */
32
37
  constructor(options = {}) {
33
38
  super();
@@ -39,6 +44,15 @@ export class VideoMeetingClient extends EventEmitter {
39
44
  this.debug = options.debug;
40
45
  this.isGuest = false;
41
46
  this.inWaitingRoom = false;
47
+ this.sdk = options.sdk || null;
48
+
49
+ // Reassignment recovery state. Attempt window is 60s; cap at 3 attempts.
50
+ // Backoff schedule: 500ms, 2s, 5s. Counter resets once a connection has
51
+ // stayed up for >60s (see _onReassignmentStableConnect).
52
+ this._reassignmentAttempts = []; // timestamps (Date.now()) of recent attempts
53
+ this._reassignmentInFlight = false;
54
+ this._reassignmentStableTimer = null;
55
+ this._lastJoinArgs = null; // args used by consumer to call sdk.video.joinRoom()
42
56
 
43
57
  // Managers will be initialized when we have connection info
44
58
  this.connection = null;
@@ -51,16 +65,19 @@ export class VideoMeetingClient extends EventEmitter {
51
65
  this._initializeManagers(options.serverUrl);
52
66
  }
53
67
 
54
- this.logger.info('VideoMeetingClient initialized');
68
+ this.logger.info('VideoMeetingClient initialized', { hasSdk: !!this.sdk });
55
69
  }
56
70
 
57
71
  /**
58
72
  * Initialize managers with server URL
59
73
  * @private
74
+ * @param {string} serverUrl
75
+ * @param {string} [namespace] - Socket.IO namespace path, e.g. `/video`
60
76
  */
61
- _initializeManagers(serverUrl) {
77
+ _initializeManagers(serverUrl, namespace = null) {
62
78
  this.connection = new ConnectionManager({
63
79
  serverUrl,
80
+ namespace,
64
81
  debug: this.debug,
65
82
  });
66
83
 
@@ -83,6 +100,15 @@ export class VideoMeetingClient extends EventEmitter {
83
100
 
84
101
  // Proxy manager events to SDK events
85
102
  this._setupEventProxies();
103
+
104
+ // When mediasoup detects its transports are stale after a long disconnect,
105
+ // re-run the room.join flow to get fresh transport options + producer list
106
+ // from the server. This keeps media working across network blips >30s.
107
+ this.mediasoup.on('transports:recreated-needed', () => {
108
+ this._rejoinRoomForTransportRecreation().catch((err) => {
109
+ this.logger.error('Rejoin for transport recreation failed', err);
110
+ });
111
+ });
86
112
  }
87
113
 
88
114
  /**
@@ -94,6 +120,15 @@ export class VideoMeetingClient extends EventEmitter {
94
120
  this.connection.on('connected', () => {
95
121
  this._setState('connected');
96
122
  this.emit('connected');
123
+
124
+ // On Socket.IO reconnect (not initial connect), check whether the
125
+ // WebRTC transports died during the disconnect window. If the state
126
+ // has been bad for >10s, mediasoup will emit transports:recreated-needed.
127
+ if (this.mediasoup && (this.mediasoup.sendTransport || this.mediasoup.recvTransport)) {
128
+ this.mediasoup.recreateStaleTransports().catch((err) => {
129
+ this.logger.error('recreateStaleTransports threw', err);
130
+ });
131
+ }
97
132
  });
98
133
 
99
134
  this.connection.on('disconnected', (data) => {
@@ -105,6 +140,14 @@ export class VideoMeetingClient extends EventEmitter {
105
140
  this.emit('error', error);
106
141
  });
107
142
 
143
+ // Server signaled the assigned pod is stale — re-fetch joinRoom and reconnect.
144
+ this.connection.on('reassignmentRequired', ({ code }) => {
145
+ this.logger.warn('Reassignment required', { code });
146
+ this._handleReassignment(code).catch((err) => {
147
+ this.logger.error('Reassignment handler threw', err);
148
+ });
149
+ });
150
+
108
151
  // Local media events
109
152
  this.localMedia.on('stream:added', (data) => {
110
153
  this.emit('local-stream:added', data);
@@ -304,52 +347,83 @@ export class VideoMeetingClient extends EventEmitter {
304
347
  async joinFromApiResponse(joinResponse, options = {}) {
305
348
  this.logger.info('Joining from API response');
306
349
 
307
- try {
308
- // Extract data from API response
309
- const { server, authorization, videoRoom, participant } = joinResponse;
350
+ // Store args used to obtain this response. If a reassignment is required
351
+ // later, we replay these against sdk.video.joinRoom() to get a fresh
352
+ // assignment without the consumer having to wire anything up.
353
+ if (options.joinRoomArgs !== undefined) {
354
+ this._lastJoinArgs = options.joinRoomArgs;
355
+ }
310
356
 
311
- if (!server?.url || !server?.socketPort) {
312
- throw new Error('Invalid join response: missing server info');
357
+ try {
358
+ const { videoRoom, participant } = joinResponse;
359
+
360
+ // Support both the new shape (connectionInfo.socket) and the legacy
361
+ // shape (top-level server + authorization) during the rollout.
362
+ // Legacy path is removed once every app1-api replica returns the new
363
+ // shape in staging/prod.
364
+ const connectionInfo = joinResponse.connectionInfo;
365
+ const socketInfo = connectionInfo?.socket;
366
+ const legacyServer = joinResponse.server;
367
+
368
+ let socketUrl;
369
+ let socketNamespace;
370
+ let authData;
371
+ let authorization;
372
+
373
+ if (socketInfo?.url) {
374
+ socketUrl = socketInfo.url;
375
+ socketNamespace = socketInfo.namespace || '/video';
376
+ authData = {
377
+ ...socketInfo.auth,
378
+ roomId: videoRoom.id,
379
+ participantId: participant.id,
380
+ };
381
+ authorization = connectionInfo.authorization || null;
382
+ } else if (legacyServer?.url && legacyServer?.socketPort) {
383
+ // Legacy per-pod-ingress path. Remove once centralized signaling
384
+ // is fully deployed.
385
+ socketUrl = `https://${legacyServer.url}:${legacyServer.socketPort}`;
386
+ socketNamespace = null;
387
+ authorization = joinResponse.authorization;
388
+ authData = {
389
+ accountNamespace: authorization?.namespace,
390
+ roomId: videoRoom.id,
391
+ participantId: participant.id,
392
+ };
393
+ } else {
394
+ throw new Error('Invalid join response: missing connectionInfo.socket and legacy server info');
313
395
  }
314
396
 
315
- // Store join data for later use
397
+ // Store join data for later use (including reassignment replay)
316
398
  this.joinData = {
317
399
  videoRoom,
318
400
  participant,
319
- server,
320
- authorization
401
+ connectionInfo: connectionInfo || null,
402
+ server: legacyServer || null,
403
+ authorization,
321
404
  };
322
405
 
323
406
  // Detect if this is a guest (no host privileges)
324
407
  this.isGuest = !participant.isHost && !participant.isModerator;
325
408
 
326
- // Build server URL - use https:// (not wss://) to allow Socket.io to handle upgrade
327
- // This ensures cookies are sent during the HTTP handshake for authentication
328
- const serverUrl = `https://${server.url}:${server.socketPort}`;
329
-
330
- this.logger.info('Connecting to video server:', serverUrl);
409
+ this.logger.info('Connecting to video server:', { socketUrl, socketNamespace });
331
410
 
332
411
  // Initialize managers if not already done
333
412
  if (!this.connection) {
334
- this._initializeManagers(serverUrl);
413
+ this._initializeManagers(socketUrl, socketNamespace);
335
414
  }
336
415
 
337
- // Connect with authorization (matches old videoCreateSocket.js format)
338
- // NOTE: Using cookie-based auth, so only send namespace, roomId, participantId
339
- // The HTTP cookie set by api.video.joinRoom() handles authentication
340
416
  this._setState('connecting');
341
417
 
342
- const authData = {
343
- namespace: authorization.namespace,
344
- roomId: videoRoom.id,
345
- participantId: participant.id
346
- };
347
-
348
418
  this.logger.info('Connecting with auth (cookie-based):', authData);
349
419
  await this.connection.connect(authData);
350
420
 
351
421
  this._setState('connected');
352
422
 
423
+ // Connection succeeded. If it stays up >60s, reset the reassignment
424
+ // attempt counter so the next reassignment cycle starts fresh.
425
+ this._armReassignmentStableTimer();
426
+
353
427
  // Setup remote media listeners and room event listeners
354
428
  this.logger.info('Setting up event listeners');
355
429
  this.remoteMedia._setupServerListeners();
@@ -1207,4 +1281,228 @@ export class VideoMeetingClient extends EventEmitter {
1207
1281
  }
1208
1282
  return await this.remoteMedia.retryConsumeProducer(producerId, participantId);
1209
1283
  }
1284
+
1285
+ // ========== Session Lifecycle ==========
1286
+
1287
+ /**
1288
+ * End the meeting session server-side. Clears the videoAuthToken cookie
1289
+ * and invalidates the token row so a stale cookie can't be replayed.
1290
+ *
1291
+ * Intended to be called on `leave()`, on a page `beforeunload`, or when the
1292
+ * consumer surfaces an "already joined in another tab" UX and wants to
1293
+ * clear the incumbent session before retrying. Safe to call from
1294
+ * `fetch({ keepalive: true })` during unload.
1295
+ *
1296
+ * Delegates to `sdk.video.endSession(roomId)` if an SDK was injected via
1297
+ * constructor options; otherwise this is a no-op (consumer must clear the
1298
+ * cookie through its own plumbing).
1299
+ *
1300
+ * @param {Object} [options]
1301
+ * @param {string} [options.roomId] - Override. Defaults to the current room.
1302
+ * @returns {Promise<void>}
1303
+ */
1304
+ async endSession(options = {}) {
1305
+ const roomId = options.roomId || this.currentRoomId || this.joinData?.videoRoom?.id;
1306
+ if (!roomId) {
1307
+ this.logger.warn('endSession: no roomId available');
1308
+ return;
1309
+ }
1310
+ if (!this.sdk?.video?.endSession) {
1311
+ this.logger.warn('endSession: sdk not injected — consumer must clear session cookie manually');
1312
+ return;
1313
+ }
1314
+ try {
1315
+ await this.sdk.video.endSession(roomId);
1316
+ this.logger.info('Session ended', { roomId });
1317
+ } catch (err) {
1318
+ this.logger.error('endSession failed', err);
1319
+ }
1320
+ }
1321
+
1322
+ // ========== Long-Disconnect Transport Recreation ==========
1323
+
1324
+ /**
1325
+ * Re-run the `room.join` flow after mediasoup wiped stale transports.
1326
+ * The server responds with fresh media.routerCapabilities + media.transports;
1327
+ * we reuse the existing loadDevice / transport listener plumbing so producers
1328
+ * and consumers get re-wired.
1329
+ *
1330
+ * Consumer must re-publish local streams (camera/microphone) after this —
1331
+ * producers died with the old transports.
1332
+ * @private
1333
+ */
1334
+ async _rejoinRoomForTransportRecreation() {
1335
+ if (!this.joinData || !this.connection) {
1336
+ this.logger.warn('Cannot rejoin for transport recreation: no joinData or connection');
1337
+ return;
1338
+ }
1339
+ this.logger.info('Re-emitting room.join to recreate transports after long disconnect');
1340
+
1341
+ // Reset the transports promise — _setupMediaEventListeners() creates it
1342
+ // via a fresh media.transports listener in the join flow.
1343
+ this._setupMediaEventListeners();
1344
+
1345
+ // Also need a fresh routerCapabilities listener since device must be
1346
+ // (re-)loaded. mediasoup.loadDevice() is idempotent only if device
1347
+ // isn't already loaded; after cleanup it should be safe to reload.
1348
+ this.connection.emit('room.join', {});
1349
+
1350
+ try {
1351
+ if (!this.mediasoup.device.loaded) {
1352
+ await this.mediasoup.loadDevice();
1353
+ }
1354
+ await this._waitForTransports();
1355
+ this.emit('transports:recreated');
1356
+ this.logger.info('Transport recreation complete');
1357
+ } catch (err) {
1358
+ this.logger.error('Transport recreation failed', err);
1359
+ this.emit('error', {
1360
+ code: 'transport_recreation_failed',
1361
+ message: err.message,
1362
+ });
1363
+ }
1364
+ }
1365
+
1366
+ // ========== Reassignment Recovery ==========
1367
+
1368
+ /**
1369
+ * Arm the stable-connect timer. If the current connection stays up for >60s,
1370
+ * clear the reassignment attempt counter so the next cycle starts fresh.
1371
+ * @private
1372
+ */
1373
+ _armReassignmentStableTimer() {
1374
+ if (this._reassignmentStableTimer) {
1375
+ clearTimeout(this._reassignmentStableTimer);
1376
+ }
1377
+ this._reassignmentStableTimer = setTimeout(() => {
1378
+ if (this._reassignmentAttempts.length > 0) {
1379
+ this.logger.info('Connection stable >60s; resetting reassignment attempt counter');
1380
+ this._reassignmentAttempts = [];
1381
+ }
1382
+ this._reassignmentStableTimer = null;
1383
+ }, 60_000);
1384
+ }
1385
+
1386
+ /**
1387
+ * Handle a reassignmentRequired signal from the ConnectionManager.
1388
+ * Caps at 3 attempts in a 60s window with backoff 500ms, 2s, 5s.
1389
+ * @private
1390
+ */
1391
+ async _handleReassignment(code) {
1392
+ if (this._reassignmentInFlight) {
1393
+ this.logger.warn('Reassignment already in flight; ignoring duplicate signal');
1394
+ return;
1395
+ }
1396
+ this._reassignmentInFlight = true;
1397
+
1398
+ try {
1399
+ // Prune attempts older than 60s
1400
+ const now = Date.now();
1401
+ const windowStart = now - 60_000;
1402
+ this._reassignmentAttempts = this._reassignmentAttempts.filter(ts => ts > windowStart);
1403
+
1404
+ const attemptIndex = this._reassignmentAttempts.length;
1405
+ if (attemptIndex >= 3) {
1406
+ this.logger.error('Reassignment cap reached (3 in 60s); emitting reassignment_exhausted');
1407
+ this.emit('error', {
1408
+ code: 'reassignment_exhausted',
1409
+ message: 'Reassignment attempts exhausted',
1410
+ triggeredBy: code,
1411
+ });
1412
+ return;
1413
+ }
1414
+
1415
+ this._reassignmentAttempts.push(now);
1416
+
1417
+ const backoffs = [500, 2000, 5000];
1418
+ const delay = backoffs[attemptIndex];
1419
+ this.logger.info(`Reassignment attempt ${attemptIndex + 1}/3 in ${delay}ms (code=${code})`);
1420
+
1421
+ await new Promise((resolve) => setTimeout(resolve, delay));
1422
+
1423
+ if (!this.sdk?.video?.joinRoom || !this._lastJoinArgs) {
1424
+ this.logger.error('Cannot reassign: sdk or last joinRoom args missing');
1425
+ this.emit('error', {
1426
+ code: 'reassignment_exhausted',
1427
+ message: 'Reassignment plumbing missing (no sdk or joinRoomArgs)',
1428
+ triggeredBy: code,
1429
+ });
1430
+ return;
1431
+ }
1432
+
1433
+ // Teardown current media state. The old pod's mediasoup Router is
1434
+ // gone (or the token was rejected); we re-run the full join flow.
1435
+ await this._teardownForReassignment();
1436
+
1437
+ // Fetch a fresh assignment.
1438
+ const args = Array.isArray(this._lastJoinArgs)
1439
+ ? this._lastJoinArgs
1440
+ : [this._lastJoinArgs];
1441
+ const response = await this.sdk.video.joinRoom(...args);
1442
+
1443
+ // Re-run the join flow with the fresh response.
1444
+ await this.joinFromApiResponse(response, { joinRoomArgs: this._lastJoinArgs });
1445
+
1446
+ this.emit('reassigned', { code, attempt: attemptIndex + 1 });
1447
+ this.logger.info('Reassignment succeeded', { attempt: attemptIndex + 1 });
1448
+
1449
+ } catch (err) {
1450
+ this.logger.error('Reassignment failed', err);
1451
+ // Don't count fetch/connect failures — they'll trigger another
1452
+ // reassignmentRequired or a terminal error on their own.
1453
+ } finally {
1454
+ this._reassignmentInFlight = false;
1455
+ }
1456
+ }
1457
+
1458
+ /**
1459
+ * Tear down state before reconnecting on reassignment. Media is
1460
+ * unrecoverable (mediasoup Router died with the pod), so we clean up
1461
+ * transports, producers, consumers, local device tracks, and the old
1462
+ * Socket.IO handle. Consumer re-publishes camera/mic after the `reassigned`
1463
+ * event fires — matches the plan's "Media state lost (unavoidable)" stance
1464
+ * for pod death.
1465
+ * @private
1466
+ */
1467
+ async _teardownForReassignment() {
1468
+ try {
1469
+ if (this.localMedia) {
1470
+ await this.localMedia.cleanup();
1471
+ }
1472
+ } catch (err) {
1473
+ this.logger.warn('LocalMedia cleanup threw during reassignment', err);
1474
+ }
1475
+ try {
1476
+ if (this.mediasoup) {
1477
+ await this.mediasoup.cleanup();
1478
+ }
1479
+ } catch (err) {
1480
+ this.logger.warn('Mediasoup cleanup threw during reassignment', err);
1481
+ }
1482
+ try {
1483
+ if (this.remoteMedia) {
1484
+ await this.remoteMedia.cleanup();
1485
+ }
1486
+ } catch (err) {
1487
+ this.logger.warn('RemoteMedia cleanup threw during reassignment', err);
1488
+ }
1489
+ try {
1490
+ if (this.connection) {
1491
+ await this.connection.disconnect();
1492
+ }
1493
+ } catch (err) {
1494
+ this.logger.warn('Connection disconnect threw during reassignment', err);
1495
+ }
1496
+
1497
+ // Null out managers so _initializeManagers runs again with the new URL.
1498
+ this.connection = null;
1499
+ this.mediasoup = null;
1500
+ this.localMedia = null;
1501
+ this.remoteMedia = null;
1502
+
1503
+ if (this._reassignmentStableTimer) {
1504
+ clearTimeout(this._reassignmentStableTimer);
1505
+ this._reassignmentStableTimer = null;
1506
+ }
1507
+ }
1210
1508
  }
@@ -3,6 +3,11 @@ import { EventEmitter } from '../utils/EventEmitter.js';
3
3
  import { Logger } from '../utils/Logger.js';
4
4
  import { ConnectionError, TimeoutError } from '../utils/errors.js';
5
5
 
6
+ // Server-side rejection codes that mean "this token/room/pod assignment is
7
+ // stale and the client must re-fetch a fresh assignment via sdk.video.joinRoom".
8
+ // Emitted by app1-socket's authorizeVideoSocketConnection middleware.
9
+ const REASSIGNMENT_ERROR_CODES = new Set(['pod_reassign_required', 'room_mismatch']);
10
+
6
11
  /**
7
12
  * Manages Socket.io connection and signaling with the video server
8
13
  *
@@ -11,17 +16,26 @@ import { ConnectionError, TimeoutError } from '../utils/errors.js';
11
16
  * - 'disconnected' - Disconnected from server
12
17
  * - 'error' - Connection error
13
18
  * - 'message' - Received message from server
19
+ * - 'reassignmentRequired' - Server signaled the room's pod assignment is
20
+ * stale (pod_reassign_required or room_mismatch). Consumer (VideoMeetingClient)
21
+ * re-fetches joinRoom() and reconnects transparently.
14
22
  */
15
23
  export class ConnectionManager extends EventEmitter {
16
24
  /**
17
25
  * @param {Object} options
18
- * @param {string} options.serverUrl - WebSocket server URL
26
+ * @param {string} options.serverUrl - WebSocket server URL. If a namespace
27
+ * is needed (e.g. `/video`), pass it via `options.namespace` rather than
28
+ * embedding it in `serverUrl`.
29
+ * @param {string} [options.namespace] - Socket.IO namespace path, e.g.
30
+ * `/video`. Appended to `serverUrl` when constructing the io() call.
31
+ * Comes from the API's `connectionInfo.socket.namespace` response field.
19
32
  * @param {boolean} options.debug - Enable debug logging
20
33
  */
21
34
  constructor(options) {
22
35
  super();
23
36
 
24
37
  this.serverUrl = options.serverUrl;
38
+ this.namespace = options.namespace || null;
25
39
  this.logger = new Logger('SDK:ConnectionManager', options.debug);
26
40
  this.socket = null;
27
41
  this.isConnected = false;
@@ -44,22 +58,35 @@ export class ConnectionManager extends EventEmitter {
44
58
 
45
59
  return new Promise((resolve, reject) => {
46
60
  try {
47
- // Match the old working videoCreateSocket.js pattern exactly
48
- // Keep it simple: just withCredentials and auth
61
+ // Normalize the handshake auth field. The server-side middleware
62
+ // in app1-socket expects `accountNamespace` (disambiguates from
63
+ // Socket.IO's own `/video` namespace concept). Older callers may
64
+ // still pass the field as `namespace`; translate transparently.
65
+ const normalizedAuth = { ...auth };
66
+ if (normalizedAuth.namespace !== undefined && normalizedAuth.accountNamespace === undefined) {
67
+ normalizedAuth.accountNamespace = normalizedAuth.namespace;
68
+ delete normalizedAuth.namespace;
69
+ }
70
+
49
71
  const socketOptions = {
50
72
  withCredentials: true,
51
- auth,
73
+ auth: normalizedAuth,
52
74
  };
53
75
 
54
- console.log('ConnectionManager :: Auth object being sent:', auth);
55
- console.log('ConnectionManager :: Full socket options:', socketOptions);
56
-
57
- this.logger.info('Creating socket.io connection with options:', {
58
- serverUrl: this.serverUrl,
59
- ...socketOptions
76
+ // Build the connection URL. Socket.IO reads the namespace from
77
+ // the URL path, so for the `/video` namespace we append it to
78
+ // the base server URL.
79
+ const connectionUrl = this.namespace
80
+ ? `${this.serverUrl}${this.namespace}`
81
+ : this.serverUrl;
82
+
83
+ this.logger.info('Creating socket.io connection', {
84
+ connectionUrl,
85
+ namespace: this.namespace,
86
+ authKeys: Object.keys(normalizedAuth),
60
87
  });
61
88
 
62
- this.socket = io(this.serverUrl, socketOptions);
89
+ this.socket = io(connectionUrl, socketOptions);
63
90
 
64
91
  this.logger.info('Socket.io instance created, waiting for connect event');
65
92
 
@@ -79,7 +106,23 @@ export class ConnectionManager extends EventEmitter {
79
106
  });
80
107
 
81
108
  this.socket.on('connect_error', (error) => {
82
- this.logger.error('Connection error:', error);
109
+ // Socket.IO surfaces server-side middleware rejections as
110
+ // connect_error with the Error's message set to the code
111
+ // we called next(new Error(code)) with on the server.
112
+ const errorCode = error?.data?.code || error?.message;
113
+
114
+ this.logger.error('Connection error:', error, { errorCode });
115
+
116
+ if (REASSIGNMENT_ERROR_CODES.has(errorCode)) {
117
+ // Stop Socket.IO from auto-retrying — the token/pod
118
+ // assignment is stale and will reject every time.
119
+ // VideoMeetingClient handles the re-join flow.
120
+ this.socket?.disconnect();
121
+ this.emit('reassignmentRequired', { code: errorCode });
122
+ reject(new ConnectionError(`Reassignment required: ${errorCode}`, { code: errorCode }));
123
+ return;
124
+ }
125
+
83
126
  this.reconnectAttempts++;
84
127
 
85
128
  const connectionError = new ConnectionError(
@@ -36,6 +36,14 @@ export class MediasoupManager extends EventEmitter {
36
36
  // Initialize stats collector
37
37
  this.statsCollector = new StatsCollector(this.logger);
38
38
  this.virtualBackgroundStore = null; // Will be set externally
39
+
40
+ // Timestamps for tracking transport ICE failure duration. Used by
41
+ // recreateStaleTransports() on Socket.IO reconnect to decide whether
42
+ // a network blip was long enough (>10s) to kill WebRTC state, in
43
+ // which case we must recreate transports rather than expect ICE to
44
+ // recover on its own.
45
+ this._sendFailedAt = null;
46
+ this._recvFailedAt = null;
39
47
  }
40
48
 
41
49
  /**
@@ -253,6 +261,12 @@ export class MediasoupManager extends EventEmitter {
253
261
  transport.on('connectionstatechange', (state) => {
254
262
  this.logger.info('Send transport state:', state);
255
263
 
264
+ if (state === 'failed' || state === 'disconnected') {
265
+ if (!this._sendFailedAt) this._sendFailedAt = Date.now();
266
+ } else if (state === 'connected' || state === 'completed') {
267
+ this._sendFailedAt = null;
268
+ }
269
+
256
270
  if (state === 'failed' || state === 'closed') {
257
271
  this.emit('transport:closed', { direction: 'send', id: transport.id, state });
258
272
  }
@@ -303,12 +317,82 @@ export class MediasoupManager extends EventEmitter {
303
317
  transport.on('connectionstatechange', (state) => {
304
318
  this.logger.info('Receive transport state:', state);
305
319
 
320
+ if (state === 'failed' || state === 'disconnected') {
321
+ if (!this._recvFailedAt) this._recvFailedAt = Date.now();
322
+ } else if (state === 'connected' || state === 'completed') {
323
+ this._recvFailedAt = null;
324
+ }
325
+
306
326
  if (state === 'failed' || state === 'closed') {
307
327
  this.emit('transport:closed', { direction: 'recv', id: transport.id, state });
308
328
  }
309
329
  });
310
330
  }
311
331
 
332
+ /**
333
+ * After Socket.IO reconnects, check whether the underlying WebRTC
334
+ * transports have been in a `failed`/`disconnected` state long enough
335
+ * (>10s) that ICE won't recover on its own. If so, close both transports
336
+ * and signal the consumer to re-run the `room.join` flow (which yields
337
+ * fresh routerCapabilities + transport options + producer list from the
338
+ * server).
339
+ *
340
+ * Without this, a long network blip leaves the client with a live
341
+ * Socket.IO connection but dead WebRTC media — audio and video look
342
+ * broken to the user even though signaling is fine.
343
+ *
344
+ * @returns {boolean} true if transports were recreated
345
+ */
346
+ async recreateStaleTransports() {
347
+ const STALE_THRESHOLD_MS = 10_000;
348
+ const now = Date.now();
349
+
350
+ const sendStale = this._sendFailedAt && (now - this._sendFailedAt) > STALE_THRESHOLD_MS;
351
+ const recvStale = this._recvFailedAt && (now - this._recvFailedAt) > STALE_THRESHOLD_MS;
352
+
353
+ if (!sendStale && !recvStale) {
354
+ return false;
355
+ }
356
+
357
+ this.logger.warn('Transports stale after reconnect — recreating', {
358
+ sendFailedAt: this._sendFailedAt,
359
+ recvFailedAt: this._recvFailedAt,
360
+ now,
361
+ });
362
+
363
+ // Stop stats; close producers, consumers, transports.
364
+ this.statsCollector.stopAll();
365
+
366
+ for (const [type, producer] of this.producers) {
367
+ try { producer.close(); } catch (err) { this.logger.error('closing producer', type, err); }
368
+ }
369
+ this.producers.clear();
370
+
371
+ for (const [id, consumer] of this.consumers) {
372
+ try { consumer.close(); } catch (err) { this.logger.error('closing consumer', id, err); }
373
+ }
374
+ this.consumers.clear();
375
+
376
+ if (this.sendTransport) {
377
+ try { this.sendTransport.close(); } catch (err) { this.logger.error('closing sendTransport', err); }
378
+ this.sendTransport = null;
379
+ }
380
+ if (this.recvTransport) {
381
+ try { this.recvTransport.close(); } catch (err) { this.logger.error('closing recvTransport', err); }
382
+ this.recvTransport = null;
383
+ }
384
+
385
+ this._sendFailedAt = null;
386
+ this._recvFailedAt = null;
387
+
388
+ // Consumer (VideoMeetingClient) reacts to this and drives the
389
+ // re-join — it owns the room.join emission + the _transportsPromise
390
+ // wiring.
391
+ this.emit('transports:recreated-needed');
392
+
393
+ return true;
394
+ }
395
+
312
396
  /**
313
397
  * Produce media (send to server)
314
398
  * @param {MediaStreamTrack} track - Media track to produce
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unboundcx/video-sdk-client",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "description": "Framework-agnostic WebRTC video meeting SDK powered by mediasoup",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -11,7 +11,7 @@
11
11
  "./utils/*": "./utils/*.js"
12
12
  },
13
13
  "scripts": {
14
- "test": "echo \"Error: no test specified\" && exit 1"
14
+ "test": "node --test 'test/*.test.js'"
15
15
  },
16
16
  "keywords": [
17
17
  "webrtc",