@stream-io/video-client 1.54.1-beta.0 → 1.55.1

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 (59) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/index.browser.es.js +9700 -8873
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +9707 -8880
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +9708 -8881
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +4 -4
  9. package/dist/src/StreamSfuClient.d.ts +11 -3
  10. package/dist/src/coordinator/connection/connection.d.ts +2 -1
  11. package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
  12. package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
  13. package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
  14. package/dist/src/rtc/Publisher.d.ts +1 -1
  15. package/dist/src/rtc/Subscriber.d.ts +2 -1
  16. package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
  17. package/dist/src/rtc/types.d.ts +3 -0
  18. package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
  19. package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
  20. package/dist/src/stats/rtc/Tracer.d.ts +9 -2
  21. package/dist/src/stats/rtc/types.d.ts +10 -4
  22. package/package.json +5 -3
  23. package/src/Call.ts +47 -44
  24. package/src/StreamSfuClient.ts +36 -21
  25. package/src/__tests__/StreamSfuClient.test.ts +159 -1
  26. package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
  27. package/src/coordinator/connection/__tests__/connection.test.ts +69 -0
  28. package/src/coordinator/connection/connection.ts +28 -13
  29. package/src/gen/video/sfu/event/events.ts +0 -1
  30. package/src/gen/video/sfu/models/models.ts +0 -1
  31. package/src/gen/video/sfu/signal_rpc/signal.client.ts +0 -1
  32. package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
  33. package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
  34. package/src/helpers/__tests__/browsers.test.ts +12 -12
  35. package/src/helpers/browsers.ts +5 -5
  36. package/src/helpers/client-details.ts +1 -1
  37. package/src/reporting/ClientEventReporter.ts +17 -12
  38. package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
  39. package/src/rtc/BasePeerConnection.ts +15 -34
  40. package/src/rtc/IceTrickleBuffer.ts +105 -12
  41. package/src/rtc/Publisher.ts +26 -19
  42. package/src/rtc/Subscriber.ts +71 -37
  43. package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
  44. package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
  45. package/src/rtc/__tests__/Publisher.test.ts +76 -31
  46. package/src/rtc/__tests__/Subscriber.test.ts +271 -20
  47. package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
  48. package/src/rtc/helpers/degradationPreference.ts +1 -0
  49. package/src/rtc/helpers/iceCandiates.ts +35 -0
  50. package/src/rtc/helpers/sdp.ts +3 -2
  51. package/src/rtc/helpers/tracks.ts +2 -0
  52. package/src/rtc/types.ts +3 -0
  53. package/src/stats/SfuStatsReporter.ts +149 -49
  54. package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
  55. package/src/stats/rtc/StatsTracer.ts +90 -32
  56. package/src/stats/rtc/Tracer.ts +23 -2
  57. package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
  58. package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
  59. package/src/stats/rtc/types.ts +11 -4
@@ -157,6 +157,7 @@ describe('Publisher', () => {
157
157
  publishOption: publisher['publishOptions'][0],
158
158
  transceiver,
159
159
  options: {},
160
+ negotiated: true,
160
161
  });
161
162
 
162
163
  await publisher.publish(track, TrackType.VIDEO);
@@ -166,6 +167,75 @@ describe('Publisher', () => {
166
167
  expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
167
168
  expect(track.stop).toHaveBeenCalled();
168
169
  });
170
+
171
+ it('should not renegotiate when reusing an already-negotiated transceiver', async () => {
172
+ const track = new MediaStreamTrack();
173
+ const clone = new MediaStreamTrack();
174
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
175
+
176
+ const transceiver = new RTCRtpTransceiver();
177
+ // @ts-expect-error test setup
178
+ transceiver.sender.track = track;
179
+ publisher['transceiverCache'].add({
180
+ publishOption: publisher['publishOptions'][0],
181
+ transceiver,
182
+ options: {},
183
+ negotiated: true,
184
+ });
185
+
186
+ // @ts-expect-error - private method
187
+ const negotiateSpy = vi.spyOn(publisher, 'negotiate').mockResolvedValue();
188
+
189
+ await publisher.publish(track, TrackType.VIDEO);
190
+
191
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
192
+ expect(negotiateSpy).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it('should renegotiate on republish when a previous negotiation never reached the SFU (SetPublisher timeout)', async () => {
196
+ const track = new MediaStreamTrack();
197
+ const transceiver = new RTCRtpTransceiver();
198
+ // @ts-expect-error test setup
199
+ transceiver.sender.track = track;
200
+ const bundle = {
201
+ publishOption: publisher['publishOptions'][0],
202
+ transceiver,
203
+ options: {},
204
+ negotiated: false,
205
+ };
206
+ publisher['transceiverCache'].add(bundle);
207
+
208
+ vi.spyOn(publisher['pc'], 'createOffer')
209
+ // @ts-expect-error TS picks up the wrong overload
210
+ .mockResolvedValue({ sdp: 'offer-sdp', type: 'offer' });
211
+ vi.spyOn(publisher['pc'], 'setLocalDescription').mockResolvedValue();
212
+ vi.spyOn(publisher['pc'], 'setRemoteDescription').mockResolvedValue();
213
+ vi.spyOn(publisher, 'getAnnouncedTracks').mockReturnValue([
214
+ // @ts-expect-error incomplete data
215
+ { trackId: '123' },
216
+ ]);
217
+
218
+ sfuClient.setPublisher = vi
219
+ .fn()
220
+ .mockRejectedValue(new Error('SetPublisherTimeout'));
221
+ await expect(publisher['negotiate']()).rejects.toThrow(
222
+ 'SetPublisherTimeout',
223
+ );
224
+ expect(bundle.negotiated).toBe(false);
225
+
226
+ const clone = new MediaStreamTrack();
227
+ vi.spyOn(track, 'clone').mockReturnValue(clone);
228
+ sfuClient.setPublisher = vi
229
+ .fn()
230
+ .mockResolvedValue({ response: { sdp: 'answer-sdp' } });
231
+
232
+ await publisher.publish(track, TrackType.VIDEO);
233
+
234
+ expect(publisher['pc'].addTransceiver).not.toHaveBeenCalled();
235
+ expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
236
+ expect(sfuClient.setPublisher).toHaveBeenCalled();
237
+ expect(bundle.negotiated).toBe(true);
238
+ });
169
239
  });
170
240
 
171
241
  describe('Event Handling', () => {
@@ -525,37 +595,6 @@ describe('Publisher', () => {
525
595
  expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
526
596
  });
527
597
 
528
- it(`isStable() returns false when ICE is 'new'`, () => {
529
- // @ts-expect-error private api
530
- publisher['pc'].iceConnectionState = 'new';
531
- // default connectionState in mock is 'connected'
532
- expect(publisher.isStable()).toBe(false);
533
- });
534
-
535
- it(`isStable() returns true when ICE is 'connected' and connectionState is 'connected'`, () => {
536
- // @ts-expect-error private api
537
- publisher['pc'].iceConnectionState = 'connected';
538
- // @ts-expect-error private api
539
- publisher['pc'].connectionState = 'connected';
540
- expect(publisher.isStable()).toBe(true);
541
- });
542
-
543
- it(`isStable() returns true when ICE is 'completed' and connectionState is 'connected'`, () => {
544
- // @ts-expect-error private api
545
- publisher['pc'].iceConnectionState = 'completed';
546
- // @ts-expect-error private api
547
- publisher['pc'].connectionState = 'connected';
548
- expect(publisher.isStable()).toBe(true);
549
- });
550
-
551
- it(`isStable() returns false when ICE is 'disconnected'`, () => {
552
- // @ts-expect-error private api
553
- publisher['pc'].iceConnectionState = 'disconnected';
554
- // @ts-expect-error private api
555
- publisher['pc'].connectionState = 'connected';
556
- expect(publisher.isStable()).toBe(false);
557
- });
558
-
559
598
  it(`after connected→disconnected→connected cycle, subsequent 'failed' DOES trigger ICE restart (flag stays true)`, () => {
560
599
  // @ts-expect-error private api
561
600
  publisher['pc'].iceConnectionState = 'connected';
@@ -1488,6 +1527,7 @@ describe('Publisher', () => {
1488
1527
  publishOption: publisher['publishOptions'][0],
1489
1528
  transceiver,
1490
1529
  options: {},
1530
+ negotiated: true,
1491
1531
  });
1492
1532
 
1493
1533
  // stopping seeds the bundle's videoSender from the current encoder
@@ -1546,11 +1586,13 @@ describe('Publisher', () => {
1546
1586
  publishOption: publisher['publishOptions'][0],
1547
1587
  transceiver: vp8Transceiver,
1548
1588
  options: {},
1589
+ negotiated: true,
1549
1590
  });
1550
1591
  publisher['transceiverCache'].add({
1551
1592
  publishOption: publisher['publishOptions'][1],
1552
1593
  transceiver: vp9Transceiver,
1553
1594
  options: {},
1595
+ negotiated: true,
1554
1596
  });
1555
1597
 
1556
1598
  await publisher.stopTracks(TrackType.VIDEO);
@@ -1620,6 +1662,7 @@ describe('Publisher', () => {
1620
1662
  publishOption,
1621
1663
  transceiver,
1622
1664
  options: {},
1665
+ negotiated: true,
1623
1666
  });
1624
1667
 
1625
1668
  // SFU sends a changePublishQuality while we are not publishing.
@@ -1634,6 +1677,7 @@ describe('Publisher', () => {
1634
1677
  {
1635
1678
  publishOptionId: publishOption.id,
1636
1679
  trackType: TrackType.VIDEO,
1680
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1637
1681
  layers: [
1638
1682
  {
1639
1683
  name: 'q',
@@ -1728,6 +1772,7 @@ describe('Publisher', () => {
1728
1772
  {
1729
1773
  publishOptionId: publishOption.id,
1730
1774
  trackType: TrackType.VIDEO,
1775
+ degradationPreference: DegradationPreference.UNSPECIFIED,
1731
1776
  layers: [
1732
1777
  {
1733
1778
  name: 'q',
@@ -141,26 +141,6 @@ describe('Subscriber', () => {
141
141
  );
142
142
  });
143
143
 
144
- it(`isStable() returns true only when ICE is connected/completed and connectionState is connected`, () => {
145
- // @ts-expect-error - private field
146
- subscriber['pc'].iceConnectionState = 'connected';
147
- // @ts-expect-error - private field
148
- subscriber['pc'].connectionState = 'connected';
149
- expect(subscriber.isStable()).toBe(true);
150
-
151
- // @ts-expect-error - private field
152
- subscriber['pc'].iceConnectionState = 'completed';
153
- expect(subscriber.isStable()).toBe(true);
154
-
155
- // @ts-expect-error - private field
156
- subscriber['pc'].iceConnectionState = 'disconnected';
157
- expect(subscriber.isStable()).toBe(false);
158
-
159
- // @ts-expect-error - private field
160
- subscriber['pc'].iceConnectionState = 'new';
161
- expect(subscriber.isStable()).toBe(false);
162
- });
163
-
164
144
  it(`iceHasEverConnected tracks lifetime connectivity`, () => {
165
145
  expect(subscriber['iceHasEverConnected']).toBe(false);
166
146
  simulatePriorIceConnected();
@@ -194,6 +174,277 @@ describe('Subscriber', () => {
194
174
  });
195
175
  });
196
176
 
177
+ describe('Subscriber negotiation', () => {
178
+ const subscriberOffer: SubscriberOffer = {
179
+ sdp: 'subscriber-offer-sdp',
180
+ iceRestart: false,
181
+ negotiationId: 10,
182
+ };
183
+
184
+ beforeEach(() => {
185
+ sfuClient.sendAnswer = vi.fn().mockResolvedValue({ response: {} });
186
+ });
187
+
188
+ it('resets isIceRestarting once a negotiation completes', async () => {
189
+ subscriber['isIceRestarting'] = true;
190
+
191
+ await subscriber['negotiate'](subscriberOffer);
192
+
193
+ expect(sfuClient.sendAnswer).toHaveBeenCalledWith({
194
+ peerType: PeerType.SUBSCRIBER,
195
+ sdp: '',
196
+ negotiationId: 10,
197
+ });
198
+ expect(subscriber['isIceRestarting']).toBe(false);
199
+ });
200
+
201
+ it('resets isIceRestarting even when the negotiation fails', async () => {
202
+ subscriber['isIceRestarting'] = true;
203
+ sfuClient.sendAnswer = vi
204
+ .fn()
205
+ .mockRejectedValue(new Error('send answer failed'));
206
+
207
+ await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
208
+ 'send answer failed',
209
+ );
210
+ expect(subscriber['isIceRestarting']).toBe(false);
211
+ });
212
+
213
+ it('rolls back the remote description when a negotiation fails mid-offer', async () => {
214
+ const setRemoteDescription = vi.fn().mockResolvedValue({});
215
+ subscriber['pc'].setRemoteDescription = setRemoteDescription;
216
+ // @ts-expect-error - readonly field
217
+ subscriber['pc'].signalingState = 'have-remote-offer';
218
+ sfuClient.sendAnswer = vi
219
+ .fn()
220
+ .mockRejectedValue(new Error('send answer failed'));
221
+
222
+ await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
223
+ 'send answer failed',
224
+ );
225
+ expect(setRemoteDescription).toHaveBeenCalledWith({ type: 'rollback' });
226
+ });
227
+
228
+ it('does not roll back when the peer connection never applied the offer', async () => {
229
+ // signalingState stays 'stable' because setRemoteDescription rejected
230
+ subscriber['pc'].setRemoteDescription = vi
231
+ .fn()
232
+ .mockRejectedValue(new Error('set remote description failed'));
233
+
234
+ await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
235
+ 'set remote description failed',
236
+ );
237
+ expect(subscriber['pc'].setRemoteDescription).not.toHaveBeenCalledWith({
238
+ type: 'rollback',
239
+ });
240
+ });
241
+
242
+ it('propagates the original error even when the rollback itself fails', async () => {
243
+ subscriber['pc'].setRemoteDescription = vi
244
+ .fn()
245
+ .mockResolvedValueOnce({}) // applying the offer succeeds
246
+ .mockRejectedValueOnce(new Error('rollback failed')); // rollback fails
247
+ // @ts-expect-error - readonly field
248
+ subscriber['pc'].signalingState = 'have-remote-offer';
249
+ sfuClient.sendAnswer = vi
250
+ .fn()
251
+ .mockRejectedValue(new Error('send answer failed'));
252
+
253
+ await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
254
+ 'send answer failed',
255
+ );
256
+ });
257
+
258
+ it('restores the previous generation in the buffer when a negotiation rolls back', async () => {
259
+ const sdp = (ufrag: string) =>
260
+ `v=0\r\na=ice-ufrag:${ufrag}\r\na=ice-pwd:pwd\r\n`;
261
+ const updateActiveGeneration = vi.spyOn(
262
+ sfuClient.iceTrickleBuffer,
263
+ 'updateActiveGeneration',
264
+ );
265
+ // previously-committed generation u0; the new (failing) offer is u1
266
+ // @ts-expect-error - overriding readonly mock field
267
+ subscriber['pc'].currentRemoteDescription = {
268
+ type: 'offer',
269
+ sdp: sdp('u0'),
270
+ };
271
+ // @ts-expect-error - overriding readonly mock field
272
+ subscriber['pc'].remoteDescription = { type: 'offer', sdp: sdp('u1') };
273
+ // @ts-expect-error - readonly field
274
+ subscriber['pc'].signalingState = 'have-remote-offer';
275
+ sfuClient.sendAnswer = vi
276
+ .fn()
277
+ .mockRejectedValue(new Error('send answer failed'));
278
+
279
+ await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
280
+ 'send answer failed',
281
+ );
282
+
283
+ // advanced to the new generation, then restored to the rolled-back one
284
+ expect(updateActiveGeneration).toHaveBeenCalledWith(
285
+ PeerType.SUBSCRIBER,
286
+ sdp('u1'),
287
+ );
288
+ expect(updateActiveGeneration).toHaveBeenLastCalledWith(
289
+ PeerType.SUBSCRIBER,
290
+ sdp('u0'),
291
+ );
292
+ });
293
+ });
294
+
295
+ describe('negotiation failure recovery', () => {
296
+ const offer = SubscriberOffer.create({
297
+ sdp: 'offer-sdp',
298
+ iceRestart: false,
299
+ });
300
+ const dispatchOffer = () =>
301
+ dispatcher.dispatch(
302
+ SfuEvent.create({
303
+ eventPayload: {
304
+ oneofKind: 'subscriberOffer',
305
+ subscriberOffer: offer,
306
+ },
307
+ }) as DispatchableMessage<'subscriberOffer'>,
308
+ 'test',
309
+ );
310
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
311
+
312
+ it('retries via ICE restart on a single negotiation failure', async () => {
313
+ // @ts-expect-error - private method
314
+ subscriber.negotiate = vi.fn().mockRejectedValue(new Error('boom'));
315
+ // @ts-expect-error - protected method
316
+ subscriber.tryRestartIce = vi.fn();
317
+ subscriber['onReconnectionNeeded'] = vi.fn();
318
+
319
+ dispatchOffer();
320
+ await flush();
321
+
322
+ // @ts-expect-error - protected method
323
+ expect(subscriber.tryRestartIce).toHaveBeenCalledTimes(1);
324
+ expect(subscriber['onReconnectionNeeded']).not.toHaveBeenCalled();
325
+ });
326
+
327
+ it('escalates to REJOIN after repeated failures instead of looping ICE restarts', async () => {
328
+ // @ts-expect-error - private method
329
+ subscriber.negotiate = vi.fn().mockRejectedValue(new Error('boom'));
330
+ // @ts-expect-error - protected method
331
+ subscriber.tryRestartIce = vi.fn();
332
+ subscriber['onReconnectionNeeded'] = vi.fn();
333
+
334
+ // three consecutive failures (the configured ceiling)
335
+ for (let i = 0; i < 3; i++) {
336
+ dispatchOffer();
337
+ await flush();
338
+ }
339
+
340
+ // the first two fall through to an ICE restart; the third gives up and rejoins
341
+ // @ts-expect-error - protected method
342
+ expect(subscriber.tryRestartIce).toHaveBeenCalledTimes(2);
343
+ expect(subscriber['onReconnectionNeeded']).toHaveBeenCalledWith(
344
+ WebsocketReconnectStrategy.REJOIN,
345
+ ReconnectReason.SUBSCRIBER_NEGOTIATION_FAILED,
346
+ PeerType.SUBSCRIBER,
347
+ );
348
+ });
349
+
350
+ it('resets the failure counter after a successful negotiation', async () => {
351
+ // @ts-expect-error - private method
352
+ subscriber.negotiate = vi
353
+ .fn()
354
+ .mockRejectedValueOnce(new Error('boom'))
355
+ .mockRejectedValueOnce(new Error('boom'))
356
+ .mockResolvedValueOnce(undefined)
357
+ .mockRejectedValueOnce(new Error('boom'))
358
+ .mockRejectedValueOnce(new Error('boom'));
359
+ // @ts-expect-error - protected method
360
+ subscriber.tryRestartIce = vi.fn();
361
+ subscriber['onReconnectionNeeded'] = vi.fn();
362
+
363
+ for (let i = 0; i < 5; i++) {
364
+ dispatchOffer();
365
+ await flush();
366
+ }
367
+
368
+ // four failures total, but never three in a row, so no REJOIN
369
+ expect(subscriber['onReconnectionNeeded']).not.toHaveBeenCalled();
370
+ // @ts-expect-error - protected method
371
+ expect(subscriber.tryRestartIce).toHaveBeenCalledTimes(4);
372
+ });
373
+ });
374
+
375
+ describe('ICE candidate trickling', () => {
376
+ const trickle = (ufrag: string, candidate: string) => ({
377
+ peerType: PeerType.SUBSCRIBER,
378
+ iceCandidate: JSON.stringify({ usernameFragment: ufrag, candidate }),
379
+ });
380
+
381
+ const sdp = (ufrag: string) =>
382
+ `v=0\r\na=ice-ufrag:${ufrag}\r\na=ice-pwd:pwd\r\n`;
383
+
384
+ const setRemoteUfrag = (ufrag: string) => {
385
+ // @ts-expect-error - overriding readonly remoteDescription on the mock
386
+ subscriber['pc'].remoteDescription = { type: 'offer', sdp: sdp(ufrag) };
387
+ };
388
+
389
+ const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
390
+
391
+ it('declares the active generation from the remote description and adds emitted candidates', async () => {
392
+ const addIceCandidate = vi
393
+ .spyOn(subscriber['pc'], 'addIceCandidate')
394
+ .mockResolvedValue();
395
+ const updateActiveGeneration = vi.spyOn(
396
+ sfuClient.iceTrickleBuffer,
397
+ 'updateActiveGeneration',
398
+ );
399
+ setRemoteUfrag('u1');
400
+ sfuClient.iceTrickleBuffer.push(trickle('u1', 'c1'));
401
+
402
+ subscriber['addTrickledIceCandidates']();
403
+ await flush();
404
+
405
+ expect(updateActiveGeneration).toHaveBeenCalledWith(
406
+ PeerType.SUBSCRIBER,
407
+ sdp('u1'),
408
+ );
409
+ expect(addIceCandidate).toHaveBeenCalledWith({
410
+ usernameFragment: 'u1',
411
+ candidate: 'c1',
412
+ });
413
+ });
414
+
415
+ it('does not re-add a superseded-generation candidate after an ICE restart', async () => {
416
+ const addIceCandidate = vi
417
+ .spyOn(subscriber['pc'], 'addIceCandidate')
418
+ .mockResolvedValue();
419
+
420
+ setRemoteUfrag('u0');
421
+ sfuClient.iceTrickleBuffer.push(trickle('u0', 'c0'));
422
+ subscriber['addTrickledIceCandidates']();
423
+ await flush();
424
+ expect(addIceCandidate).toHaveBeenCalledWith({
425
+ usernameFragment: 'u0',
426
+ candidate: 'c0',
427
+ });
428
+
429
+ addIceCandidate.mockClear();
430
+
431
+ // ICE restart -> generation u1; the old u0 candidate must not be re-added
432
+ setRemoteUfrag('u1');
433
+ sfuClient.iceTrickleBuffer.push(trickle('u1', 'c1'));
434
+ subscriber['addTrickledIceCandidates']();
435
+ await flush();
436
+
437
+ expect(addIceCandidate).toHaveBeenCalledWith({
438
+ usernameFragment: 'u1',
439
+ candidate: 'c1',
440
+ });
441
+ expect(addIceCandidate).not.toHaveBeenCalledWith({
442
+ usernameFragment: 'u0',
443
+ candidate: 'c0',
444
+ });
445
+ });
446
+ });
447
+
197
448
  describe('OnTrack', () => {
198
449
  it('should add unknown tracks to the to the call state', () => {
199
450
  const mediaStream = new MediaStream();
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getCandidateUfrag, parseIceUfrag, toJSON } from '../iceCandiates';
3
+
4
+ describe('iceCandiates helpers', () => {
5
+ describe('toJSON', () => {
6
+ it('parses the ufrag from the candidate string when usernameFragment is missing (react-native-webrtc)', () => {
7
+ const candidate = {
8
+ candidate:
9
+ 'candidate:1 1 udp 100 1.2.3.4 5000 typ host ufrag ABC network-id 1',
10
+ sdpMid: '0',
11
+ } as RTCIceCandidate;
12
+
13
+ const result = JSON.parse(toJSON(candidate));
14
+ expect(result.usernameFragment).toBe('ABC');
15
+ expect(result.sdpMid).toBe('0');
16
+ });
17
+
18
+ it('leaves usernameFragment undefined when it is missing and the candidate has no ufrag token', () => {
19
+ const candidate = {
20
+ candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host generation 0',
21
+ } as unknown as RTCIceCandidate;
22
+
23
+ const result = JSON.parse(toJSON(candidate));
24
+ expect(result.usernameFragment).toBeUndefined();
25
+ });
26
+ });
27
+
28
+ describe('parseIceUfrag', () => {
29
+ it('extracts the ice-ufrag from an SDP', () => {
30
+ const sdp = 'v=0\r\na=ice-ufrag:F7gIaBcD\r\na=ice-pwd:somepwd\r\n';
31
+ expect(parseIceUfrag(sdp)).toBe('F7gIaBcD');
32
+ });
33
+
34
+ it('returns undefined when the SDP has no ice-ufrag', () => {
35
+ expect(parseIceUfrag('v=0\r\na=ice-pwd:somepwd\r\n')).toBeUndefined();
36
+ });
37
+
38
+ it('returns undefined for an undefined SDP', () => {
39
+ expect(parseIceUfrag(undefined)).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ describe('getCandidateUfrag', () => {
44
+ it('uses usernameFragment when present', () => {
45
+ expect(
46
+ getCandidateUfrag({
47
+ candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host',
48
+ usernameFragment: 'DEF',
49
+ }),
50
+ ).toBe('DEF');
51
+ });
52
+
53
+ it('prefers usernameFragment over the candidate-string ufrag token', () => {
54
+ expect(
55
+ getCandidateUfrag({
56
+ candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host ufrag ABC',
57
+ usernameFragment: 'DEF',
58
+ }),
59
+ ).toBe('DEF');
60
+ });
61
+
62
+ it('falls back to the ufrag token in the candidate string when usernameFragment is absent', () => {
63
+ expect(
64
+ getCandidateUfrag({
65
+ candidate:
66
+ 'candidate:1 1 udp 100 1.2.3.4 5000 typ host ufrag ABC network-id 1',
67
+ }),
68
+ ).toBe('ABC');
69
+ });
70
+
71
+ it('reads the ufrag from a realistic SFU trickled candidate string', () => {
72
+ // The SFU embeds the generation as a `ufrag` token in the candidate
73
+ // string, so the consumer can classify it without a usernameFragment.
74
+ const candidate =
75
+ 'candidate:842163049 1 udp 1677729535 203.0.113.1 56789 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag F7gIaBcD network-id 1 network-cost 10';
76
+ expect(getCandidateUfrag({ candidate })).toBe('F7gIaBcD');
77
+ });
78
+
79
+ it('returns undefined when neither a usernameFragment nor a ufrag token is present', () => {
80
+ expect(
81
+ getCandidateUfrag({
82
+ candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host',
83
+ }),
84
+ ).toBeUndefined();
85
+ expect(getCandidateUfrag({})).toBeUndefined();
86
+ });
87
+ });
88
+ });
@@ -18,6 +18,7 @@ export const toRTCDegradationPreference = (
18
18
  return undefined;
19
19
  default:
20
20
  ensureExhausted(preference, 'Unknown degradation preference');
21
+ return undefined;
21
22
  }
22
23
  };
23
24
 
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Converts the ICE candidate to a JSON string.
3
+ */
4
+ export const toJSON = (candidate: RTCIceCandidate) => {
5
+ if (!candidate.usernameFragment) {
6
+ // react-native-webrtc doesn't include usernameFragment in the candidate
7
+ const usernameFragment = parseUfragFromCandidate(candidate.candidate);
8
+ return JSON.stringify({ ...candidate, usernameFragment });
9
+ }
10
+ return JSON.stringify(candidate.toJSON());
11
+ };
12
+
13
+ /**
14
+ * Extracts the ICE ufrag from an SDP, or `undefined` when absent.
15
+ */
16
+ export const parseIceUfrag = (sdp: string | undefined): string | undefined => {
17
+ return sdp?.match(/^a=ice-ufrag:(\S+)/m)?.[1];
18
+ };
19
+
20
+ /**
21
+ * Extracts the ICE ufrag (generation) a trickled candidate was gathered under.
22
+ */
23
+ export const getCandidateUfrag = (ice: RTCIceCandidateInit) => {
24
+ return ice.usernameFragment ?? parseUfragFromCandidate(ice.candidate);
25
+ };
26
+
27
+ /**
28
+ * Parses the `ufrag` token from a raw ICE candidate string
29
+ * (e.g. `candidate:... ufrag <value> ...`). Returns `undefined` when absent.
30
+ */
31
+ const parseUfragFromCandidate = (candidate: string | undefined) => {
32
+ const segments = candidate?.split(' ') ?? [];
33
+ const index = segments.indexOf('ufrag');
34
+ return index !== -1 ? segments[index + 1] : undefined;
35
+ };
@@ -158,11 +158,12 @@ export const removeCodecsExcept = (
158
158
  // If a specific fmtp profile is requested, only keep payloads whose fmtp config matches it
159
159
  if (fmtpProfileToKeep) {
160
160
  const filtered = new Set<number>();
161
- const required = new Set(fmtpProfileToKeep.split(';'));
161
+ const required = fmtpProfileToKeep.split(';');
162
162
  for (const fmtp of media.fmtp) {
163
+ const actual = new Set(fmtp.config.split(';'));
163
164
  if (
164
165
  payloadsToKeep.has(fmtp.payload) &&
165
- required.difference(new Set(fmtp.config.split(';'))).size === 0
166
+ required.every((part) => actual.has(part))
166
167
  ) {
167
168
  filtered.add(fmtp.payload);
168
169
  }
@@ -23,6 +23,7 @@ export const trackTypeToParticipantStreamKey = (
23
23
  throw new Error('Track type is unspecified');
24
24
  default:
25
25
  ensureExhausted(trackType, 'Unknown track type');
26
+ return undefined;
26
27
  }
27
28
  };
28
29
 
@@ -40,6 +41,7 @@ export const muteTypeToTrackType = (
40
41
  return TrackType.SCREEN_SHARE_AUDIO;
41
42
  default:
42
43
  ensureExhausted(muteType, 'Unknown mute type');
44
+ return undefined;
43
45
  }
44
46
  };
45
47
 
package/src/rtc/types.ts CHANGED
@@ -28,6 +28,8 @@ export const ReconnectReason = {
28
28
  CONNECTION_FAILED: 'connection_failed',
29
29
  /** `restartIce()` rejected. */
30
30
  RESTART_ICE_FAILED: 'restart_ice_failed',
31
+ /** Subscriber renegotiation kept failing, escalate to REJOIN. */
32
+ SUBSCRIBER_NEGOTIATION_FAILED: 'subscriber_negotiation_failed',
31
33
  /** SFU `goAway` event, migrate to a new SFU. */
32
34
  GO_AWAY: 'go_away',
33
35
  /** Network came back online after going offline. */
@@ -110,6 +112,7 @@ export type PublishBundle = {
110
112
  transceiver: RTCRtpTransceiver;
111
113
  options: TrackPublishOptions;
112
114
  videoSender?: VideoSender;
115
+ negotiated?: boolean;
113
116
  };
114
117
 
115
118
  export type TrackLayersCache = {