@stream-io/video-client 1.5.0-0 → 1.5.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +6 -230
  2. package/dist/index.browser.es.js +1498 -1963
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1495 -1961
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1498 -1963
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +9 -93
  9. package/dist/src/StreamSfuClient.d.ts +56 -72
  10. package/dist/src/StreamVideoClient.d.ts +10 -2
  11. package/dist/src/coordinator/connection/client.d.ts +4 -3
  12. package/dist/src/coordinator/connection/types.d.ts +1 -5
  13. package/dist/src/devices/InputMediaDeviceManager.d.ts +0 -4
  14. package/dist/src/devices/MicrophoneManager.d.ts +1 -1
  15. package/dist/src/events/callEventHandlers.d.ts +3 -1
  16. package/dist/src/events/internal.d.ts +0 -4
  17. package/dist/src/gen/video/sfu/event/events.d.ts +4 -106
  18. package/dist/src/gen/video/sfu/models/models.d.ts +65 -64
  19. package/dist/src/logger.d.ts +0 -1
  20. package/dist/src/rpc/createClient.d.ts +0 -2
  21. package/dist/src/rpc/index.d.ts +0 -1
  22. package/dist/src/rtc/Dispatcher.d.ts +1 -1
  23. package/dist/src/rtc/IceTrickleBuffer.d.ts +1 -0
  24. package/dist/src/rtc/Publisher.d.ts +25 -24
  25. package/dist/src/rtc/Subscriber.d.ts +11 -12
  26. package/dist/src/rtc/flows/join.d.ts +20 -0
  27. package/dist/src/rtc/helpers/tracks.d.ts +3 -3
  28. package/dist/src/rtc/signal.d.ts +1 -1
  29. package/dist/src/store/CallState.d.ts +2 -46
  30. package/package.json +3 -3
  31. package/src/Call.ts +562 -615
  32. package/src/StreamSfuClient.ts +246 -277
  33. package/src/StreamVideoClient.ts +65 -15
  34. package/src/coordinator/connection/client.ts +8 -25
  35. package/src/coordinator/connection/connection.ts +0 -1
  36. package/src/coordinator/connection/token_manager.ts +1 -1
  37. package/src/coordinator/connection/types.ts +0 -6
  38. package/src/devices/BrowserPermission.ts +1 -5
  39. package/src/devices/CameraManager.ts +1 -1
  40. package/src/devices/InputMediaDeviceManager.ts +3 -12
  41. package/src/devices/MicrophoneManager.ts +3 -3
  42. package/src/devices/devices.ts +1 -1
  43. package/src/events/__tests__/mutes.test.ts +13 -10
  44. package/src/events/__tests__/participant.test.ts +0 -75
  45. package/src/events/callEventHandlers.ts +7 -4
  46. package/src/events/internal.ts +3 -20
  47. package/src/events/mutes.ts +3 -5
  48. package/src/events/participant.ts +15 -48
  49. package/src/gen/video/sfu/event/events.ts +8 -451
  50. package/src/gen/video/sfu/models/models.ts +204 -211
  51. package/src/logger.ts +1 -3
  52. package/src/rpc/createClient.ts +0 -21
  53. package/src/rpc/index.ts +0 -1
  54. package/src/rtc/Dispatcher.ts +2 -6
  55. package/src/rtc/IceTrickleBuffer.ts +2 -2
  56. package/src/rtc/Publisher.ts +163 -127
  57. package/src/rtc/Subscriber.ts +155 -94
  58. package/src/rtc/__tests__/Publisher.test.ts +95 -18
  59. package/src/rtc/__tests__/Subscriber.test.ts +99 -63
  60. package/src/rtc/__tests__/videoLayers.test.ts +2 -2
  61. package/src/rtc/flows/join.ts +65 -0
  62. package/src/rtc/helpers/tracks.ts +7 -27
  63. package/src/rtc/signal.ts +3 -3
  64. package/src/rtc/videoLayers.ts +10 -1
  65. package/src/stats/SfuStatsReporter.ts +0 -1
  66. package/src/store/CallState.ts +2 -109
  67. package/src/store/__tests__/CallState.test.ts +37 -48
  68. package/dist/src/helpers/ensureExhausted.d.ts +0 -1
  69. package/dist/src/helpers/withResolvers.d.ts +0 -14
  70. package/dist/src/rpc/retryable.d.ts +0 -23
  71. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +0 -2
  72. package/src/helpers/ensureExhausted.ts +0 -5
  73. package/src/helpers/withResolvers.ts +0 -43
  74. package/src/rpc/__tests__/retryable.test.ts +0 -72
  75. package/src/rpc/retryable.ts +0 -57
  76. package/src/rtc/helpers/rtcConfiguration.ts +0 -11
@@ -5,27 +5,23 @@ import { SubscriberOffer } from '../gen/video/sfu/event/events';
5
5
  import { Dispatcher } from './Dispatcher';
6
6
  import { getLogger } from '../logger';
7
7
  import { CallingState, CallState } from '../store';
8
- import { withoutConcurrency } from '../helpers/concurrency';
9
- import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
10
- import { Logger } from '../coordinator/connection/types';
11
8
 
12
9
  export type SubscriberOpts = {
13
10
  sfuClient: StreamSfuClient;
14
11
  dispatcher: Dispatcher;
15
12
  state: CallState;
16
13
  connectionConfig?: RTCConfiguration;
14
+ iceRestartDelay?: number;
17
15
  onUnrecoverableError?: () => void;
18
- logTag: string;
19
16
  };
20
17
 
18
+ const logger = getLogger(['Subscriber']);
19
+
21
20
  /**
22
21
  * A wrapper around the `RTCPeerConnection` that handles the incoming
23
22
  * media streams from the SFU.
24
- *
25
- * @internal
26
23
  */
27
24
  export class Subscriber {
28
- private readonly logger: Logger;
29
25
  private pc: RTCPeerConnection;
30
26
  private sfuClient: StreamSfuClient;
31
27
  private state: CallState;
@@ -34,7 +30,9 @@ export class Subscriber {
34
30
  private readonly unregisterOnIceRestart: () => void;
35
31
  private readonly onUnrecoverableError?: () => void;
36
32
 
33
+ private readonly iceRestartDelay: number;
37
34
  private isIceRestarting = false;
35
+ private iceRestartTimeout?: NodeJS.Timeout;
38
36
 
39
37
  // workaround for the lack of RTCPeerConnection.getConfiguration() method in react-native-webrtc
40
38
  private _connectionConfiguration: RTCConfiguration | undefined;
@@ -58,44 +56,35 @@ export class Subscriber {
58
56
  * @param connectionConfig the connection configuration to use.
59
57
  * @param iceRestartDelay the delay in milliseconds to wait before restarting ICE when connection goes to `disconnected` state.
60
58
  * @param onUnrecoverableError a callback to call when an unrecoverable error occurs.
61
- * @param logTag a tag to use for logging.
62
59
  */
63
60
  constructor({
64
61
  sfuClient,
65
62
  dispatcher,
66
63
  state,
67
64
  connectionConfig,
65
+ iceRestartDelay = 2500,
68
66
  onUnrecoverableError,
69
- logTag,
70
67
  }: SubscriberOpts) {
71
- this.logger = getLogger(['Subscriber', logTag]);
72
68
  this.sfuClient = sfuClient;
73
69
  this.state = state;
70
+ this.iceRestartDelay = iceRestartDelay;
74
71
  this.onUnrecoverableError = onUnrecoverableError;
75
72
 
76
73
  this.pc = this.createPeerConnection(connectionConfig);
77
74
 
78
- const subscriberOfferConcurrencyTag = Symbol('subscriberOffer');
79
75
  this.unregisterOnSubscriberOffer = dispatcher.on(
80
76
  'subscriberOffer',
81
77
  (subscriberOffer) => {
82
- // TODO: use queue per peer connection, otherwise
83
- // it could happen we consume an offer for a different peer connection
84
- withoutConcurrency(subscriberOfferConcurrencyTag, () => {
85
- return this.negotiate(subscriberOffer);
86
- }).catch((err) => {
87
- this.logger('warn', `Negotiation failed.`, err);
78
+ this.negotiate(subscriberOffer).catch((err) => {
79
+ logger('warn', `Negotiation failed.`, err);
88
80
  });
89
81
  },
90
82
  );
91
83
 
92
- const iceRestartConcurrencyTag = Symbol('iceRestart');
93
84
  this.unregisterOnIceRestart = dispatcher.on('iceRestart', (iceRestart) => {
94
- withoutConcurrency(iceRestartConcurrencyTag, async () => {
95
- if (iceRestart.peerType !== PeerType.SUBSCRIBER) return;
96
- await this.restartIce();
97
- }).catch((err) => {
98
- this.logger('warn', `ICERestart failed`, err);
85
+ if (iceRestart.peerType !== PeerType.SUBSCRIBER) return;
86
+ this.restartIce().catch((err) => {
87
+ logger('warn', `ICERestart failed`, err);
99
88
  this.onUnrecoverableError?.();
100
89
  });
101
90
  });
@@ -129,30 +118,10 @@ export class Subscriber {
129
118
  * Closes the `RTCPeerConnection` and unsubscribes from the dispatcher.
130
119
  */
131
120
  close = () => {
132
- this.detachEventHandlers();
133
- this.pc.close();
134
- };
135
-
136
- /**
137
- * Detaches the event handlers from the `RTCPeerConnection`.
138
- * This is useful when we want to replace the `RTCPeerConnection`
139
- * instance with a new one (in case of migration).
140
- */
141
- detachEventHandlers = () => {
121
+ clearTimeout(this.iceRestartTimeout);
142
122
  this.unregisterOnSubscriberOffer();
143
123
  this.unregisterOnIceRestart();
144
-
145
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
146
- this.pc.removeEventListener('track', this.handleOnTrack);
147
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
148
- this.pc.removeEventListener(
149
- 'iceconnectionstatechange',
150
- this.onIceConnectionStateChange,
151
- );
152
- this.pc.removeEventListener(
153
- 'icegatheringstatechange',
154
- this.onIceGatheringStateChange,
155
- );
124
+ this.pc.close();
156
125
  };
157
126
 
158
127
  /**
@@ -173,17 +142,95 @@ export class Subscriber {
173
142
  this.sfuClient = sfuClient;
174
143
  };
175
144
 
145
+ /**
146
+ * Migrates the subscriber to a new SFU client.
147
+ *
148
+ * @param sfuClient the new SFU client to migrate to.
149
+ * @param connectionConfig the new connection configuration to use.
150
+ */
151
+ migrateTo = (
152
+ sfuClient: StreamSfuClient,
153
+ connectionConfig?: RTCConfiguration,
154
+ ) => {
155
+ this.setSfuClient(sfuClient);
156
+
157
+ // when migrating, we want to keep the previous subscriber open
158
+ // until the new one is connected
159
+ const previousPC = this.pc;
160
+
161
+ // we keep a record of previously available video tracks
162
+ // so that we can monitor when they become available on the new
163
+ // subscriber and close the previous one.
164
+ const trackIdsToMigrate = new Set<string>();
165
+ previousPC.getReceivers().forEach((r) => {
166
+ if (r.track.kind === 'video') {
167
+ trackIdsToMigrate.add(r.track.id);
168
+ }
169
+ });
170
+
171
+ // set up a new subscriber peer connection, configured to connect
172
+ // to the new SFU node
173
+ const pc = this.createPeerConnection(connectionConfig);
174
+
175
+ let migrationTimeoutId: NodeJS.Timeout;
176
+ const cleanupMigration = () => {
177
+ previousPC.close();
178
+ clearTimeout(migrationTimeoutId);
179
+ };
180
+
181
+ // When migrating, we want to keep track of the video tracks
182
+ // that are migrating to the new subscriber.
183
+ // Once all of them are available, we can close the previous subscriber.
184
+ const handleTrackMigration = (e: RTCTrackEvent) => {
185
+ logger(
186
+ 'debug',
187
+ `[Migration]: Migrated track: ${e.track.id}, ${e.track.kind}`,
188
+ );
189
+ trackIdsToMigrate.delete(e.track.id);
190
+ if (trackIdsToMigrate.size === 0) {
191
+ logger('debug', `[Migration]: Migration complete`);
192
+ pc.removeEventListener('track', handleTrackMigration);
193
+ cleanupMigration();
194
+ }
195
+ };
196
+
197
+ // When migrating, we want to keep track of the connection state
198
+ // of the new subscriber.
199
+ // Once it is connected, we give it a 2-second grace period to receive
200
+ // all the video tracks that are migrating from the previous subscriber.
201
+ // After this threshold, we abruptly close the previous subscriber.
202
+ const handleConnectionStateChange = () => {
203
+ if (pc.connectionState === 'connected') {
204
+ migrationTimeoutId = setTimeout(() => {
205
+ pc.removeEventListener('track', handleTrackMigration);
206
+ cleanupMigration();
207
+ }, 2000);
208
+
209
+ pc.removeEventListener(
210
+ 'connectionstatechange',
211
+ handleConnectionStateChange,
212
+ );
213
+ }
214
+ };
215
+
216
+ pc.addEventListener('track', handleTrackMigration);
217
+ pc.addEventListener('connectionstatechange', handleConnectionStateChange);
218
+
219
+ // replace the PeerConnection instance
220
+ this.pc = pc;
221
+ };
222
+
176
223
  /**
177
224
  * Restarts the ICE connection and renegotiates with the SFU.
178
225
  */
179
226
  restartIce = async () => {
180
- this.logger('debug', 'Restarting ICE connection');
227
+ logger('debug', 'Restarting ICE connection');
181
228
  if (this.pc.signalingState === 'have-remote-offer') {
182
- this.logger('debug', 'ICE restart is already in progress');
229
+ logger('debug', 'ICE restart is already in progress');
183
230
  return;
184
231
  }
185
232
  if (this.pc.connectionState === 'new') {
186
- this.logger(
233
+ logger(
187
234
  'debug',
188
235
  `ICE connection is not yet established, skipping restart.`,
189
236
  );
@@ -205,59 +252,54 @@ export class Subscriber {
205
252
  private handleOnTrack = (e: RTCTrackEvent) => {
206
253
  const [primaryStream] = e.streams;
207
254
  // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
208
- const [trackId, rawTrackType] = primaryStream.id.split(':');
255
+ const [trackId, trackType] = primaryStream.id.split(':');
209
256
  const participantToUpdate = this.state.participants.find(
210
257
  (p) => p.trackLookupPrefix === trackId,
211
258
  );
212
- this.logger(
259
+ logger(
213
260
  'debug',
214
- `[onTrack]: Got remote ${rawTrackType} track for userId: ${participantToUpdate?.userId}`,
261
+ `[onTrack]: Got remote ${trackType} track for userId: ${participantToUpdate?.userId}`,
215
262
  e.track.id,
216
263
  e.track,
217
264
  );
265
+ if (!participantToUpdate) {
266
+ logger(
267
+ 'warn',
268
+ `[onTrack]: Received track for unknown participant: ${trackId}`,
269
+ e,
270
+ );
271
+ return;
272
+ }
218
273
 
219
- const trackDebugInfo = `${participantToUpdate?.userId} ${rawTrackType}:${trackId}`;
274
+ const trackDebugInfo = `${participantToUpdate.userId} ${trackType}:${trackId}`;
220
275
  e.track.addEventListener('mute', () => {
221
- this.logger('info', `[onTrack]: Track muted: ${trackDebugInfo}`);
276
+ logger('info', `[onTrack]: Track muted: ${trackDebugInfo}`);
222
277
  });
223
278
 
224
279
  e.track.addEventListener('unmute', () => {
225
- this.logger('info', `[onTrack]: Track unmuted: ${trackDebugInfo}`);
280
+ logger('info', `[onTrack]: Track unmuted: ${trackDebugInfo}`);
226
281
  });
227
282
 
228
283
  e.track.addEventListener('ended', () => {
229
- this.logger('info', `[onTrack]: Track ended: ${trackDebugInfo}`);
230
- this.state.removeOrphanedTrack(primaryStream.id);
284
+ logger('info', `[onTrack]: Track ended: ${trackDebugInfo}`);
231
285
  });
232
286
 
233
- const trackType = toTrackType(rawTrackType);
234
- if (!trackType) {
235
- return this.logger('error', `Unknown track type: ${rawTrackType}`);
236
- }
237
-
238
- if (!participantToUpdate) {
239
- this.logger(
240
- 'warn',
241
- `[onTrack]: Received track for unknown participant: ${trackId}`,
242
- e,
243
- );
244
- this.state.registerOrphanedTrack({
245
- id: primaryStream.id,
246
- trackLookupPrefix: trackId,
247
- track: primaryStream,
248
- trackType,
249
- });
250
- return;
251
- }
287
+ const streamKindProp = (
288
+ {
289
+ TRACK_TYPE_AUDIO: 'audioStream',
290
+ TRACK_TYPE_VIDEO: 'videoStream',
291
+ TRACK_TYPE_SCREEN_SHARE: 'screenShareStream',
292
+ TRACK_TYPE_SCREEN_SHARE_AUDIO: 'screenShareAudioStream',
293
+ } as const
294
+ )[trackType];
252
295
 
253
- const streamKindProp = trackTypeToParticipantStreamKey(trackType);
254
296
  if (!streamKindProp) {
255
- this.logger('error', `Unknown track type: ${rawTrackType}`);
297
+ logger('error', `Unknown track type: ${trackType}`);
256
298
  return;
257
299
  }
258
300
  const previousStream = participantToUpdate[streamKindProp];
259
301
  if (previousStream) {
260
- this.logger(
302
+ logger(
261
303
  'info',
262
304
  `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`,
263
305
  );
@@ -274,7 +316,7 @@ export class Subscriber {
274
316
  private onIceCandidate = (e: RTCPeerConnectionIceEvent) => {
275
317
  const { candidate } = e;
276
318
  if (!candidate) {
277
- this.logger('debug', 'null ice candidate');
319
+ logger('debug', 'null ice candidate');
278
320
  return;
279
321
  }
280
322
 
@@ -284,12 +326,12 @@ export class Subscriber {
284
326
  peerType: PeerType.SUBSCRIBER,
285
327
  })
286
328
  .catch((err) => {
287
- this.logger('warn', `ICETrickle failed`, err);
329
+ logger('warn', `ICETrickle failed`, err);
288
330
  });
289
331
  };
290
332
 
291
333
  private negotiate = async (subscriberOffer: SubscriberOffer) => {
292
- this.logger('info', `Received subscriberOffer`, subscriberOffer);
334
+ logger('info', `Received subscriberOffer`, subscriberOffer);
293
335
 
294
336
  await this.pc.setRemoteDescription({
295
337
  type: 'offer',
@@ -302,7 +344,7 @@ export class Subscriber {
302
344
  const iceCandidate = JSON.parse(candidate.iceCandidate);
303
345
  await this.pc.addIceCandidate(iceCandidate);
304
346
  } catch (e) {
305
- this.logger('warn', `ICE candidate error`, [e, candidate]);
347
+ logger('warn', `ICE candidate error`, [e, candidate]);
306
348
  }
307
349
  },
308
350
  );
@@ -320,28 +362,47 @@ export class Subscriber {
320
362
 
321
363
  private onIceConnectionStateChange = () => {
322
364
  const state = this.pc.iceConnectionState;
323
- this.logger('debug', `ICE connection state changed`, state);
324
-
325
- if (this.state.callingState === CallingState.RECONNECTING) return;
365
+ logger('debug', `ICE connection state changed`, state);
326
366
 
327
367
  // do nothing when ICE is restarting
328
368
  if (this.isIceRestarting) return;
329
369
 
330
- if (state === 'failed' || state === 'disconnected') {
331
- this.logger('debug', `Attempting to restart ICE`);
370
+ const hasNetworkConnection =
371
+ this.state.callingState !== CallingState.OFFLINE;
372
+
373
+ if (state === 'failed') {
374
+ logger('debug', `Attempting to restart ICE`);
332
375
  this.restartIce().catch((e) => {
333
- this.logger('error', `ICE restart failed`, e);
376
+ logger('error', `ICE restart failed`, e);
334
377
  this.onUnrecoverableError?.();
335
378
  });
379
+ } else if (state === 'disconnected' && hasNetworkConnection) {
380
+ // when in `disconnected` state, the browser may recover automatically,
381
+ // hence, we delay the ICE restart
382
+ logger('debug', `Scheduling ICE restart in ${this.iceRestartDelay} ms.`);
383
+ this.iceRestartTimeout = setTimeout(() => {
384
+ // check if the state is still `disconnected` or `failed`
385
+ // as the connection may have recovered (or failed) in the meantime
386
+ if (
387
+ this.pc.iceConnectionState === 'disconnected' ||
388
+ this.pc.iceConnectionState === 'failed'
389
+ ) {
390
+ this.restartIce().catch((e) => {
391
+ logger('error', `ICE restart failed`, e);
392
+ this.onUnrecoverableError?.();
393
+ });
394
+ } else {
395
+ logger(
396
+ 'debug',
397
+ `Scheduled ICE restart: connection recovered, canceled.`,
398
+ );
399
+ }
400
+ }, this.iceRestartDelay);
336
401
  }
337
402
  };
338
403
 
339
404
  private onIceGatheringStateChange = () => {
340
- this.logger(
341
- 'debug',
342
- `ICE gathering state changed`,
343
- this.pc.iceGatheringState,
344
- );
405
+ logger('debug', `ICE gathering state changed`, this.pc.iceGatheringState);
345
406
  };
346
407
 
347
408
  private onIceCandidateError = (e: Event) => {
@@ -351,6 +412,6 @@ export class Subscriber {
351
412
  const iceState = this.pc.iceConnectionState;
352
413
  const logLevel =
353
414
  iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
354
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
415
+ logger(logLevel, `ICE Candidate error`, errorMessage);
355
416
  };
356
417
  }
@@ -4,10 +4,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
4
  import { Publisher } from '../Publisher';
5
5
  import { CallState } from '../../store';
6
6
  import { StreamSfuClient } from '../../StreamSfuClient';
7
- import { DispatchableMessage, Dispatcher } from '../Dispatcher';
7
+ import { Dispatcher } from '../Dispatcher';
8
8
  import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
9
- import { SfuEvent } from '../../gen/video/sfu/event/events';
10
9
  import { IceTrickleBuffer } from '../IceTrickleBuffer';
10
+ import { SfuEvent } from '../../gen/video/sfu/event/events';
11
11
 
12
12
  vi.mock('../../StreamSfuClient', () => {
13
13
  console.log('MOCKING StreamSfuClient');
@@ -40,22 +40,14 @@ describe('Publisher', () => {
40
40
  dispatcher = new Dispatcher();
41
41
  sfuClient = new StreamSfuClient({
42
42
  dispatcher,
43
- sessionId: 'session-id-test',
44
- credentials: {
45
- server: {
46
- url: 'https://getstream.io/',
47
- ws_endpoint: 'https://getstream.io/ws',
48
- edge_name: 'sfu-1',
49
- },
50
- token: 'token',
51
- ice_servers: [],
43
+ sfuServer: {
44
+ url: 'https://getstream.io/',
45
+ ws_endpoint: 'https://getstream.io/ws',
46
+ edge_name: 'sfu-1',
52
47
  },
53
- logTag: 'test',
48
+ token: 'token',
54
49
  });
55
50
 
56
- // @ts-expect-error readonly field
57
- sfuClient.iceTrickleBuffer = new IceTrickleBuffer();
58
-
59
51
  // @ts-ignore
60
52
  sfuClient['sessionId'] = sessionId;
61
53
 
@@ -66,7 +58,7 @@ describe('Publisher', () => {
66
58
  state,
67
59
  isDtxEnabled: true,
68
60
  isRedEnabled: true,
69
- logTag: 'test',
61
+ iceRestartDelay: 100,
70
62
  });
71
63
  });
72
64
 
@@ -218,6 +210,74 @@ describe('Publisher', () => {
218
210
  expect(addEventListenerSpy).not.toHaveBeenCalled();
219
211
  });
220
212
 
213
+ describe('Publisher migration', () => {
214
+ it('should update the sfuClient and peer connection configuration', async () => {
215
+ const newSfuClient = new StreamSfuClient({
216
+ dispatcher: new Dispatcher(),
217
+ sfuServer: {
218
+ url: 'https://getstream.io/',
219
+ ws_endpoint: 'https://getstream.io/ws',
220
+ edge_name: 'sfu-1',
221
+ },
222
+ token: 'token',
223
+ });
224
+
225
+ const newPeerConnectionConfig = {
226
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
227
+ };
228
+
229
+ vi.spyOn(publisher['pc'], 'setConfiguration');
230
+ // @ts-ignore
231
+ publisher['pc'].iceConnectionState = 'connected';
232
+ // @ts-ignore
233
+ vi.spyOn(publisher, 'negotiate').mockReturnValue(Promise.resolve());
234
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
235
+
236
+ await publisher.migrateTo(newSfuClient, newPeerConnectionConfig);
237
+
238
+ expect(publisher['sfuClient']).toEqual(newSfuClient);
239
+ expect(publisher['pc'].setConfiguration).toHaveBeenCalledWith(
240
+ newPeerConnectionConfig,
241
+ );
242
+ expect(publisher['negotiate']).toHaveBeenCalledWith({ iceRestart: true });
243
+ });
244
+
245
+ it('should initiate ICE Restart when there are published tracks', async () => {
246
+ vi.spyOn(publisher['pc'], 'getTransceivers').mockReturnValue([]);
247
+ // @ts-ignore
248
+ sfuClient['iceTrickleBuffer'] = new IceTrickleBuffer();
249
+ sfuClient.setPublisher = vi.fn().mockResolvedValue({
250
+ response: {
251
+ sessionId: 'new-session-id',
252
+ sdp: 'new-sdp',
253
+ iceRestart: false,
254
+ },
255
+ });
256
+
257
+ // @ts-ignore
258
+ publisher['pc'].iceConnectionState = 'connected';
259
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
260
+ vi.spyOn(publisher, 'getCurrentTrackInfos').mockReturnValue([
261
+ // @ts-expect-error
262
+ { layers: [], trackType: TrackType.AUDIO, mid: '0' },
263
+ ]);
264
+
265
+ await publisher.migrateTo(sfuClient, {
266
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
267
+ });
268
+
269
+ expect(publisher['pc'].createOffer).toHaveBeenCalledWith({
270
+ iceRestart: true,
271
+ });
272
+ expect(publisher['pc'].setLocalDescription).toHaveBeenCalled();
273
+ expect(publisher['pc'].setRemoteDescription).toHaveBeenCalledWith({
274
+ type: 'answer',
275
+ sdp: 'new-sdp',
276
+ });
277
+ expect(sfuClient.setPublisher).toHaveBeenCalled();
278
+ });
279
+ });
280
+
221
281
  describe('Publisher ICE Restart', () => {
222
282
  it('should perform ICE restart when iceRestart event is received', () => {
223
283
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
@@ -229,7 +289,7 @@ describe('Publisher', () => {
229
289
  peerType: PeerType.PUBLISHER_UNSPECIFIED,
230
290
  },
231
291
  },
232
- }) as DispatchableMessage<'iceRestart'>,
292
+ }),
233
293
  );
234
294
  expect(publisher.restartIce).toHaveBeenCalled();
235
295
  });
@@ -244,7 +304,7 @@ describe('Publisher', () => {
244
304
  peerType: PeerType.SUBSCRIBER,
245
305
  },
246
306
  },
247
- }) as DispatchableMessage<'iceRestart'>,
307
+ }),
248
308
  );
249
309
  expect(publisher.restartIce).not.toHaveBeenCalled();
250
310
  });
@@ -269,10 +329,27 @@ describe('Publisher', () => {
269
329
 
270
330
  it(`should perform ICE restart when connection state changes to 'disconnected'`, () => {
271
331
  vi.spyOn(publisher, 'restartIce').mockResolvedValue();
332
+ vi.useFakeTimers();
333
+
272
334
  // @ts-ignore
273
335
  publisher['pc'].iceConnectionState = 'disconnected';
274
336
  publisher['onIceConnectionStateChange']();
337
+ vi.runAllTimers();
275
338
  expect(publisher.restartIce).toHaveBeenCalled();
276
339
  });
340
+
341
+ it(`should bail-out from ICE restart once connection recovers before timeout`, () => {
342
+ vi.spyOn(publisher, 'restartIce').mockResolvedValue();
343
+ vi.useFakeTimers();
344
+
345
+ // @ts-ignore
346
+ publisher['pc'].iceConnectionState = 'disconnected';
347
+ publisher['onIceConnectionStateChange']();
348
+ // @ts-ignore
349
+ publisher['pc'].iceConnectionState = 'connected';
350
+
351
+ vi.runAllTimers();
352
+ expect(publisher.restartIce).not.toHaveBeenCalled();
353
+ });
277
354
  });
278
355
  });