@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
@@ -66,13 +66,14 @@ export class StableWSConnection {
66
66
  consecutiveFailures = 0;
67
67
  /** keep track of the total number of failures */
68
68
  totalFailures = 0;
69
+ lastConnectionError?: WSConnectionError;
69
70
 
70
71
  // Health-check pings + connection-staleness check.
71
72
  /** Send a health check message every 25 seconds */
72
73
  pingInterval = 25 * 1000;
73
74
  healthCheckTimeoutRef?: number;
74
75
  connectionCheckTimeout = this.pingInterval + 10 * 1000;
75
- connectionCheckTimeoutRef?: NodeJS.Timeout;
76
+ connectionCheckTimeoutRef?: number;
76
77
  /** Store the last event time for health checks */
77
78
  lastEvent: Date | null = null;
78
79
 
@@ -102,6 +103,7 @@ export class StableWSConnection {
102
103
  }
103
104
 
104
105
  this.isDisconnected = false;
106
+ this.lastConnectionError = undefined;
105
107
 
106
108
  try {
107
109
  const healthCheck = await this._connect(timeout);
@@ -140,6 +142,7 @@ export class StableWSConnection {
140
142
  // _connect()'s catch) keeps a single failure from spawning two
141
143
  // parallel chains - one from this catch and one from _reconnect's
142
144
  // own catch when _connect was called from there.
145
+ this.lastConnectionError = error;
143
146
  this._reconnect();
144
147
  }
145
148
  }
@@ -174,18 +177,27 @@ export class StableWSConnection {
174
177
  await sleep(interval);
175
178
  }
176
179
  }
180
+ return undefined;
177
181
  })(),
178
182
  (async () => {
179
183
  await sleep(timeout);
180
184
  this.isConnecting = false;
181
- throw new Error(
182
- JSON.stringify({
183
- code: '',
184
- StatusCode: '',
185
- message: 'initial WS connection could not be established',
186
- isWSFailure: true,
187
- }),
188
- );
185
+ const e = this.lastConnectionError;
186
+ const errorPayload = e
187
+ ? {
188
+ code: e.code,
189
+ StatusCode: e.StatusCode,
190
+ message: e.message,
191
+ isWSFailure: e.isWSFailure,
192
+ }
193
+ : {
194
+ code: '',
195
+ StatusCode: '',
196
+ message: 'initial WS connection could not be established',
197
+ isWSFailure: true,
198
+ };
199
+
200
+ throw new Error(JSON.stringify(errorPayload));
189
201
  })(),
190
202
  ]);
191
203
  };
@@ -221,7 +233,7 @@ export class StableWSConnection {
221
233
  getTimers().clearInterval(this.healthCheckTimeoutRef);
222
234
  }
223
235
  if (this.connectionCheckTimeoutRef) {
224
- clearInterval(this.connectionCheckTimeoutRef);
236
+ getTimers().clearTimeout(this.connectionCheckTimeoutRef);
225
237
  }
226
238
 
227
239
  removeConnectionEventListeners(this.onlineStatusChanged);
@@ -372,8 +384,10 @@ export class StableWSConnection {
372
384
  this.client.resolveConnectionId?.(this.connectionID);
373
385
  return response;
374
386
  }
387
+ return undefined;
375
388
  } catch (caught) {
376
389
  const err = caught as WSConnectionError;
390
+ this.lastConnectionError = err;
377
391
  this.isConnecting = false;
378
392
  this._log(`_connect() - Error - `, err);
379
393
  // Reject THIS attempt's connection-id promise (P1) directly via the
@@ -579,7 +593,7 @@ export class StableWSConnection {
579
593
  code === KnownCodes.TOKEN_EXPIRED &&
580
594
  !this.client.tokenManager.isStatic()
581
595
  ) {
582
- clearTimeout(this.connectionCheckTimeoutRef);
596
+ getTimers().clearTimeout(this.connectionCheckTimeoutRef);
583
597
  this._log(
584
598
  'connect() - WS failure due to expired token, so going to try to reload token and reconnect',
585
599
  );
@@ -769,8 +783,9 @@ export class StableWSConnection {
769
783
  * to be reconnected.
770
784
  */
771
785
  scheduleConnectionCheck = () => {
772
- clearTimeout(this.connectionCheckTimeoutRef);
773
- this.connectionCheckTimeoutRef = setTimeout(() => {
786
+ const timers = getTimers();
787
+ timers.clearTimeout(this.connectionCheckTimeoutRef);
788
+ this.connectionCheckTimeoutRef = timers.setTimeout(() => {
774
789
  const now = new Date();
775
790
  if (
776
791
  this.lastEvent &&
@@ -1,4 +1,3 @@
1
-
2
1
  // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
3
2
  // @generated from protobuf file "video/sfu/event/events.proto" (package "stream.video.sfu.event", syntax proto3)
4
3
  // tslint:disable
@@ -1,4 +1,3 @@
1
-
2
1
  // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
3
2
  // @generated from protobuf file "video/sfu/models/models.proto" (package "stream.video.sfu.models", syntax proto3)
4
3
  // tslint:disable
@@ -1,4 +1,3 @@
1
-
2
1
  // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
3
2
  // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3)
4
3
  // tslint:disable
@@ -1,4 +1,3 @@
1
-
2
1
  // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
3
2
  // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3)
4
3
  // tslint:disable
@@ -93,6 +93,7 @@ export class MediaPlaybackWatchdog {
93
93
  const HAVE_CURRENT_DATA = 2;
94
94
  if (this.element.readyState < HAVE_CURRENT_DATA) return 'notReady';
95
95
  if (!this.element.paused) return 'notPaused';
96
+ return undefined;
96
97
  };
97
98
 
98
99
  private attemptPlay = async () => {
@@ -169,70 +169,70 @@ describe('browsers', () => {
169
169
 
170
170
  it('should return true for supported Chrome version', async () => {
171
171
  vi.mocked(getClientDetails).mockResolvedValue({
172
- browser: { name: 'Chrome', version: '124' },
172
+ browser: { name: 'Chrome', version: '136' },
173
173
  } as ClientDetails);
174
174
  expect(await isSupportedBrowser()).toBe(true);
175
175
  });
176
176
 
177
177
  it('should return true for supported Chrome detailed version', async () => {
178
178
  vi.mocked(getClientDetails).mockResolvedValue({
179
- browser: { name: 'Chrome', version: '124.0.7204.158' },
179
+ browser: { name: 'Chrome', version: '136.0.7204.158' },
180
180
  } as ClientDetails);
181
181
  expect(await isSupportedBrowser()).toBe(true);
182
182
  });
183
183
 
184
184
  it('should return false for unsupported Chrome version', async () => {
185
185
  vi.mocked(getClientDetails).mockResolvedValue({
186
- browser: { name: 'Chrome', version: '123' },
186
+ browser: { name: 'Chrome', version: '135' },
187
187
  } as ClientDetails);
188
188
  expect(await isSupportedBrowser()).toBe(false);
189
189
  });
190
190
 
191
191
  it('should return false for unsupported Chrome detailed version', async () => {
192
192
  vi.mocked(getClientDetails).mockResolvedValue({
193
- browser: { name: 'Chrome', version: '123.0.1234.99' },
193
+ browser: { name: 'Chrome', version: '135.0.1234.99' },
194
194
  } as ClientDetails);
195
195
  expect(await isSupportedBrowser()).toBe(false);
196
196
  });
197
197
 
198
198
  it('should return true for supported Edge version', async () => {
199
199
  vi.mocked(getClientDetails).mockResolvedValue({
200
- browser: { name: 'Edge', version: '124' },
200
+ browser: { name: 'Edge', version: '136' },
201
201
  } as ClientDetails);
202
202
  expect(await isSupportedBrowser()).toBe(true);
203
203
  });
204
204
 
205
205
  it('should return false for unsupported Edge version', async () => {
206
206
  vi.mocked(getClientDetails).mockResolvedValue({
207
- browser: { name: 'Edge', version: '123' },
207
+ browser: { name: 'Edge', version: '135' },
208
208
  } as ClientDetails);
209
209
  expect(await isSupportedBrowser()).toBe(false);
210
210
  });
211
211
 
212
212
  it('should return true for supported Firefox version', async () => {
213
213
  vi.mocked(getClientDetails).mockResolvedValue({
214
- browser: { name: 'Firefox', version: '124' },
214
+ browser: { name: 'Firefox', version: '137' },
215
215
  } as ClientDetails);
216
216
  expect(await isSupportedBrowser()).toBe(true);
217
217
  });
218
218
 
219
219
  it('should return false for unsupported Firefox version', async () => {
220
220
  vi.mocked(getClientDetails).mockResolvedValue({
221
- browser: { name: 'Firefox', version: '123' },
221
+ browser: { name: 'Firefox', version: '136' },
222
222
  } as ClientDetails);
223
223
  expect(await isSupportedBrowser()).toBe(false);
224
224
  });
225
225
 
226
226
  it('should return true for supported Safari version', async () => {
227
227
  vi.mocked(getClientDetails).mockResolvedValue({
228
- browser: { name: 'Safari', version: '17' },
228
+ browser: { name: 'Safari', version: '18' },
229
229
  } as ClientDetails);
230
230
  expect(await isSupportedBrowser()).toBe(true);
231
231
  });
232
232
 
233
233
  it('should return false for unsupported Safari version', async () => {
234
234
  vi.mocked(getClientDetails).mockResolvedValue({
235
- browser: { name: 'Safari', version: '16' },
235
+ browser: { name: 'Safari', version: '17' },
236
236
  } as ClientDetails);
237
237
  expect(await isSupportedBrowser()).toBe(false);
238
238
  });
@@ -253,14 +253,14 @@ describe('browsers', () => {
253
253
 
254
254
  it('should return true for supported WebView version (WebView on Android)', async () => {
255
255
  vi.mocked(getClientDetails).mockResolvedValue({
256
- browser: { name: 'WebView', version: '124' },
256
+ browser: { name: 'WebView', version: '136' },
257
257
  } as ClientDetails);
258
258
  expect(await isSupportedBrowser()).toBe(true);
259
259
  });
260
260
 
261
261
  it('should return false for unsupported WebView version (WebView on Android)', async () => {
262
262
  vi.mocked(getClientDetails).mockResolvedValue({
263
- browser: { name: 'WebView', version: '123' },
263
+ browser: { name: 'WebView', version: '135' },
264
264
  } as ClientDetails);
265
265
  expect(await isSupportedBrowser()).toBe(false);
266
266
  });
@@ -62,11 +62,11 @@ export const isSupportedBrowser = async (): Promise<boolean> => {
62
62
  const [major] = browser.version.split('.');
63
63
  const version = parseInt(major, 10);
64
64
  return (
65
- (name.includes('chrome') && version >= 124) ||
66
- (name.includes('edge') && version >= 124) ||
67
- (name.includes('firefox') && version >= 124) ||
68
- (name.includes('safari') && version >= 17) ||
65
+ (name.includes('chrome') && version >= 136) ||
66
+ (name.includes('edge') && version >= 136) ||
67
+ (name.includes('firefox') && version >= 137) ||
68
+ (name.includes('safari') && version >= 18) ||
69
69
  (name.includes('webkit') && version >= 605) || // WebView on iOS
70
- (name.includes('webview') && version >= 124) // WebView on Android
70
+ (name.includes('webview') && version >= 136) // WebView on Android
71
71
  );
72
72
  };
@@ -192,6 +192,6 @@ export const getClientDetails = async (): Promise<ClientDetails> => {
192
192
  .join(' '),
193
193
  version: '',
194
194
  },
195
- webrtcVersion: webRtcInfo?.version || '',
195
+ webrtcVersion: browserVersion,
196
196
  };
197
197
  };
@@ -219,7 +219,7 @@ export class ClientEventReporter {
219
219
  joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
220
220
  };
221
221
 
222
- this.send({
222
+ this.sendForCall(cid, {
223
223
  ...this.buildCommon(cid, 'MediaDevicePermission', pair),
224
224
  ...this.sessionIdField(cid),
225
225
  microphone_permission_status: readPermissionStatus(
@@ -322,7 +322,7 @@ export class ClientEventReporter {
322
322
  };
323
323
 
324
324
  const resolvedSfuId = this.getSfuId(cid);
325
- this.send({
325
+ this.sendForCall(cid, {
326
326
  ...this.buildCommon(cid, stage, pair),
327
327
  ...this.sessionIdField(cid),
328
328
  ...(resolvedSfuId && { sfu_id: resolvedSfuId }),
@@ -395,7 +395,7 @@ export class ClientEventReporter {
395
395
  const coordinatorConnectId = this.coordinatorConnectId;
396
396
  const ctx = this.callContexts.get(cid);
397
397
 
398
- this.send({
398
+ this.sendForCall(cid, {
399
399
  user_id: this.streamClient.userID || this.coordinatorConnectUserId,
400
400
  type: ctx?.callType,
401
401
  id: ctx?.callId,
@@ -454,7 +454,7 @@ export class ClientEventReporter {
454
454
  joinReasonSnapshot: this.joinReasons.get(cid),
455
455
  };
456
456
  this.coordinatorPairs.set(cid, pair);
457
- this.send({
457
+ this.sendForCall(cid, {
458
458
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
459
459
  ...(pair.joinReasonSnapshot && {
460
460
  join_reason: pair.joinReasonSnapshot,
@@ -469,7 +469,7 @@ export class ClientEventReporter {
469
469
  private succeedCoordinator = (cid: string) => {
470
470
  const pair = this.coordinatorPairs.get(cid);
471
471
  if (!pair) return;
472
- this.send({
472
+ this.sendForCall(cid, {
473
473
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
474
474
  ...this.sessionIdField(cid),
475
475
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
@@ -488,7 +488,7 @@ export class ClientEventReporter {
488
488
  return;
489
489
  }
490
490
  const { reason, code } = pair.lastError;
491
- this.send({
491
+ this.sendForCall(cid, {
492
492
  ...this.buildCommon(cid, 'CoordinatorJoin', pair),
493
493
  ...this.sessionIdField(cid),
494
494
  ...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
@@ -513,7 +513,7 @@ export class ClientEventReporter {
513
513
  };
514
514
  this.wsPairs.set(cid, pair);
515
515
  const sfuId = this.getSfuId(cid);
516
- this.send({
516
+ this.sendForCall(cid, {
517
517
  ...this.buildCommon(cid, 'WSJoin', pair),
518
518
  ...this.sessionIdField(cid),
519
519
  ...(sfuId && { sfu_id: sfuId }),
@@ -529,7 +529,7 @@ export class ClientEventReporter {
529
529
  const pair = this.wsPairs.get(cid);
530
530
  if (!pair) return;
531
531
  const sfuId = this.getSfuId(cid);
532
- this.send({
532
+ this.sendForCall(cid, {
533
533
  ...this.buildCommon(cid, 'WSJoin', pair),
534
534
  ...this.sessionIdField(cid),
535
535
  ...(sfuId && { sfu_id: sfuId }),
@@ -552,7 +552,7 @@ export class ClientEventReporter {
552
552
  const { reason, code } = pair.lastError;
553
553
  const sfuId = this.getSfuId(cid);
554
554
 
555
- this.send({
555
+ this.sendForCall(cid, {
556
556
  ...this.buildCommon(cid, 'WSJoin', pair),
557
557
  ...this.sessionIdField(cid),
558
558
  event_type: 'completed',
@@ -627,7 +627,7 @@ export class ClientEventReporter {
627
627
  };
628
628
  this.peerConnectionPairs.set(key, pair);
629
629
 
630
- this.send({
630
+ this.sendForCall(cid, {
631
631
  ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
632
632
  ...this.sessionIdField(cid),
633
633
  peer_connection: role,
@@ -648,7 +648,7 @@ export class ClientEventReporter {
648
648
  const pair = this.peerConnectionPairs.get(key);
649
649
  if (!pair) return;
650
650
 
651
- this.send({
651
+ this.sendForCall(cid, {
652
652
  ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
653
653
  ...this.sessionIdField(cid),
654
654
  peer_connection: role,
@@ -676,7 +676,7 @@ export class ClientEventReporter {
676
676
  const pair = this.peerConnectionPairs.get(key);
677
677
  if (!pair) return;
678
678
 
679
- this.send({
679
+ this.sendForCall(cid, {
680
680
  ...this.buildCommon(cid, 'PeerConnectionConnect', pair),
681
681
  ...this.sessionIdField(cid),
682
682
  peer_connection: role,
@@ -738,6 +738,11 @@ export class ClientEventReporter {
738
738
  void this.sendWithRetry(body);
739
739
  };
740
740
 
741
+ private sendForCall = (cid: string, body: Record<string, unknown>) => {
742
+ if (!this.callContexts.has(cid)) return;
743
+ this.send(body);
744
+ };
745
+
741
746
  private sendWithRetry = async (
742
747
  body: Record<string, unknown>,
743
748
  ): Promise<boolean> => {
@@ -617,6 +617,58 @@ describe('ClientEventReporter', () => {
617
617
  expect(ws1.every((e) => e.sfu_id === 'sfu-1')).toBe(true);
618
618
  expect(ws2.every((e) => e.sfu_id === 'sfu-2')).toBe(true);
619
619
  });
620
+
621
+ describe('drops events for an unregistered call', () => {
622
+ it('does not emit stage events after the call is unregistered', async () => {
623
+ reporter.startCorrelation(cid, 'first-attempt');
624
+ reporter.unregisterCall(cid);
625
+ await flush();
626
+ doAxiosRequest.mockClear();
627
+
628
+ await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
629
+ reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
630
+ await flush();
631
+
632
+ expect(doAxiosRequest).not.toHaveBeenCalled();
633
+ });
634
+
635
+ it('does not emit a stage completion when unregistered mid-flight', async () => {
636
+ reporter.startCorrelation(cid, 'first-attempt');
637
+ await flush();
638
+ doAxiosRequest.mockClear();
639
+
640
+ await reporter.track(cid, 'WSJoin', async () => {
641
+ reporter.unregisterCall(cid);
642
+ return 'ok';
643
+ });
644
+ await flush();
645
+
646
+ const completed = postedEvents().filter(
647
+ (e) => e.stage === 'WSJoin' && e.event_type === 'completed',
648
+ );
649
+ expect(completed).toHaveLength(0);
650
+ });
651
+
652
+ it('does not emit JoinInitiated for an unregistered call', async () => {
653
+ reporter.unregisterCall(cid);
654
+ reporter.startCorrelation(cid, 'first-attempt');
655
+ await flush();
656
+
657
+ const joinInitiated = postedEvents().filter(
658
+ (e) => e.stage === 'JoinInitiated',
659
+ );
660
+ expect(joinInitiated).toHaveLength(0);
661
+ });
662
+
663
+ it('still emits CoordinatorWS events (connection-scoped, not call-gated)', async () => {
664
+ reporter.unregisterCall(cid);
665
+ await reporter.trackCoordinatorWs(() => Promise.resolve('ok'));
666
+ await flush();
667
+
668
+ const ws = postedEvents().filter((e) => e.stage === 'CoordinatorWS');
669
+ expect(ws.length).toBeGreaterThan(0);
670
+ });
671
+ });
620
672
  });
621
673
 
622
674
  describe('ClientEventReporter (disabled)', () => {
@@ -13,6 +13,7 @@ import { StreamSfuClient } from '../StreamSfuClient';
13
13
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
14
14
  import { withoutConcurrency } from '../helpers/concurrency';
15
15
  import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
16
+ import { toJSON } from './helpers/iceCandiates';
16
17
  import {
17
18
  BasePeerConnectionOpts,
18
19
  OnIceConnected,
@@ -37,7 +38,7 @@ export abstract class BasePeerConnection {
37
38
  protected tag: string;
38
39
  protected sfuClient: StreamSfuClient;
39
40
 
40
- private onReconnectionNeeded?: OnReconnectionNeeded;
41
+ protected onReconnectionNeeded?: OnReconnectionNeeded;
41
42
  private onIceConnected?: OnIceConnected;
42
43
  private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
43
44
  protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
@@ -224,11 +225,17 @@ export abstract class BasePeerConnection {
224
225
  * Appends the trickled ICE candidates to the `RTCPeerConnection`.
225
226
  */
226
227
  protected addTrickledIceCandidates = () => {
228
+ // Declare the ICE generation this negotiation established so the buffer
229
+ // only replays candidates of the current generation.
227
230
  const { iceTrickleBuffer } = this.sfuClient;
231
+ const sdp = this.pc.remoteDescription?.sdp;
232
+ iceTrickleBuffer.updateActiveGeneration(this.peerType, sdp);
233
+
234
+ const { subscriber, publisher } = iceTrickleBuffer;
228
235
  const observable =
229
236
  this.peerType === PeerType.SUBSCRIBER
230
- ? iceTrickleBuffer.subscriberCandidates
231
- : iceTrickleBuffer.publisherCandidates;
237
+ ? subscriber.candidates
238
+ : publisher.candidates;
232
239
 
233
240
  this.unsubscribeIceTrickle?.();
234
241
  this.unsubscribeIceTrickle = createSafeAsyncSubscription(
@@ -283,20 +290,6 @@ export abstract class BasePeerConnection {
283
290
  return !failedStates.has(iceState) && !failedStates.has(connectionState);
284
291
  };
285
292
 
286
- /**
287
- * Returns true only when the peer connection is currently fully established
288
- * (ICE `connected`/`completed` AND connection state `connected`).
289
- * Transient states like `disconnected`, `checking`, or `new` return false.
290
- */
291
- isStable = () => {
292
- const iceState = this.pc.iceConnectionState;
293
- const connectionState = this.pc.connectionState;
294
- return (
295
- (iceState === 'connected' || iceState === 'completed') &&
296
- connectionState === 'connected'
297
- );
298
- };
299
-
300
293
  /**
301
294
  * Handles the ICECandidate event and
302
295
  * Initiates an ICE Trickle process with the SFU.
@@ -308,7 +301,7 @@ export abstract class BasePeerConnection {
308
301
  return;
309
302
  }
310
303
 
311
- const iceCandidate = this.asJSON(candidate);
304
+ const iceCandidate = toJSON(candidate);
312
305
  this.sfuClient
313
306
  .iceTrickle({ peerType: this.peerType, iceCandidate })
314
307
  .catch((err) => {
@@ -317,20 +310,6 @@ export abstract class BasePeerConnection {
317
310
  });
318
311
  };
319
312
 
320
- /**
321
- * Converts the ICE candidate to a JSON string.
322
- */
323
- private asJSON = (candidate: RTCIceCandidate): string => {
324
- if (!candidate.usernameFragment) {
325
- // react-native-webrtc doesn't include usernameFragment in the candidate
326
- const segments = candidate.candidate.split(' ');
327
- const ufragIndex = segments.findIndex((s) => s === 'ufrag') + 1;
328
- const usernameFragment = segments[ufragIndex];
329
- return JSON.stringify({ ...candidate, usernameFragment });
330
- }
331
- return JSON.stringify(candidate.toJSON());
332
- };
333
-
334
313
  /**
335
314
  * Handles the ConnectionStateChange event.
336
315
  */
@@ -343,8 +322,10 @@ export abstract class BasePeerConnection {
343
322
  });
344
323
  if (this.tracer && (state === 'connected' || state === 'failed')) {
345
324
  try {
346
- const stats = await this.stats.get();
347
- this.tracer.trace('getstats', stats.delta);
325
+ // Sample stats into the delivery chain at connect/fail. The reporter
326
+ // ships and commits the un-acked chain, so we must not trace the delta
327
+ // separately here (that would double-send it and corrupt the chain).
328
+ await this.stats.takeSample();
348
329
  } catch (err) {
349
330
  this.tracer.trace('getstatsOnFailure', (err as Error).toString());
350
331
  }