livekit-client 2.20.1 → 2.20.2

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 (74) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +33 -7
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +589 -163
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +33 -7
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts.map +1 -1
  14. package/dist/src/logger.d.ts +3 -1
  15. package/dist/src/logger.d.ts.map +1 -1
  16. package/dist/src/room/PCTransport.d.ts +2 -0
  17. package/dist/src/room/PCTransport.d.ts.map +1 -1
  18. package/dist/src/room/PCTransportManager.d.ts +1 -0
  19. package/dist/src/room/PCTransportManager.d.ts.map +1 -1
  20. package/dist/src/room/RTCEngine.d.ts +45 -4
  21. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  22. package/dist/src/room/RegionUrlProvider.d.ts.map +1 -1
  23. package/dist/src/room/Room.d.ts.map +1 -1
  24. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  25. package/dist/src/room/data-track/incoming/IncomingDataTrackManager.d.ts.map +1 -1
  26. package/dist/src/room/data-track/outgoing/OutgoingDataTrackManager.d.ts.map +1 -1
  27. package/dist/src/room/participant/LocalParticipant.d.ts.map +1 -1
  28. package/dist/src/room/participant/Participant.d.ts.map +1 -1
  29. package/dist/src/room/participant/publishUtils.d.ts +7 -0
  30. package/dist/src/room/participant/publishUtils.d.ts.map +1 -1
  31. package/dist/src/room/track/LocalAudioTrack.d.ts +1 -0
  32. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  33. package/dist/src/room/track/Track.d.ts.map +1 -1
  34. package/dist/src/room/track/options.d.ts +11 -1
  35. package/dist/src/room/track/options.d.ts.map +1 -1
  36. package/dist/src/test/signalToken.d.ts +26 -0
  37. package/dist/src/test/signalToken.d.ts.map +1 -0
  38. package/dist/src/utils/dataPacketBuffer.d.ts +18 -0
  39. package/dist/src/utils/dataPacketBuffer.d.ts.map +1 -1
  40. package/dist/ts4.2/logger.d.ts +3 -1
  41. package/dist/ts4.2/room/PCTransport.d.ts +2 -0
  42. package/dist/ts4.2/room/PCTransportManager.d.ts +1 -0
  43. package/dist/ts4.2/room/RTCEngine.d.ts +45 -4
  44. package/dist/ts4.2/room/data-track/incoming/IncomingDataTrackManager.d.ts +7 -0
  45. package/dist/ts4.2/room/participant/publishUtils.d.ts +7 -0
  46. package/dist/ts4.2/room/track/LocalAudioTrack.d.ts +1 -0
  47. package/dist/ts4.2/room/track/options.d.ts +11 -1
  48. package/dist/ts4.2/test/signalToken.d.ts +26 -0
  49. package/dist/ts4.2/utils/dataPacketBuffer.d.ts +18 -0
  50. package/package.json +7 -3
  51. package/src/api/SignalClient.e2e.test.ts +264 -0
  52. package/src/api/SignalClient.ts +34 -2
  53. package/src/logger.ts +2 -0
  54. package/src/room/PCTransport.ts +66 -31
  55. package/src/room/PCTransportManager.ts +10 -6
  56. package/src/room/RTCEngine.test.ts +275 -2
  57. package/src/room/RTCEngine.ts +276 -71
  58. package/src/room/RegionUrlProvider.ts +4 -2
  59. package/src/room/Room.ts +7 -1
  60. package/src/room/data-track/incoming/IncomingDataTrackManager.test.ts +303 -0
  61. package/src/room/data-track/incoming/IncomingDataTrackManager.ts +70 -8
  62. package/src/room/data-track/outgoing/OutgoingDataTrackManager.ts +2 -0
  63. package/src/room/errors.ts +4 -4
  64. package/src/room/participant/LocalParticipant.ts +16 -6
  65. package/src/room/participant/Participant.ts +5 -0
  66. package/src/room/participant/publishUtils.ts +14 -11
  67. package/src/room/track/LocalAudioTrack.ts +16 -0
  68. package/src/room/track/Track.ts +10 -6
  69. package/src/room/track/options.ts +11 -1
  70. package/src/test/signalServerSetup.ts +151 -0
  71. package/src/test/signalToken.ts +59 -0
  72. package/src/test/vitest-context.d.ts +10 -0
  73. package/src/utils/dataPacketBuffer.test.ts +100 -0
  74. package/src/utils/dataPacketBuffer.ts +46 -1
@@ -86,6 +86,37 @@ describe('DataTrackIncomingManager', () => {
86
86
  DataTrackHandle.fromNumber(5),
87
87
  ]);
88
88
  });
89
+
90
+ it('should not republish when a track sid is reassigned', async () => {
91
+ const manager = new IncomingDataTrackManager();
92
+ const managerEvents = subscribeToEvents<DataTrackIncomingManagerCallbacks>(manager, [
93
+ 'sfuUpdateSubscription',
94
+ 'trackPublished',
95
+ 'trackUnpublished',
96
+ ]);
97
+
98
+ const pubHandle = DataTrackHandle.fromNumber(5);
99
+ const oldSid = 'old sid';
100
+ const newSid = 'new sid';
101
+
102
+ // Simulate track published
103
+ await manager.receiveSfuPublicationUpdates(
104
+ new Map([['id', [{ sid: oldSid, pubHandle, name: 'test', usesE2ee: false }]]]),
105
+ );
106
+
107
+ const { track } = await managerEvents.waitFor('trackPublished');
108
+ expect(track.info.sid).toStrictEqual(oldSid);
109
+
110
+ // Simulate publisher full reconnect: same track, new SID
111
+ await manager.receiveSfuPublicationUpdates(
112
+ new Map([['id', [{ sid: newSid, pubHandle, name: 'test', usesE2ee: false }]]]),
113
+ );
114
+
115
+ // No publish/unpublish should appear
116
+ expect(managerEvents.areThereBufferedEvents('trackPublished')).toStrictEqual(false);
117
+ expect(managerEvents.areThereBufferedEvents('trackUnpublished')).toStrictEqual(false);
118
+ expect(track.info.sid).toStrictEqual(newSid);
119
+ });
89
120
  });
90
121
 
91
122
  describe('Track subscription', () => {
@@ -1064,6 +1095,278 @@ describe('DataTrackIncomingManager', () => {
1064
1095
  expect(onlyFrame.done).toStrictEqual(false);
1065
1096
  expect(onlyFrame.value?.payload).toStrictEqual(new Uint8Array([0xb1, 0xb2]));
1066
1097
  });
1098
+
1099
+ it('should resubscribe an active subscription when a track sid is reassigned', async () => {
1100
+ const manager = new IncomingDataTrackManager();
1101
+ const managerEvents = subscribeToEvents<DataTrackIncomingManagerCallbacks>(manager, [
1102
+ 'sfuUpdateSubscription',
1103
+ 'trackPublished',
1104
+ ]);
1105
+
1106
+ const pubHandle = DataTrackHandle.fromNumber(5);
1107
+ const oldSid = 'old sid';
1108
+ const newSid = 'new sid';
1109
+ const oldSubHandle = DataTrackHandle.fromNumber(6);
1110
+ const newSubHandle = DataTrackHandle.fromNumber(7);
1111
+
1112
+ // Simulate track published
1113
+ await manager.receiveSfuPublicationUpdates(
1114
+ new Map([['id', [{ sid: oldSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1115
+ );
1116
+ const { track } = await managerEvents.waitFor('trackPublished');
1117
+
1118
+ // Subscribe to the track
1119
+ const [stream, sfuSubscriptionComplete] = manager.openSubscriptionStream(oldSid);
1120
+ const reader = stream.getReader();
1121
+ await managerEvents.waitFor('sfuUpdateSubscription');
1122
+
1123
+ // Simulate SFU assigning subscriber handle
1124
+ manager.receivedSfuSubscriberHandles(new Map([[oldSubHandle, oldSid]]));
1125
+
1126
+ await sfuSubscriptionComplete;
1127
+
1128
+ // Simulate publisher full reconnect: same track, new SID
1129
+ await manager.receiveSfuPublicationUpdates(
1130
+ new Map([['id', [{ sid: newSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1131
+ );
1132
+
1133
+ // Manager should re-subscribe under the new SID
1134
+ const event = await managerEvents.waitFor('sfuUpdateSubscription');
1135
+ expect(event.subscribe).toStrictEqual(true);
1136
+ expect(event.sid).toStrictEqual(newSid);
1137
+ expect(track.info.sid).toStrictEqual(newSid);
1138
+
1139
+ // Simulate SFU assigning a new subscriber handle
1140
+ manager.receivedSfuSubscriberHandles(new Map([[newSubHandle, newSid]]));
1141
+
1142
+ // Frames received on the new handle reach the existing subscriber
1143
+ manager.packetReceived(
1144
+ new DataTrackPacket(
1145
+ new DataTrackPacketHeader({
1146
+ extensions: new DataTrackExtensions(),
1147
+ frameNumber: WrapAroundUnsignedInt.u16(0),
1148
+ marker: FrameMarker.Single,
1149
+ sequence: WrapAroundUnsignedInt.u16(0),
1150
+ timestamp: DataTrackTimestamp.fromRtpTicks(0),
1151
+ trackHandle: newSubHandle,
1152
+ }),
1153
+ new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]),
1154
+ ).toBinary(),
1155
+ );
1156
+
1157
+ const { value, done } = await reader.read();
1158
+ expect(done).toStrictEqual(false);
1159
+ expect(value?.payload).toStrictEqual(new Uint8Array([0x01, 0x02, 0x03, 0x04, 0x05]));
1160
+ });
1161
+
1162
+ it('should stop routing packets on the old handle once a new handle is assigned', async () => {
1163
+ const manager = new IncomingDataTrackManager();
1164
+ const managerEvents = subscribeToEvents<DataTrackIncomingManagerCallbacks>(manager, [
1165
+ 'sfuUpdateSubscription',
1166
+ 'trackPublished',
1167
+ ]);
1168
+
1169
+ const pubHandle = DataTrackHandle.fromNumber(5);
1170
+ const oldSid = 'old sid';
1171
+ const newSid = 'new sid';
1172
+ const oldSubHandle = DataTrackHandle.fromNumber(6);
1173
+ const newSubHandle = DataTrackHandle.fromNumber(7);
1174
+
1175
+ // Simulate track published
1176
+ await manager.receiveSfuPublicationUpdates(
1177
+ new Map([['id', [{ sid: oldSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1178
+ );
1179
+ await managerEvents.waitFor('trackPublished');
1180
+
1181
+ // Subscribe to the track
1182
+ const [stream, sfuSubscriptionComplete] = manager.openSubscriptionStream(oldSid);
1183
+ const reader = stream.getReader();
1184
+ await managerEvents.waitFor('sfuUpdateSubscription');
1185
+
1186
+ // Simulate SFU assigning subscriber handle
1187
+ manager.receivedSfuSubscriberHandles(new Map([[oldSubHandle, oldSid]]));
1188
+ await sfuSubscriptionComplete;
1189
+
1190
+ // Simulate publisher full reconnect: same track, new SID
1191
+ await manager.receiveSfuPublicationUpdates(
1192
+ new Map([['id', [{ sid: newSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1193
+ );
1194
+ await managerEvents.waitFor('sfuUpdateSubscription');
1195
+
1196
+ // Simulate SFU assigning a new subscriber handle
1197
+ manager.receivedSfuSubscriberHandles(new Map([[newSubHandle, newSid]]));
1198
+
1199
+ const buildPacket = (trackHandle: DataTrackHandle, frameNumber: number, payload: number[]) =>
1200
+ new DataTrackPacket(
1201
+ new DataTrackPacketHeader({
1202
+ extensions: new DataTrackExtensions(),
1203
+ frameNumber: WrapAroundUnsignedInt.u16(frameNumber),
1204
+ marker: FrameMarker.Single,
1205
+ sequence: WrapAroundUnsignedInt.u16(frameNumber),
1206
+ timestamp: DataTrackTimestamp.fromRtpTicks(0),
1207
+ trackHandle,
1208
+ }),
1209
+ new Uint8Array(payload),
1210
+ ).toBinary();
1211
+
1212
+ // A packet received on the old handle must be dropped, not routed to the subscriber
1213
+ await manager.packetReceived(buildPacket(oldSubHandle, 0, [0xaa, 0xbb]));
1214
+
1215
+ // A packet received on the new handle reaches the subscriber
1216
+ await manager.packetReceived(buildPacket(newSubHandle, 1, [0x01, 0x02]));
1217
+
1218
+ const { value, done } = await reader.read();
1219
+ expect(done).toStrictEqual(false);
1220
+ expect(value?.payload).toStrictEqual(new Uint8Array([0x01, 0x02]));
1221
+ });
1222
+
1223
+ it('should terminate the sfu subscription when the stream is cancelled after a sid reassignment', async () => {
1224
+ const manager = new IncomingDataTrackManager();
1225
+ const managerEvents = subscribeToEvents<DataTrackIncomingManagerCallbacks>(manager, [
1226
+ 'sfuUpdateSubscription',
1227
+ 'trackPublished',
1228
+ ]);
1229
+
1230
+ const pubHandle = DataTrackHandle.fromNumber(5);
1231
+ const oldSid = 'old sid';
1232
+ const newSid = 'new sid';
1233
+ const oldSubHandle = DataTrackHandle.fromNumber(6);
1234
+ const newSubHandle = DataTrackHandle.fromNumber(7);
1235
+
1236
+ // Simulate track published
1237
+ await manager.receiveSfuPublicationUpdates(
1238
+ new Map([['id', [{ sid: oldSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1239
+ );
1240
+ await managerEvents.waitFor('trackPublished');
1241
+
1242
+ // Subscribe to the track
1243
+ const [stream, sfuSubscriptionComplete] = manager.openSubscriptionStream(oldSid);
1244
+ const reader = stream.getReader();
1245
+ await managerEvents.waitFor('sfuUpdateSubscription');
1246
+
1247
+ // Simulate SFU assigning subscriber handle
1248
+ manager.receivedSfuSubscriberHandles(new Map([[oldSubHandle, oldSid]]));
1249
+ await sfuSubscriptionComplete;
1250
+
1251
+ // Simulate publisher full reconnect: same track, new SID
1252
+ await manager.receiveSfuPublicationUpdates(
1253
+ new Map([['id', [{ sid: newSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1254
+ );
1255
+
1256
+ // Manager should re-subscribe under the new SID
1257
+ const resubscribeEvent = await managerEvents.waitFor('sfuUpdateSubscription');
1258
+ expect(resubscribeEvent.subscribe).toStrictEqual(true);
1259
+ expect(resubscribeEvent.sid).toStrictEqual(newSid);
1260
+
1261
+ // Simulate SFU assigning a new subscriber handle
1262
+ manager.receivedSfuSubscriberHandles(new Map([[newSubHandle, newSid]]));
1263
+
1264
+ // Cancel the stream; the SFU unsubscribe must go out under the new SID
1265
+ await reader.cancel();
1266
+ const endEvent = await managerEvents.waitFor('sfuUpdateSubscription');
1267
+ expect(endEvent.sid).toStrictEqual(newSid);
1268
+ expect(endEvent.subscribe).toStrictEqual(false);
1269
+ });
1270
+
1271
+ it('should terminate the sfu subscription when the abortsignal fires after a sid reassignment', async () => {
1272
+ const manager = new IncomingDataTrackManager();
1273
+ const managerEvents = subscribeToEvents<DataTrackIncomingManagerCallbacks>(manager, [
1274
+ 'sfuUpdateSubscription',
1275
+ 'trackPublished',
1276
+ ]);
1277
+
1278
+ const pubHandle = DataTrackHandle.fromNumber(5);
1279
+ const oldSid = 'old sid';
1280
+ const newSid = 'new sid';
1281
+ const oldSubHandle = DataTrackHandle.fromNumber(6);
1282
+ const newSubHandle = DataTrackHandle.fromNumber(7);
1283
+
1284
+ // Simulate track published
1285
+ await manager.receiveSfuPublicationUpdates(
1286
+ new Map([['id', [{ sid: oldSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1287
+ );
1288
+ await managerEvents.waitFor('trackPublished');
1289
+
1290
+ // Subscribe to the track
1291
+ const controller = new AbortController();
1292
+ const [stream, sfuSubscriptionComplete] = manager.openSubscriptionStream(
1293
+ oldSid,
1294
+ controller.signal,
1295
+ );
1296
+ const reader = stream.getReader();
1297
+ await managerEvents.waitFor('sfuUpdateSubscription');
1298
+
1299
+ // Simulate SFU assigning subscriber handle
1300
+ manager.receivedSfuSubscriberHandles(new Map([[oldSubHandle, oldSid]]));
1301
+ await sfuSubscriptionComplete;
1302
+
1303
+ // Simulate publisher full reconnect: same track, new SID
1304
+ await manager.receiveSfuPublicationUpdates(
1305
+ new Map([['id', [{ sid: newSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1306
+ );
1307
+
1308
+ // Manager should re-subscribe under the new SID
1309
+ const resubscribeEvent = await managerEvents.waitFor('sfuUpdateSubscription');
1310
+ expect(resubscribeEvent.subscribe).toStrictEqual(true);
1311
+ expect(resubscribeEvent.sid).toStrictEqual(newSid);
1312
+
1313
+ // Simulate SFU assigning a new subscriber handle
1314
+ manager.receivedSfuSubscriberHandles(new Map([[newSubHandle, newSid]]));
1315
+
1316
+ // Abort the subscription; the SFU unsubscribe must go out under the new SID
1317
+ const inFlightReadPromise = reader.read();
1318
+ controller.abort();
1319
+ await expect(inFlightReadPromise).rejects.toThrowError(
1320
+ 'Subscription to data track cancelled by caller',
1321
+ );
1322
+ const endEvent = await managerEvents.waitFor('sfuUpdateSubscription');
1323
+ expect(endEvent.sid).toStrictEqual(newSid);
1324
+ expect(endEvent.subscribe).toStrictEqual(false);
1325
+ });
1326
+
1327
+ it('should cancel a pending subscription under the new sid after a sid reassignment', async () => {
1328
+ const manager = new IncomingDataTrackManager();
1329
+ const managerEvents = subscribeToEvents<DataTrackIncomingManagerCallbacks>(manager, [
1330
+ 'sfuUpdateSubscription',
1331
+ 'trackPublished',
1332
+ ]);
1333
+
1334
+ const pubHandle = DataTrackHandle.fromNumber(5);
1335
+ const oldSid = 'old sid';
1336
+ const newSid = 'new sid';
1337
+
1338
+ // Simulate track published
1339
+ await manager.receiveSfuPublicationUpdates(
1340
+ new Map([['id', [{ sid: oldSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1341
+ );
1342
+ await managerEvents.waitFor('trackPublished');
1343
+
1344
+ // Begin subscribing to the track, leaving the subscription pending
1345
+ const controller = new AbortController();
1346
+ const [, sfuSubscriptionComplete] = manager.openSubscriptionStream(oldSid, controller.signal);
1347
+ const startEvent = await managerEvents.waitFor('sfuUpdateSubscription');
1348
+ expect(startEvent.sid).toStrictEqual(oldSid);
1349
+ expect(startEvent.subscribe).toStrictEqual(true);
1350
+
1351
+ // Simulate publisher full reconnect: same track, new SID
1352
+ await manager.receiveSfuPublicationUpdates(
1353
+ new Map([['id', [{ sid: newSid, pubHandle, name: 'test', usesE2ee: false }]]]),
1354
+ );
1355
+
1356
+ // Manager should re-subscribe under the new SID
1357
+ const resubscribeEvent = await managerEvents.waitFor('sfuUpdateSubscription');
1358
+ expect(resubscribeEvent.subscribe).toStrictEqual(true);
1359
+ expect(resubscribeEvent.sid).toStrictEqual(newSid);
1360
+
1361
+ // Abort the pending subscription; the SFU unsubscribe must go out under the new SID
1362
+ controller.abort();
1363
+ await expect(sfuSubscriptionComplete).rejects.toStrictEqual(
1364
+ DataTrackSubscribeError.cancelled(),
1365
+ );
1366
+ const endEvent = await managerEvents.waitFor('sfuUpdateSubscription');
1367
+ expect(endEvent.sid).toStrictEqual(newSid);
1368
+ expect(endEvent.subscribe).toStrictEqual(false);
1369
+ });
1067
1370
  });
1068
1371
  });
1069
1372
 
@@ -147,6 +147,10 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
147
147
  let streamController: ReadableStreamDefaultController<DataTrackFrame> | null = null;
148
148
  const sfuSubscriptionComplete = new Future<void, DataTrackSubscribeError>();
149
149
 
150
+ // Hold the descriptor by reference: SID reassignment re-keys the map, so lookups
151
+ // with the SID captured at subscribe time would fail.
152
+ const descriptor = this.descriptors.get(sid);
153
+
150
154
  const detachSignal = () => {
151
155
  signal?.removeEventListener('abort', onAbort);
152
156
  };
@@ -158,8 +162,7 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
158
162
  log.warn(`ReadableStream subscribed to ${sid} was not started.`);
159
163
  return;
160
164
  }
161
- const descriptor = this.descriptors.get(sid);
162
- if (!descriptor) {
165
+ if (!descriptor || this.descriptors.get(descriptor.info.sid) !== descriptor) {
163
166
  log.warn(`Unknown track ${sid}, skipping cancel...`);
164
167
  return;
165
168
  }
@@ -180,9 +183,8 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
180
183
  if (!streamController) {
181
184
  return;
182
185
  }
183
- const currentDescriptor = this.descriptors.get(sid);
184
- if (currentDescriptor?.subscription.type === 'active') {
185
- currentDescriptor.subscription.streamControllers.delete(streamController);
186
+ if (descriptor?.subscription.type === 'active') {
187
+ descriptor.subscription.streamControllers.delete(streamController);
186
188
  }
187
189
 
188
190
  streamController.error(DataTrackSubscribeError.cancelled());
@@ -198,8 +200,7 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
198
200
 
199
201
  this.subscribeRequest(sid, signal)
200
202
  .then(async () => {
201
- const descriptor = this.descriptors.get(sid);
202
- if (!descriptor) {
203
+ if (!descriptor || this.descriptors.get(descriptor.info.sid) !== descriptor) {
203
204
  log.error(`Unknown track ${sid}`);
204
205
  const err = DataTrackSubscribeError.disconnected();
205
206
  controller.error(err);
@@ -330,7 +331,7 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
330
331
  descriptor.subscription = { type: 'none' };
331
332
 
332
333
  // Let the SFU know that the subscribe has been cancelled
333
- this.emit('sfuUpdateSubscription', { sid, subscribe: false });
334
+ this.emit('sfuUpdateSubscription', { sid: descriptor.info.sid, subscribe: false });
334
335
 
335
336
  if (previousDescriptorSubscription.type === 'pending') {
336
337
  previousDescriptorSubscription.completionFuture.reject?.(
@@ -458,6 +459,9 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
458
459
  if (this.descriptors.has(info.sid)) {
459
460
  continue;
460
461
  }
462
+ if (this.handleSidReassigned(publisherIdentity, info)) {
463
+ continue;
464
+ }
461
465
  await this.handleTrackPublished(publisherIdentity, info);
462
466
  }
463
467
  publisherParticipantToSidsInUpdate.set(publisherIdentity, sidsInUpdate);
@@ -499,6 +503,62 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
499
503
  this.emit('trackPublished', { track });
500
504
  }
501
505
 
506
+ /**
507
+ * Detects and handles SID reassignment, which occurs when the publisher
508
+ * republishes its tracks after a full reconnect.
509
+ *
510
+ * Returns `true` if an SID reassignment occurred, `false` otherwise.
511
+ */
512
+ private handleSidReassigned(
513
+ publisherIdentity: Participant['identity'],
514
+ info: DataTrackInfo,
515
+ ): boolean {
516
+ // Publisher identity and pub handle are stable across republications.
517
+ const existingEntry = Array.from(this.descriptors.entries()).find(
518
+ ([_sid, descriptor]) =>
519
+ descriptor.publisherIdentity === publisherIdentity &&
520
+ descriptor.info.pubHandle === info.pubHandle,
521
+ );
522
+ if (!existingEntry) {
523
+ return false;
524
+ }
525
+ const [oldSid, descriptor] = existingEntry;
526
+
527
+ // Invariant: other than SID, info should not have changed.
528
+ // TODO: consider refactoring to move SID out of info to allow for direct comparison.
529
+ const { name, usesE2ee } = descriptor.info;
530
+ if (name !== info.name || usesE2ee !== info.usesE2ee) {
531
+ log.warn(`Info mismatch for ${oldSid}, treating as new publication`);
532
+ return false;
533
+ }
534
+
535
+ const newSid = info.sid;
536
+ log.debug(`SID reassigned: ${oldSid} -> ${newSid}`);
537
+
538
+ if (!this.descriptors.delete(oldSid)) {
539
+ return false;
540
+ }
541
+ descriptor.info.sid = newSid;
542
+
543
+ switch (descriptor.subscription.type) {
544
+ case 'none':
545
+ break;
546
+ case 'pending':
547
+ case 'active':
548
+ // The SFU does not carry subscriptions across a publisher's full
549
+ // reconnect; re-request the subscription under the new SID.
550
+ this.emit('sfuUpdateSubscription', { sid: newSid, subscribe: true });
551
+ break;
552
+ }
553
+ if (descriptor.subscription.type === 'active') {
554
+ // Keep the routing index consistent until the SFU assigns a new handle
555
+ // (see `registerSubscriberHandle`).
556
+ this.subscriptionHandles.set(descriptor.subscription.subcriptionHandle, newSid);
557
+ }
558
+ this.descriptors.set(newSid, descriptor);
559
+ return true;
560
+ }
561
+
502
562
  handleTrackUnpublished(sid: DataTrackSid) {
503
563
  const descriptor = this.descriptors.get(sid);
504
564
  if (!descriptor) {
@@ -540,11 +600,13 @@ export default class IncomingDataTrackManager extends (EventEmitter as new () =>
540
600
  }
541
601
  case 'active': {
542
602
  // Update handle for an active subscription. This can occur following a full reconnect.
603
+ this.subscriptionHandles.delete(descriptor.subscription.subcriptionHandle);
543
604
  descriptor.subscription.subcriptionHandle = assignedHandle;
544
605
  this.subscriptionHandles.set(assignedHandle, sid);
545
606
  return;
546
607
  }
547
608
  case 'pending': {
609
+ log.debug(`data track subscription activated`, { sid, handle: assignedHandle });
548
610
  const pipeline = new IncomingDataTrackPipeline({
549
611
  info: descriptor.info,
550
612
  publisherIdentity: descriptor.publisherIdentity,
@@ -326,11 +326,13 @@ export default class OutgoingDataTrackManager extends (EventEmitter as new () =>
326
326
  case 'pending': {
327
327
  if (result.type === 'ok') {
328
328
  const info = result.data;
329
+ log.debug(`SFU accepted publish request for handle ${handle}`, { sid: info.sid });
329
330
  const e2eeManager = info.usesE2ee ? this.e2eeManager : null;
330
331
  this.descriptors.set(info.pubHandle, Descriptor.active(info, e2eeManager));
331
332
 
332
333
  descriptor.completionFuture.resolve?.();
333
334
  } else {
335
+ log.debug(`SFU rejected publish request for handle ${handle}`, { error: result.error });
334
336
  descriptor.completionFuture.reject?.(result.error);
335
337
  }
336
338
  return;
@@ -208,7 +208,7 @@ export class UnsupportedServer extends LivekitError {
208
208
  readonly name = 'UnsupportedServer';
209
209
 
210
210
  constructor(message?: string) {
211
- super(10, message ?? 'unsupported server');
211
+ super(10, message || 'unsupported server');
212
212
  }
213
213
  }
214
214
 
@@ -216,7 +216,7 @@ export class UnexpectedConnectionState extends LivekitError {
216
216
  readonly name = 'UnexpectedConnectionState';
217
217
 
218
218
  constructor(message?: string) {
219
- super(12, message ?? 'unexpected connection state');
219
+ super(12, message || 'unexpected connection state');
220
220
  }
221
221
  }
222
222
 
@@ -224,7 +224,7 @@ export class NegotiationError extends LivekitError {
224
224
  readonly name = 'NegotiationError';
225
225
 
226
226
  constructor(message?: string) {
227
- super(13, message ?? 'unable to negotiate');
227
+ super(13, message || 'unable to negotiate');
228
228
  }
229
229
  }
230
230
 
@@ -232,7 +232,7 @@ export class PublishDataError extends LivekitError {
232
232
  readonly name = 'PublishDataError';
233
233
 
234
234
  constructor(message?: string) {
235
- super(14, message ?? 'unable to publish data');
235
+ super(14, message || 'unable to publish data');
236
236
  }
237
237
  }
238
238
 
@@ -100,6 +100,7 @@ import {
100
100
  isLocalVideoTrack,
101
101
  isSVCCodec,
102
102
  isSafari17Based,
103
+ isVideoCodec,
103
104
  isVideoTrack,
104
105
  isWeb,
105
106
  sleep,
@@ -1249,12 +1250,21 @@ export default class LocalParticipant extends Participant {
1249
1250
  maxbr: encodings[0]?.maxBitrate ? encodings[0].maxBitrate / 1000 : 0,
1250
1251
  });
1251
1252
  }
1252
- } else if (track.codec && isSVCCodec(track.codec) && encodings[0]?.maxBitrate) {
1253
- this.engine.pcManager.publisher.setTrackCodecBitrate({
1254
- cid: req.cid,
1255
- codec: track.codec,
1256
- maxbr: encodings[0].maxBitrate / 1000,
1257
- });
1253
+ } else if (track.codec && isVideoCodec(track.codec)) {
1254
+ // Apply start bitrate for all video codecs to prevent initial blurriness.
1255
+ // - SVC codecs: use first encoding's bitrate (single stream with built-in layers)
1256
+ // - Simulcast: sum all encoding bitrates (independent streams, BWE needs total)
1257
+ const targetBitrate = isSVCCodec(track.codec)
1258
+ ? (encodings[0]?.maxBitrate ?? 0)
1259
+ : encodings.reduce((sum, enc) => sum + (enc.maxBitrate ?? 0), 0);
1260
+ if (targetBitrate > 0) {
1261
+ this.engine.pcManager.publisher.setTrackCodecBitrate({
1262
+ cid: req.cid,
1263
+ codec: track.codec,
1264
+ maxbr: targetBitrate / 1000,
1265
+ isScreenShare: track.source === Track.Source.ScreenShare,
1266
+ });
1267
+ }
1258
1268
  }
1259
1269
  }
1260
1270
 
@@ -371,6 +371,11 @@ export default class Participant extends (EventEmitter as new () => TypedEmitter
371
371
  }
372
372
 
373
373
  protected addTrackPublication(publication: TrackPublication) {
374
+ this.log.debug(`adding track publication`, {
375
+ trackSid: publication.trackSid,
376
+ source: publication.source,
377
+ kind: publication.kind,
378
+ });
374
379
  // forward publication driven events
375
380
  publication.on(TrackEvent.Muted, () => {
376
381
  this.emit(ParticipantEvent.TrackMuted, publication);
@@ -19,7 +19,6 @@ import {
19
19
  isSVCCodec,
20
20
  isSafariBased,
21
21
  isSafariSvcApi,
22
- unwrapConstraint,
23
22
  } from '../utils';
24
23
 
25
24
  /** @internal */
@@ -468,16 +467,20 @@ export class ScalabilityMode {
468
467
  }
469
468
  }
470
469
 
470
+ /**
471
+ * Returns the appropriate degradation preference for a video track based on its source.
472
+ *
473
+ * - Camera: 'maintain-framerate' (smoother video for real-time communication)
474
+ * - Screen share: 'maintain-resolution' (clarity is critical for reading text/UI)
475
+ * - Other/unknown: 'balanced'
476
+ */
471
477
  export function getDefaultDegradationPreference(track: LocalVideoTrack): RTCDegradationPreference {
472
- // a few of reasons we have different default paths:
473
- // 1. without this, Chrome seems to aggressively resize the SVC video stating `quality-limitation: bandwidth` even when BW isn't an issue
474
- // 2. since we are overriding contentHint to motion (to workaround L1T3 publishing), it overrides the default degradationPreference to `balanced`
475
- if (
476
- track.source === Track.Source.ScreenShare ||
477
- (track.constraints.height && unwrapConstraint(track.constraints.height) >= 1080)
478
- ) {
479
- return 'maintain-resolution';
480
- } else {
481
- return 'balanced';
478
+ switch (track.source) {
479
+ case Track.Source.Camera:
480
+ return 'maintain-framerate';
481
+ case Track.Source.ScreenShare:
482
+ return 'maintain-resolution';
483
+ default:
484
+ return 'balanced';
482
485
  }
483
486
  }
@@ -103,6 +103,22 @@ export default class LocalAudioTrack extends LocalTrack<Track.Kind.Audio> {
103
103
  await this.restart(constraints);
104
104
  }
105
105
 
106
+ async applyConstraints(
107
+ constraints: Pick<
108
+ AudioCaptureOptions,
109
+ 'autoGainControl' | 'noiseSuppression' | 'echoCancellation' | 'voiceIsolation'
110
+ >,
111
+ ): Promise<void> {
112
+ const unlock = await this.trackChangeLock.lock();
113
+ try {
114
+ const res = await this._mediaStreamTrack.applyConstraints(constraints);
115
+ this._constraints = { ...this._constraints, ...constraints };
116
+ return res;
117
+ } finally {
118
+ unlock();
119
+ }
120
+ }
121
+
106
122
  protected async restart(
107
123
  constraints?: MediaTrackConstraints,
108
124
  isUnmuting?: boolean,
@@ -60,6 +60,9 @@ export abstract class Track<
60
60
 
61
61
  /** @internal */
62
62
  setStreamState(value: Track.StreamState) {
63
+ if (this._streamState !== value) {
64
+ this.log.debug(`stream state changed: ${this._streamState} -> ${value}`);
65
+ }
63
66
  this._streamState = value;
64
67
  }
65
68
 
@@ -90,8 +93,8 @@ export abstract class Track<
90
93
  loggerOptions: LoggerOptions = {},
91
94
  ) {
92
95
  super();
93
- this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.Track);
94
96
  this.loggerContextCb = loggerOptions.loggerContextCb;
97
+ this.log = getLogger(loggerOptions.loggerName ?? LoggerNames.Track, () => this.logContext);
95
98
 
96
99
  this.setMaxListeners(100);
97
100
  this.kind = kind;
@@ -185,11 +188,11 @@ export abstract class Track<
185
188
  this.emit(hasAudio ? TrackEvent.AudioPlaybackFailed : TrackEvent.VideoPlaybackFailed, e);
186
189
  } else if (e.name === 'AbortError') {
187
190
  // commonly triggered by another `play` request, only log for debugging purposes
188
- log.debug(
191
+ this.log.debug(
189
192
  `${hasAudio ? 'audio' : 'video'} playback aborted, likely due to new play request`,
190
193
  );
191
194
  } else {
192
- log.warn(`could not playback ${hasAudio ? 'audio' : 'video'}`, e);
195
+ this.log.warn(`could not playback ${hasAudio ? 'audio' : 'video'}`, { error: e });
193
196
  }
194
197
  // If audio playback isn't allowed make sure we still play back the video
195
198
  if (
@@ -252,6 +255,7 @@ export abstract class Track<
252
255
  }
253
256
 
254
257
  stop() {
258
+ this.log.debug('stopping track');
255
259
  this.stopMonitor();
256
260
  this._mediaStreamTrack.stop();
257
261
  }
@@ -279,12 +283,12 @@ export abstract class Track<
279
283
 
280
284
  /** @internal */
281
285
  updateLoggerOptions(loggerOptions: LoggerOptions) {
282
- if (loggerOptions.loggerName) {
283
- this.log = getLogger(loggerOptions.loggerName);
284
- }
285
286
  if (loggerOptions.loggerContextCb) {
286
287
  this.loggerContextCb = loggerOptions.loggerContextCb;
287
288
  }
289
+ if (loggerOptions.loggerName) {
290
+ this.log = getLogger(loggerOptions.loggerName, () => this.logContext);
291
+ }
288
292
  }
289
293
 
290
294
  private recycleElement(element: HTMLMediaElement) {