@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.
- package/VideoMeetingClient.js +323 -25
- package/managers/ConnectionManager.js +55 -12
- package/managers/MediasoupManager.js +84 -0
- package/package.json +2 -2
package/VideoMeetingClient.js
CHANGED
|
@@ -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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
312
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
//
|
|
48
|
-
//
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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(
|
|
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
|
-
|
|
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": "
|
|
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": "
|
|
14
|
+
"test": "node --test 'test/*.test.js'"
|
|
15
15
|
},
|
|
16
16
|
"keywords": [
|
|
17
17
|
"webrtc",
|