@stream-io/video-client 1.24.0 → 1.25.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 (40) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/index.browser.es.js +335 -127
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +334 -126
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +335 -127
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/StreamSfuClient.d.ts +12 -4
  9. package/dist/src/StreamVideoClient.d.ts +3 -1
  10. package/dist/src/coordinator/connection/errors.d.ts +1 -0
  11. package/dist/src/gen/video/sfu/models/models.d.ts +4 -0
  12. package/dist/src/rtc/BasePeerConnection.d.ts +23 -4
  13. package/dist/src/rtc/NegotiationError.d.ts +15 -0
  14. package/dist/src/rtc/Publisher.d.ts +2 -2
  15. package/dist/src/rtc/helpers/sdp.d.ts +7 -0
  16. package/dist/src/types.d.ts +11 -0
  17. package/package.json +1 -1
  18. package/src/Call.ts +66 -38
  19. package/src/StreamSfuClient.ts +17 -7
  20. package/src/StreamVideoClient.ts +17 -7
  21. package/src/coordinator/connection/connection.ts +2 -1
  22. package/src/coordinator/connection/errors.ts +31 -0
  23. package/src/devices/ScreenShareManager.ts +12 -2
  24. package/src/devices/devices.ts +23 -12
  25. package/src/events/__tests__/internal.test.ts +1 -0
  26. package/src/gen/google/protobuf/struct.ts +2 -2
  27. package/src/gen/google/protobuf/timestamp.ts +1 -1
  28. package/src/gen/video/sfu/event/events.ts +15 -15
  29. package/src/gen/video/sfu/models/models.ts +9 -5
  30. package/src/gen/video/sfu/signal_rpc/signal.client.ts +1 -1
  31. package/src/gen/video/sfu/signal_rpc/signal.ts +6 -6
  32. package/src/rtc/BasePeerConnection.ts +132 -46
  33. package/src/rtc/NegotiationError.ts +21 -0
  34. package/src/rtc/Publisher.ts +12 -9
  35. package/src/rtc/Subscriber.ts +8 -2
  36. package/src/rtc/__tests__/Publisher.test.ts +160 -17
  37. package/src/rtc/__tests__/Subscriber.test.ts +31 -14
  38. package/src/rtc/helpers/__tests__/sdp.stereo.test.ts +120 -0
  39. package/src/rtc/helpers/sdp.ts +43 -1
  40. package/src/types.ts +12 -0
@@ -1,4 +1,4 @@
1
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
1
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
2
2
  // @generated from protobuf file "video/sfu/event/events.proto" (package "stream.video.sfu.event", syntax proto3)
3
3
  // tslint:disable
4
4
  import { MessageType } from '@protobuf-ts/runtime';
@@ -1048,7 +1048,7 @@ class ChangePublishOptions$Type extends MessageType<ChangePublishOptions> {
1048
1048
  no: 1,
1049
1049
  name: 'publish_options',
1050
1050
  kind: 'message',
1051
- repeat: 1 /*RepeatType.PACKED*/,
1051
+ repeat: 2 /*RepeatType.UNPACKED*/,
1052
1052
  T: () => PublishOption,
1053
1053
  },
1054
1054
  { no: 2, name: 'reason', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
@@ -1089,7 +1089,7 @@ class PinsChanged$Type extends MessageType<PinsChanged> {
1089
1089
  no: 1,
1090
1090
  name: 'pins',
1091
1091
  kind: 'message',
1092
- repeat: 1 /*RepeatType.PACKED*/,
1092
+ repeat: 2 /*RepeatType.UNPACKED*/,
1093
1093
  T: () => Pin,
1094
1094
  },
1095
1095
  ]);
@@ -1332,14 +1332,14 @@ class JoinRequest$Type extends MessageType<JoinRequest> {
1332
1332
  no: 9,
1333
1333
  name: 'preferred_publish_options',
1334
1334
  kind: 'message',
1335
- repeat: 1 /*RepeatType.PACKED*/,
1335
+ repeat: 2 /*RepeatType.UNPACKED*/,
1336
1336
  T: () => PublishOption,
1337
1337
  },
1338
1338
  {
1339
1339
  no: 10,
1340
1340
  name: 'preferred_subscribe_options',
1341
1341
  kind: 'message',
1342
- repeat: 1 /*RepeatType.PACKED*/,
1342
+ repeat: 2 /*RepeatType.UNPACKED*/,
1343
1343
  T: () => SubscribeOption,
1344
1344
  },
1345
1345
  ]);
@@ -1367,14 +1367,14 @@ class ReconnectDetails$Type extends MessageType<ReconnectDetails> {
1367
1367
  no: 3,
1368
1368
  name: 'announced_tracks',
1369
1369
  kind: 'message',
1370
- repeat: 1 /*RepeatType.PACKED*/,
1370
+ repeat: 2 /*RepeatType.UNPACKED*/,
1371
1371
  T: () => TrackInfo,
1372
1372
  },
1373
1373
  {
1374
1374
  no: 4,
1375
1375
  name: 'subscriptions',
1376
1376
  kind: 'message',
1377
- repeat: 1 /*RepeatType.PACKED*/,
1377
+ repeat: 2 /*RepeatType.UNPACKED*/,
1378
1378
  T: () => TrackSubscriptionDetails,
1379
1379
  },
1380
1380
  {
@@ -1417,14 +1417,14 @@ class Migration$Type extends MessageType<Migration> {
1417
1417
  no: 2,
1418
1418
  name: 'announced_tracks',
1419
1419
  kind: 'message',
1420
- repeat: 1 /*RepeatType.PACKED*/,
1420
+ repeat: 2 /*RepeatType.UNPACKED*/,
1421
1421
  T: () => TrackInfo,
1422
1422
  },
1423
1423
  {
1424
1424
  no: 3,
1425
1425
  name: 'subscriptions',
1426
1426
  kind: 'message',
1427
- repeat: 1 /*RepeatType.PACKED*/,
1427
+ repeat: 2 /*RepeatType.UNPACKED*/,
1428
1428
  T: () => TrackSubscriptionDetails,
1429
1429
  },
1430
1430
  ]);
@@ -1450,7 +1450,7 @@ class JoinResponse$Type extends MessageType<JoinResponse> {
1450
1450
  no: 4,
1451
1451
  name: 'publish_options',
1452
1452
  kind: 'message',
1453
- repeat: 1 /*RepeatType.PACKED*/,
1453
+ repeat: 2 /*RepeatType.UNPACKED*/,
1454
1454
  T: () => PublishOption,
1455
1455
  },
1456
1456
  ]);
@@ -1532,7 +1532,7 @@ class ConnectionQualityChanged$Type extends MessageType<ConnectionQualityChanged
1532
1532
  no: 1,
1533
1533
  name: 'connection_quality_updates',
1534
1534
  kind: 'message',
1535
- repeat: 1 /*RepeatType.PACKED*/,
1535
+ repeat: 2 /*RepeatType.UNPACKED*/,
1536
1536
  T: () => ConnectionQualityInfo,
1537
1537
  },
1538
1538
  ]);
@@ -1601,7 +1601,7 @@ class AudioLevelChanged$Type extends MessageType<AudioLevelChanged> {
1601
1601
  no: 1,
1602
1602
  name: 'audio_levels',
1603
1603
  kind: 'message',
1604
- repeat: 1 /*RepeatType.PACKED*/,
1604
+ repeat: 2 /*RepeatType.UNPACKED*/,
1605
1605
  T: () => AudioLevel,
1606
1606
  },
1607
1607
  ]);
@@ -1681,7 +1681,7 @@ class VideoSender$Type extends MessageType<VideoSender> {
1681
1681
  no: 3,
1682
1682
  name: 'layers',
1683
1683
  kind: 'message',
1684
- repeat: 1 /*RepeatType.PACKED*/,
1684
+ repeat: 2 /*RepeatType.UNPACKED*/,
1685
1685
  T: () => VideoLayerSetting,
1686
1686
  },
1687
1687
  {
@@ -1715,14 +1715,14 @@ class ChangePublishQuality$Type extends MessageType<ChangePublishQuality> {
1715
1715
  no: 1,
1716
1716
  name: 'audio_senders',
1717
1717
  kind: 'message',
1718
- repeat: 1 /*RepeatType.PACKED*/,
1718
+ repeat: 2 /*RepeatType.UNPACKED*/,
1719
1719
  T: () => AudioSender,
1720
1720
  },
1721
1721
  {
1722
1722
  no: 2,
1723
1723
  name: 'video_senders',
1724
1724
  kind: 'message',
1725
- repeat: 1 /*RepeatType.PACKED*/,
1725
+ repeat: 2 /*RepeatType.UNPACKED*/,
1726
1726
  T: () => VideoSender,
1727
1727
  },
1728
1728
  ]);
@@ -1,4 +1,4 @@
1
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
1
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
2
2
  // @generated from protobuf file "video/sfu/models/models.proto" (package "stream.video.sfu.models", syntax proto3)
3
3
  // tslint:disable
4
4
  import { MessageType } from '@protobuf-ts/runtime';
@@ -811,6 +811,10 @@ export enum ErrorCode {
811
811
  * @generated from protobuf enum value: ERROR_CODE_PARTICIPANT_MEDIA_TRANSPORT_FAILURE = 205;
812
812
  */
813
813
  PARTICIPANT_MEDIA_TRANSPORT_FAILURE = 205,
814
+ /**
815
+ * @generated from protobuf enum value: ERROR_CODE_PARTICIPANT_SIGNAL_LOST = 206;
816
+ */
817
+ PARTICIPANT_SIGNAL_LOST = 206,
814
818
  /**
815
819
  * @generated from protobuf enum value: ERROR_CODE_CALL_NOT_FOUND = 300;
816
820
  */
@@ -1084,7 +1088,7 @@ class CallState$Type extends MessageType<CallState> {
1084
1088
  no: 1,
1085
1089
  name: 'participants',
1086
1090
  kind: 'message',
1087
- repeat: 1 /*RepeatType.PACKED*/,
1091
+ repeat: 2 /*RepeatType.UNPACKED*/,
1088
1092
  T: () => Participant,
1089
1093
  },
1090
1094
  { no: 2, name: 'started_at', kind: 'message', T: () => Timestamp },
@@ -1098,7 +1102,7 @@ class CallState$Type extends MessageType<CallState> {
1098
1102
  no: 4,
1099
1103
  name: 'pins',
1100
1104
  kind: 'message',
1101
- repeat: 1 /*RepeatType.PACKED*/,
1105
+ repeat: 2 /*RepeatType.UNPACKED*/,
1102
1106
  T: () => Pin,
1103
1107
  },
1104
1108
  ]);
@@ -1276,7 +1280,7 @@ class SubscribeOption$Type extends MessageType<SubscribeOption> {
1276
1280
  no: 2,
1277
1281
  name: 'codecs',
1278
1282
  kind: 'message',
1279
- repeat: 1 /*RepeatType.PACKED*/,
1283
+ repeat: 2 /*RepeatType.UNPACKED*/,
1280
1284
  T: () => Codec,
1281
1285
  },
1282
1286
  ]);
@@ -1409,7 +1413,7 @@ class TrackInfo$Type extends MessageType<TrackInfo> {
1409
1413
  no: 5,
1410
1414
  name: 'layers',
1411
1415
  kind: 'message',
1412
- repeat: 1 /*RepeatType.PACKED*/,
1416
+ repeat: 2 /*RepeatType.UNPACKED*/,
1413
1417
  T: () => VideoLayer,
1414
1418
  },
1415
1419
  { no: 6, name: 'mid', kind: 'scalar', T: 9 /*ScalarType.STRING*/ },
@@ -1,4 +1,4 @@
1
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
1
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
2
2
  // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3)
3
3
  // tslint:disable
4
4
  import type {
@@ -1,4 +1,4 @@
1
- // @generated by protobuf-ts 2.9.6 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
1
+ // @generated by protobuf-ts 2.10.0 with parameter long_type_string,client_generic,server_none,eslint_disable,optimize_code_size
2
2
  // @generated from protobuf file "video/sfu/signal_rpc/signal.proto" (package "stream.video.sfu.signal", syntax proto3)
3
3
  // tslint:disable
4
4
  import {
@@ -566,14 +566,14 @@ class SendStatsRequest$Type extends MessageType<SendStatsRequest> {
566
566
  no: 16,
567
567
  name: 'encode_stats',
568
568
  kind: 'message',
569
- repeat: 1 /*RepeatType.PACKED*/,
569
+ repeat: 2 /*RepeatType.UNPACKED*/,
570
570
  T: () => PerformanceStats,
571
571
  },
572
572
  {
573
573
  no: 17,
574
574
  name: 'decode_stats',
575
575
  kind: 'message',
576
- repeat: 1 /*RepeatType.PACKED*/,
576
+ repeat: 2 /*RepeatType.UNPACKED*/,
577
577
  T: () => PerformanceStats,
578
578
  },
579
579
  {
@@ -640,7 +640,7 @@ class UpdateMuteStatesRequest$Type extends MessageType<UpdateMuteStatesRequest>
640
640
  no: 3,
641
641
  name: 'mute_states',
642
642
  kind: 'message',
643
- repeat: 1 /*RepeatType.PACKED*/,
643
+ repeat: 2 /*RepeatType.UNPACKED*/,
644
644
  T: () => TrackMuteState,
645
645
  },
646
646
  ]);
@@ -717,7 +717,7 @@ class UpdateSubscriptionsRequest$Type extends MessageType<UpdateSubscriptionsReq
717
717
  no: 3,
718
718
  name: 'tracks',
719
719
  kind: 'message',
720
- repeat: 1 /*RepeatType.PACKED*/,
720
+ repeat: 2 /*RepeatType.UNPACKED*/,
721
721
  T: () => TrackSubscriptionDetails,
722
722
  },
723
723
  ]);
@@ -817,7 +817,7 @@ class SetPublisherRequest$Type extends MessageType<SetPublisherRequest> {
817
817
  no: 3,
818
818
  name: 'tracks',
819
819
  kind: 'message',
820
- repeat: 1 /*RepeatType.PACKED*/,
820
+ repeat: 2 /*RepeatType.UNPACKED*/,
821
821
  T: () => TrackInfo,
822
822
  },
823
823
  ]);
@@ -5,20 +5,32 @@ import type {
5
5
  } from '../coordinator/connection/types';
6
6
  import { CallingState, CallState } from '../store';
7
7
  import { createSafeAsyncSubscription } from '../store/rxUtils';
8
- import { PeerType, TrackType } from '../gen/video/sfu/models/models';
8
+ import {
9
+ ErrorCode,
10
+ PeerType,
11
+ TrackType,
12
+ WebsocketReconnectStrategy,
13
+ } from '../gen/video/sfu/models/models';
14
+ import { NegotiationError } from './NegotiationError';
9
15
  import { StreamSfuClient } from '../StreamSfuClient';
10
16
  import { AllSfuEvents, Dispatcher } from './Dispatcher';
11
17
  import { withoutConcurrency } from '../helpers/concurrency';
12
18
  import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
13
19
 
20
+ export type OnReconnectionNeeded = (
21
+ kind: WebsocketReconnectStrategy,
22
+ reason: string,
23
+ ) => void;
24
+
14
25
  export type BasePeerConnectionOpts = {
15
26
  sfuClient: StreamSfuClient;
16
27
  state: CallState;
17
28
  connectionConfig?: RTCConfiguration;
18
29
  dispatcher: Dispatcher;
19
- onUnrecoverableError?: (reason: string) => void;
30
+ onReconnectionNeeded?: OnReconnectionNeeded;
20
31
  logTag: string;
21
32
  enableTracing: boolean;
33
+ iceRestartDelay?: number;
22
34
  };
23
35
 
24
36
  /**
@@ -33,7 +45,9 @@ export abstract class BasePeerConnection {
33
45
  protected readonly dispatcher: Dispatcher;
34
46
  protected sfuClient: StreamSfuClient;
35
47
 
36
- protected onUnrecoverableError?: (reason: string) => void;
48
+ private onReconnectionNeeded?: OnReconnectionNeeded;
49
+ private readonly iceRestartDelay: number;
50
+ private iceRestartTimeout?: NodeJS.Timeout;
37
51
  protected isIceRestarting = false;
38
52
  private isDisposed = false;
39
53
 
@@ -41,6 +55,7 @@ export abstract class BasePeerConnection {
41
55
 
42
56
  readonly tracer?: Tracer;
43
57
  readonly stats: StatsTracer;
58
+
44
59
  private readonly subscriptions: (() => void)[] = [];
45
60
  private unsubscribeIceTrickle?: () => void;
46
61
  protected readonly lock = Math.random().toString(36).slice(2);
@@ -55,33 +70,23 @@ export abstract class BasePeerConnection {
55
70
  connectionConfig,
56
71
  state,
57
72
  dispatcher,
58
- onUnrecoverableError,
73
+ onReconnectionNeeded,
59
74
  logTag,
60
75
  enableTracing,
76
+ iceRestartDelay = 2500,
61
77
  }: BasePeerConnectionOpts,
62
78
  ) {
63
79
  this.peerType = peerType;
64
80
  this.sfuClient = sfuClient;
65
81
  this.state = state;
66
82
  this.dispatcher = dispatcher;
67
- this.onUnrecoverableError = onUnrecoverableError;
83
+ this.iceRestartDelay = iceRestartDelay;
84
+ this.onReconnectionNeeded = onReconnectionNeeded;
68
85
  this.logger = getLogger([
69
86
  peerType === PeerType.SUBSCRIBER ? 'Subscriber' : 'Publisher',
70
87
  logTag,
71
88
  ]);
72
- this.pc = new RTCPeerConnection(connectionConfig);
73
- this.pc.addEventListener('icecandidate', this.onIceCandidate);
74
- this.pc.addEventListener('icecandidateerror', this.onIceCandidateError);
75
- this.pc.addEventListener(
76
- 'iceconnectionstatechange',
77
- this.onIceConnectionStateChange,
78
- );
79
- this.pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
80
- this.pc.addEventListener('signalingstatechange', this.onSignalingChange);
81
- this.pc.addEventListener(
82
- 'connectionstatechange',
83
- this.onConnectionStateChange,
84
- );
89
+ this.pc = this.createPeerConnection(connectionConfig);
85
90
  this.stats = new StatsTracer(this.pc, peerType, this.trackIdToTrackType);
86
91
  if (enableTracing) {
87
92
  const tag = `${logTag}-${peerType === PeerType.SUBSCRIBER ? 'sub' : 'pub'}`;
@@ -94,11 +99,27 @@ export abstract class BasePeerConnection {
94
99
  }
95
100
  }
96
101
 
102
+ private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
103
+ const pc = new RTCPeerConnection(connectionConfig);
104
+ pc.addEventListener('icecandidate', this.onIceCandidate);
105
+ pc.addEventListener('icecandidateerror', this.onIceCandidateError);
106
+ pc.addEventListener(
107
+ 'iceconnectionstatechange',
108
+ this.onIceConnectionStateChange,
109
+ );
110
+ pc.addEventListener('icegatheringstatechange', this.onIceGatherChange);
111
+ pc.addEventListener('signalingstatechange', this.onSignalingChange);
112
+ pc.addEventListener('connectionstatechange', this.onConnectionStateChange);
113
+ return pc;
114
+ };
115
+
97
116
  /**
98
117
  * Disposes the `RTCPeerConnection` instance.
99
118
  */
100
119
  dispose() {
101
- this.onUnrecoverableError = undefined;
120
+ clearTimeout(this.iceRestartTimeout);
121
+ this.iceRestartTimeout = undefined;
122
+ this.onReconnectionNeeded = undefined;
102
123
  this.isDisposed = true;
103
124
  this.detachEventHandlers();
104
125
  this.pc.close();
@@ -109,17 +130,15 @@ export abstract class BasePeerConnection {
109
130
  * Detaches the event handlers from the `RTCPeerConnection`.
110
131
  */
111
132
  detachEventHandlers() {
112
- this.pc.removeEventListener('icecandidate', this.onIceCandidate);
113
- this.pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
114
- this.pc.removeEventListener('signalingstatechange', this.onSignalingChange);
115
- this.pc.removeEventListener(
133
+ const pc = this.pc;
134
+ pc.removeEventListener('icecandidate', this.onIceCandidate);
135
+ pc.removeEventListener('icecandidateerror', this.onIceCandidateError);
136
+ pc.removeEventListener('signalingstatechange', this.onSignalingChange);
137
+ pc.removeEventListener(
116
138
  'iceconnectionstatechange',
117
139
  this.onIceConnectionStateChange,
118
140
  );
119
- this.pc.removeEventListener(
120
- 'icegatheringstatechange',
121
- this.onIceGatherChange,
122
- );
141
+ pc.removeEventListener('icegatheringstatechange', this.onIceGatherChange);
123
142
  this.unsubscribeIceTrickle?.();
124
143
  this.subscriptions.forEach((unsubscribe) => unsubscribe());
125
144
  }
@@ -129,6 +148,24 @@ export abstract class BasePeerConnection {
129
148
  */
130
149
  protected abstract restartIce(): Promise<void>;
131
150
 
151
+ /**
152
+ * Attempts to restart ICE on the `RTCPeerConnection`.
153
+ * This method intentionally doesn't await the `restartIce()` method,
154
+ * allowing it to run in the background and handle any errors that may occur.
155
+ */
156
+ protected tryRestartIce = () => {
157
+ this.restartIce().catch((e) => {
158
+ const reason = 'restartICE() failed, initiating reconnect';
159
+ this.logger('error', reason, e);
160
+ const strategy =
161
+ e instanceof NegotiationError &&
162
+ e.error.code === ErrorCode.PARTICIPANT_SIGNAL_LOST
163
+ ? WebsocketReconnectStrategy.FAST
164
+ : WebsocketReconnectStrategy.REJOIN;
165
+ this.onReconnectionNeeded?.(strategy, reason);
166
+ });
167
+ };
168
+
132
169
  /**
133
170
  * Handles events synchronously.
134
171
  * Consecutive events are queued and executed one after the other.
@@ -194,6 +231,22 @@ export abstract class BasePeerConnection {
194
231
  return this.trackIdToTrackType.get(trackId);
195
232
  };
196
233
 
234
+ /**
235
+ * Checks if the `RTCPeerConnection` is healthy.
236
+ * It checks the ICE connection state and the peer connection state.
237
+ * If either state is `failed`, `disconnected`, or `closed`,
238
+ * it returns `false`, otherwise it returns `true`.
239
+ */
240
+ isHealthy = () => {
241
+ const failedStates = new Set<
242
+ RTCIceConnectionState | RTCPeerConnectionState
243
+ >(['failed', 'closed']);
244
+
245
+ const iceState = this.pc.iceConnectionState;
246
+ const connectionState = this.pc.connectionState;
247
+ return !failedStates.has(iceState) && !failedStates.has(connectionState);
248
+ };
249
+
197
250
  /**
198
251
  * Handles the ICECandidate event and
199
252
  * Initiates an ICE Trickle process with the SFU.
@@ -234,8 +287,7 @@ export abstract class BasePeerConnection {
234
287
  private onConnectionStateChange = async () => {
235
288
  const state = this.pc.connectionState;
236
289
  this.logger('debug', `Connection state changed`, state);
237
- if (!this.tracer) return;
238
- if (state === 'connected' || state === 'failed') {
290
+ if (this.tracer && (state === 'connected' || state === 'failed')) {
239
291
  try {
240
292
  const stats = await this.stats.get();
241
293
  this.tracer.trace('getstats', stats.delta);
@@ -243,6 +295,17 @@ export abstract class BasePeerConnection {
243
295
  this.tracer.trace('getstatsOnFailure', (err as Error).toString());
244
296
  }
245
297
  }
298
+
299
+ // we can't recover from a failed connection state (contrary to ICE)
300
+ if (state === 'failed') {
301
+ this.onReconnectionNeeded?.(
302
+ WebsocketReconnectStrategy.REJOIN,
303
+ 'Connection failed',
304
+ );
305
+ return;
306
+ }
307
+
308
+ this.handleConnectionStateUpdate(state);
246
309
  };
247
310
 
248
311
  /**
@@ -251,22 +314,47 @@ export abstract class BasePeerConnection {
251
314
  private onIceConnectionStateChange = () => {
252
315
  const state = this.pc.iceConnectionState;
253
316
  this.logger('debug', `ICE connection state changed`, state);
317
+ this.handleConnectionStateUpdate(state);
318
+ };
254
319
 
255
- if (this.state.callingState === CallingState.OFFLINE) return;
256
- if (this.state.callingState === CallingState.RECONNECTING) return;
320
+ private handleConnectionStateUpdate = (
321
+ state: RTCIceConnectionState | RTCPeerConnectionState,
322
+ ) => {
323
+ const { callingState } = this.state;
324
+ if (callingState === CallingState.OFFLINE) return;
325
+ if (callingState === CallingState.RECONNECTING) return;
257
326
 
258
327
  // do nothing when ICE is restarting
259
328
  if (this.isIceRestarting) return;
260
329
 
261
- if (state === 'failed') {
262
- this.onUnrecoverableError?.('ICE connection failed');
263
- } else if (state === 'disconnected') {
264
- this.logger('debug', `Attempting to restart ICE`);
265
- this.restartIce().catch((e) => {
266
- const reason = `ICE restart failed`;
267
- this.logger('error', reason, e);
268
- this.onUnrecoverableError?.(`${reason}: ${e}`);
269
- });
330
+ switch (state) {
331
+ case 'failed':
332
+ // in the `failed` state, we try to restart ICE immediately
333
+ this.logger('info', 'restartICE due to failed connection');
334
+ this.tryRestartIce();
335
+ break;
336
+
337
+ case 'disconnected':
338
+ // in the `disconnected` state, we schedule a restartICE() after a delay
339
+ // as the browser might recover the connection in the meantime
340
+ this.logger('info', 'disconnected connection, scheduling restartICE');
341
+ clearTimeout(this.iceRestartTimeout);
342
+ this.iceRestartTimeout = setTimeout(() => {
343
+ const currentState = this.pc.iceConnectionState;
344
+ if (currentState === 'disconnected' || currentState === 'failed') {
345
+ this.tryRestartIce();
346
+ }
347
+ }, this.iceRestartDelay);
348
+ break;
349
+
350
+ case 'connected':
351
+ // in the `connected` state, we clear the ice restart timeout if it exists
352
+ if (this.iceRestartTimeout) {
353
+ this.logger('info', 'connected connection, canceling restartICE');
354
+ clearTimeout(this.iceRestartTimeout);
355
+ this.iceRestartTimeout = undefined;
356
+ }
357
+ break;
270
358
  }
271
359
  };
272
360
 
@@ -275,12 +363,10 @@ export abstract class BasePeerConnection {
275
363
  */
276
364
  private onIceCandidateError = (e: Event) => {
277
365
  const errorMessage =
278
- e instanceof RTCPeerConnectionIceErrorEvent &&
279
- `${e.errorCode}: ${e.errorText}`;
280
- const iceState = this.pc.iceConnectionState;
281
- const logLevel =
282
- iceState === 'connected' || iceState === 'checking' ? 'debug' : 'warn';
283
- this.logger(logLevel, `ICE Candidate error`, errorMessage);
366
+ e instanceof RTCPeerConnectionIceErrorEvent
367
+ ? `${e.errorCode}: ${e.errorText}`
368
+ : e;
369
+ this.logger('debug', 'ICE Candidate error', errorMessage);
284
370
  };
285
371
 
286
372
  /**
@@ -0,0 +1,21 @@
1
+ import { Error as SfuError } from '../gen/video/sfu/models/models';
2
+
3
+ /**
4
+ * NegotiationError is thrown when there is an error during the negotiation process.
5
+ * It extends the built-in Error class and includes an SfuError object for more details.
6
+ */
7
+ export class NegotiationError extends Error {
8
+ /**
9
+ * The SfuError object that contains details about the error.
10
+ */
11
+ error: SfuError;
12
+
13
+ /**
14
+ * Creates an instance of NegotiationError.
15
+ */
16
+ constructor(error: SfuError) {
17
+ super(error.message);
18
+ this.name = 'NegotiationError';
19
+ this.error = error;
20
+ }
21
+ }
@@ -2,6 +2,7 @@ import {
2
2
  BasePeerConnection,
3
3
  BasePeerConnectionOpts,
4
4
  } from './BasePeerConnection';
5
+ import { NegotiationError } from './NegotiationError';
5
6
  import { TransceiverCache } from './TransceiverCache';
6
7
  import {
7
8
  PeerType,
@@ -44,11 +45,7 @@ export class Publisher extends BasePeerConnection {
44
45
 
45
46
  this.on('iceRestart', (iceRestart) => {
46
47
  if (iceRestart.peerType !== PeerType.PUBLISHER_UNSPECIFIED) return;
47
- this.restartIce().catch((err) => {
48
- const reason = `ICE restart failed`;
49
- this.logger('warn', reason, err);
50
- this.onUnrecoverableError?.(`${reason}: ${err}`);
51
- });
48
+ this.tryRestartIce();
52
49
  });
53
50
 
54
51
  this.on('changePublishQuality', async (event) => {
@@ -189,11 +186,11 @@ export class Publisher extends BasePeerConnection {
189
186
  /**
190
187
  * Returns true if the given track type is currently being published to the SFU.
191
188
  *
192
- * @param trackType the track type to check.
189
+ * @param trackType the track type to check. If omitted, checks if any track is being published.
193
190
  */
194
- isPublishing = (trackType: TrackType): boolean => {
191
+ isPublishing = (trackType?: TrackType): boolean => {
195
192
  for (const item of this.transceiverCache.items()) {
196
- if (item.publishOption.trackType !== trackType) continue;
193
+ if (trackType && item.publishOption.trackType !== trackType) continue;
197
194
 
198
195
  const track = item.transceiver.sender.track;
199
196
  if (!track) continue;
@@ -338,10 +335,16 @@ export class Publisher extends BasePeerConnection {
338
335
 
339
336
  const { sdp = '' } = offer;
340
337
  const { response } = await this.sfuClient.setPublisher({ sdp, tracks });
341
- if (response.error) throw new Error(response.error.message);
338
+ if (response.error) throw new NegotiationError(response.error);
342
339
 
343
340
  const { sdp: answerSdp } = response;
344
341
  await this.pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
342
+ } catch (err) {
343
+ // negotiation failed, rollback to the previous state
344
+ if (this.pc.signalingState === 'have-local-offer') {
345
+ await this.pc.setLocalDescription({ type: 'rollback' });
346
+ }
347
+ throw err;
345
348
  } finally {
346
349
  this.isIceRestarting = false;
347
350
  }
@@ -2,9 +2,11 @@ import {
2
2
  BasePeerConnection,
3
3
  BasePeerConnectionOpts,
4
4
  } from './BasePeerConnection';
5
+ import { NegotiationError } from './NegotiationError';
5
6
  import { PeerType } from '../gen/video/sfu/models/models';
6
7
  import { SubscriberOffer } from '../gen/video/sfu/event/events';
7
8
  import { toTrackType, trackTypeToParticipantStreamKey } from './helpers/tracks';
9
+ import { enableStereo } from './helpers/sdp';
8
10
 
9
11
  /**
10
12
  * A wrapper around the `RTCPeerConnection` that handles the incoming
@@ -54,11 +56,12 @@ export class Subscriber extends BasePeerConnection {
54
56
  return;
55
57
  }
56
58
  const previousIsIceRestarting = this.isIceRestarting;
59
+ this.isIceRestarting = true;
57
60
  try {
58
- this.isIceRestarting = true;
59
- await this.sfuClient.iceRestart({
61
+ const { response } = await this.sfuClient.iceRestart({
60
62
  peerType: PeerType.SUBSCRIBER,
61
63
  });
64
+ if (response.error) throw new NegotiationError(response.error);
62
65
  } catch (e) {
63
66
  // restore the previous state, as our intent for restarting ICE failed
64
67
  this.isIceRestarting = previousIsIceRestarting;
@@ -154,6 +157,9 @@ export class Subscriber extends BasePeerConnection {
154
157
  this.addTrickledIceCandidates();
155
158
 
156
159
  const answer = await this.pc.createAnswer();
160
+ if (answer.sdp) {
161
+ answer.sdp = enableStereo(subscriberOffer.sdp, answer.sdp);
162
+ }
157
163
  await this.pc.setLocalDescription(answer);
158
164
 
159
165
  await this.sfuClient.sendAnswer({