@stream-io/video-client 0.0.28 → 0.0.29

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 (67) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/index.browser.es.js +2514 -1757
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +2534 -1755
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +2514 -1757
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +2 -3
  9. package/dist/src/StreamSfuClient.d.ts +23 -10
  10. package/dist/src/StreamVideoClient.d.ts +1 -4
  11. package/dist/src/client-details.d.ts +2 -1
  12. package/dist/src/coordinator/connection/types.d.ts +2 -2
  13. package/dist/src/coordinator/connection/utils.d.ts +1 -0
  14. package/dist/src/events/internal.d.ts +4 -0
  15. package/dist/src/gen/coordinator/index.d.ts +6 -0
  16. package/dist/src/gen/google/protobuf/struct.d.ts +8 -15
  17. package/dist/src/gen/google/protobuf/timestamp.d.ts +2 -9
  18. package/dist/src/gen/video/sfu/event/events.d.ts +121 -1
  19. package/dist/src/gen/video/sfu/models/models.d.ts +38 -1
  20. package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +3 -14
  21. package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +4 -12
  22. package/dist/src/logger.d.ts +4 -2
  23. package/dist/src/rtc/Dispatcher.d.ts +1 -2
  24. package/dist/src/rtc/{publisher.d.ts → Publisher.d.ts} +49 -15
  25. package/dist/src/rtc/Subscriber.d.ts +58 -0
  26. package/dist/src/rtc/__tests__/Subscriber.test.d.ts +1 -0
  27. package/dist/src/rtc/flows/join.d.ts +8 -1
  28. package/dist/src/rtc/index.d.ts +2 -2
  29. package/dist/src/rtc/signal.d.ts +1 -0
  30. package/dist/src/stats/state-store-stats-reporter.d.ts +3 -4
  31. package/dist/src/store/CallState.d.ts +10 -0
  32. package/package.json +3 -1
  33. package/src/Call.ts +215 -209
  34. package/src/StreamSfuClient.ts +48 -21
  35. package/src/StreamVideoClient.ts +7 -24
  36. package/src/client-details.ts +33 -1
  37. package/src/coordinator/connection/client.ts +6 -8
  38. package/src/coordinator/connection/types.ts +2 -3
  39. package/src/coordinator/connection/utils.ts +1 -0
  40. package/src/events/call.ts +0 -1
  41. package/src/events/callEventHandlers.ts +2 -0
  42. package/src/events/internal.ts +20 -0
  43. package/src/events/sessions.ts +0 -1
  44. package/src/gen/coordinator/index.ts +6 -0
  45. package/src/gen/google/protobuf/struct.ts +541 -333
  46. package/src/gen/google/protobuf/timestamp.ts +214 -148
  47. package/src/gen/video/sfu/event/events.ts +353 -3
  48. package/src/gen/video/sfu/models/models.ts +37 -0
  49. package/src/gen/video/sfu/signal_rpc/signal.client.ts +160 -94
  50. package/src/gen/video/sfu/signal_rpc/signal.ts +1214 -731
  51. package/src/logger.ts +43 -30
  52. package/src/rtc/Dispatcher.ts +5 -9
  53. package/src/rtc/{publisher.ts → Publisher.ts} +245 -111
  54. package/src/rtc/Subscriber.ts +304 -0
  55. package/src/rtc/__tests__/{publisher.test.ts → Publisher.test.ts} +77 -9
  56. package/src/rtc/__tests__/Subscriber.test.ts +121 -0
  57. package/src/rtc/__tests__/mocks/webrtc.mocks.ts +20 -0
  58. package/src/rtc/flows/join.ts +43 -2
  59. package/src/rtc/index.ts +2 -2
  60. package/src/rtc/signal.ts +6 -5
  61. package/src/rtc/videoLayers.ts +1 -4
  62. package/src/stats/state-store-stats-reporter.ts +3 -5
  63. package/src/store/CallState.ts +20 -0
  64. package/src/types.ts +0 -1
  65. package/dist/src/rtc/subscriber.d.ts +0 -9
  66. package/src/rtc/subscriber.ts +0 -107
  67. /package/dist/src/rtc/__tests__/{publisher.test.d.ts → Publisher.test.d.ts} +0 -0
@@ -0,0 +1,304 @@
1
+ import { StreamSfuClient } from '../StreamSfuClient';
2
+ import { getIceCandidate } from './helpers/iceCandidate';
3
+ import { PeerType } from '../gen/video/sfu/models/models';
4
+ import { SubscriberOffer } from '../gen/video/sfu/event/events';
5
+ import { Dispatcher } from './Dispatcher';
6
+ import { getLogger } from '../logger';
7
+ import { CallState } from '../store';
8
+
9
+ export type SubscriberOpts = {
10
+ sfuClient: StreamSfuClient;
11
+ dispatcher: Dispatcher;
12
+ state: CallState;
13
+ connectionConfig?: RTCConfiguration;
14
+ };
15
+
16
+ const logger = getLogger(['Subscriber']);
17
+
18
+ /**
19
+ * A wrapper around the `RTCPeerConnection` that handles the incoming
20
+ * media streams from the SFU.
21
+ */
22
+ export class Subscriber {
23
+ private pc: RTCPeerConnection;
24
+ private readonly unregisterOnSubscriberOffer: () => void;
25
+ private sfuClient: StreamSfuClient;
26
+ private dispatcher: Dispatcher;
27
+ private state: CallState;
28
+
29
+ /**
30
+ * Constructs a new `Subscriber` instance.
31
+ *
32
+ * @param sfuClient the SFU client to use.
33
+ * @param dispatcher the dispatcher to use.
34
+ * @param state the state of the call.
35
+ * @param connectionConfig the connection configuration to use.
36
+ */
37
+ constructor({
38
+ sfuClient,
39
+ dispatcher,
40
+ state,
41
+ connectionConfig,
42
+ }: SubscriberOpts) {
43
+ this.sfuClient = sfuClient;
44
+ this.dispatcher = dispatcher;
45
+ this.state = state;
46
+
47
+ this.pc = this.createPeerConnection(connectionConfig);
48
+
49
+ this.unregisterOnSubscriberOffer = dispatcher.on(
50
+ 'subscriberOffer',
51
+ async (message) => {
52
+ if (message.eventPayload.oneofKind !== 'subscriberOffer') return;
53
+ const { subscriberOffer } = message.eventPayload;
54
+ await this.negotiate(subscriberOffer);
55
+ },
56
+ );
57
+ }
58
+
59
+ /**
60
+ * Creates a new `RTCPeerConnection` instance with the given configuration.
61
+ *
62
+ * @param connectionConfig the connection configuration to use.
63
+ */
64
+ private createPeerConnection = (connectionConfig?: RTCConfiguration) => {
65
+ const pc = new RTCPeerConnection(connectionConfig);
66
+ pc.addEventListener('icecandidate', this.onIceCandidate);
67
+ pc.addEventListener('track', this.handleOnTrack);
68
+
69
+ pc.addEventListener('icecandidateerror', this.onIceCandidateError);
70
+ pc.addEventListener(
71
+ 'iceconnectionstatechange',
72
+ this.onIceConnectionStateChange,
73
+ );
74
+ pc.addEventListener(
75
+ 'icegatheringstatechange',
76
+ this.onIceGatheringStateChange,
77
+ );
78
+
79
+ return pc;
80
+ };
81
+
82
+ /**
83
+ * Closes the `RTCPeerConnection` and unsubscribes from the dispatcher.
84
+ */
85
+ close = () => {
86
+ this.unregisterOnSubscriberOffer();
87
+ this.pc.close();
88
+ };
89
+
90
+ /**
91
+ * Returns the result of the `RTCPeerConnection.getStats()` method
92
+ * @param selector
93
+ * @returns
94
+ */
95
+ getStats = (selector?: MediaStreamTrack | null | undefined) => {
96
+ return this.pc.getStats(selector);
97
+ };
98
+
99
+ /**
100
+ * Migrates the subscriber to a new SFU client.
101
+ *
102
+ * @param sfuClient the new SFU client to migrate to.
103
+ * @param connectionConfig the new connection configuration to use.
104
+ */
105
+ migrateTo = (
106
+ sfuClient: StreamSfuClient,
107
+ connectionConfig?: RTCConfiguration,
108
+ ) => {
109
+ this.sfuClient = sfuClient;
110
+
111
+ // when migrating, we want to keep the previous subscriber open
112
+ // until the new one is connected
113
+ const previousPC = this.pc;
114
+
115
+ // we keep a record of previously available video tracks
116
+ // so that we can monitor when they become available on the new
117
+ // subscriber and close the previous one.
118
+ const trackIdsToMigrate = new Set<string>();
119
+ previousPC.getReceivers().forEach((r) => {
120
+ if (r.track.kind === 'video') {
121
+ trackIdsToMigrate.add(r.track.id);
122
+ }
123
+ });
124
+
125
+ // set up a new subscriber peer connection, configured to connect
126
+ // to the new SFU node
127
+ const pc = this.createPeerConnection(connectionConfig);
128
+
129
+ let migrationTimeoutId: NodeJS.Timeout;
130
+ const cleanupMigration = () => {
131
+ previousPC.close();
132
+ clearTimeout(migrationTimeoutId);
133
+ };
134
+
135
+ // When migrating, we want to keep track of the video tracks
136
+ // that are migrating to the new subscriber.
137
+ // Once all of them are available, we can close the previous subscriber.
138
+ const handleTrackMigration = (e: RTCTrackEvent) => {
139
+ logger(
140
+ 'debug',
141
+ `[Migration]: Migrated track: ${e.track.id}, ${e.track.kind}`,
142
+ );
143
+ trackIdsToMigrate.delete(e.track.id);
144
+ if (trackIdsToMigrate.size === 0) {
145
+ logger('debug', `[Migration]: Migration complete`);
146
+ pc.removeEventListener('track', handleTrackMigration);
147
+ cleanupMigration();
148
+ }
149
+ };
150
+
151
+ // When migrating, we want to keep track of the connection state
152
+ // of the new subscriber.
153
+ // Once it is connected, we give it a 2-second grace period to receive
154
+ // all the video tracks that are migrating from the previous subscriber.
155
+ // After this threshold, we abruptly close the previous subscriber.
156
+ const handleConnectionStateChange = () => {
157
+ if (pc.connectionState === 'connected') {
158
+ migrationTimeoutId = setTimeout(() => {
159
+ pc.removeEventListener('track', handleTrackMigration);
160
+ cleanupMigration();
161
+ }, 2000);
162
+
163
+ pc.removeEventListener(
164
+ 'connectionstatechange',
165
+ handleConnectionStateChange,
166
+ );
167
+ }
168
+ };
169
+
170
+ pc.addEventListener('track', handleTrackMigration);
171
+ pc.addEventListener('connectionstatechange', handleConnectionStateChange);
172
+
173
+ // replace the PeerConnection instance
174
+ this.pc = pc;
175
+ };
176
+
177
+ private handleOnTrack = (e: RTCTrackEvent) => {
178
+ const [primaryStream] = e.streams;
179
+ // example: `e3f6aaf8-b03d-4911-be36-83f47d37a76a:TRACK_TYPE_VIDEO`
180
+ const [trackId, trackType] = primaryStream.id.split(':');
181
+ const participantToUpdate = this.state.participants.find(
182
+ (p) => p.trackLookupPrefix === trackId,
183
+ );
184
+ logger(
185
+ 'debug',
186
+ `[onTrack]: Got remote ${trackType} track for userId: ${participantToUpdate?.userId}`,
187
+ e.track.id,
188
+ e.track,
189
+ );
190
+ if (!participantToUpdate) {
191
+ logger(
192
+ 'error',
193
+ `[onTrack]: Received track for unknown participant: ${trackId}`,
194
+ e,
195
+ );
196
+ return;
197
+ }
198
+
199
+ e.track.addEventListener('mute', () => {
200
+ logger(
201
+ 'info',
202
+ `[onTrack]: Track muted: ${participantToUpdate.userId} ${trackType}:${trackId}`,
203
+ );
204
+ });
205
+
206
+ e.track.addEventListener('unmute', () => {
207
+ logger(
208
+ 'info',
209
+ `[onTrack]: Track unmuted: ${participantToUpdate.userId} ${trackType}:${trackId}`,
210
+ );
211
+ });
212
+
213
+ e.track.addEventListener('ended', () => {
214
+ logger(
215
+ 'info',
216
+ `[onTrack]: Track ended: ${participantToUpdate.userId} ${trackType}:${trackId}`,
217
+ );
218
+ });
219
+
220
+ const streamKindProp = (
221
+ {
222
+ TRACK_TYPE_AUDIO: 'audioStream',
223
+ TRACK_TYPE_VIDEO: 'videoStream',
224
+ TRACK_TYPE_SCREEN_SHARE: 'screenShareStream',
225
+ } as const
226
+ )[trackType];
227
+
228
+ if (!streamKindProp) {
229
+ logger('error', `Unknown track type: ${trackType}`);
230
+ return;
231
+ }
232
+ const previousStream = participantToUpdate[streamKindProp];
233
+ if (previousStream) {
234
+ logger(
235
+ 'info',
236
+ `[onTrack]: Cleaning up previous remote ${e.track.kind} tracks for userId: ${participantToUpdate.userId}`,
237
+ );
238
+ previousStream.getTracks().forEach((t) => {
239
+ t.stop();
240
+ previousStream.removeTrack(t);
241
+ });
242
+ }
243
+ this.state.updateParticipant(participantToUpdate.sessionId, {
244
+ [streamKindProp]: primaryStream,
245
+ });
246
+ };
247
+
248
+ private onIceCandidate = async (e: RTCPeerConnectionIceEvent) => {
249
+ const { candidate } = e;
250
+ if (!candidate) {
251
+ logger('warn', 'null ice candidate');
252
+ return;
253
+ }
254
+
255
+ await this.sfuClient.iceTrickle({
256
+ iceCandidate: getIceCandidate(candidate),
257
+ peerType: PeerType.SUBSCRIBER,
258
+ });
259
+ };
260
+
261
+ private negotiate = async (subscriberOffer: SubscriberOffer) => {
262
+ logger('info', `Received subscriberOffer`, subscriberOffer);
263
+
264
+ await this.pc.setRemoteDescription({
265
+ type: 'offer',
266
+ sdp: subscriberOffer.sdp,
267
+ });
268
+
269
+ this.sfuClient.iceTrickleBuffer.subscriberCandidates.subscribe(
270
+ async (candidate) => {
271
+ try {
272
+ const iceCandidate = JSON.parse(candidate.iceCandidate);
273
+ await this.pc.addIceCandidate(iceCandidate);
274
+ } catch (e) {
275
+ logger('error', `ICE candidate error`, [e, candidate]);
276
+ }
277
+ },
278
+ );
279
+
280
+ // apply ice candidates
281
+ const answer = await this.pc.createAnswer();
282
+ await this.pc.setLocalDescription(answer);
283
+
284
+ await this.sfuClient.sendAnswer({
285
+ peerType: PeerType.SUBSCRIBER,
286
+ sdp: answer.sdp || '',
287
+ });
288
+ };
289
+
290
+ private onIceConnectionStateChange = () => {
291
+ logger('info', `ICE connection state changed`, this.pc.iceConnectionState);
292
+ };
293
+
294
+ private onIceGatheringStateChange = () => {
295
+ logger('info', `ICE gathering state changed`, this.pc.iceGatheringState);
296
+ };
297
+
298
+ private onIceCandidateError = (e: Event) => {
299
+ const errorMessage =
300
+ e instanceof RTCPeerConnectionIceErrorEvent &&
301
+ `${e.errorCode}: ${e.errorText}`;
302
+ logger('error', `ICE Candidate error`, errorMessage);
303
+ };
304
+ }
@@ -1,11 +1,12 @@
1
1
  import './mocks/webrtc.mocks';
2
2
 
3
3
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
- import { Publisher } from '../publisher';
4
+ import { Publisher } from '../Publisher';
5
5
  import { CallState } from '../../store';
6
6
  import { StreamSfuClient } from '../../StreamSfuClient';
7
7
  import { Dispatcher } from '../Dispatcher';
8
8
  import { TrackType } from '../../gen/video/sfu/models/models';
9
+ import { IceTrickleBuffer } from '../IceTrickleBuffer';
9
10
 
10
11
  vi.mock('../../StreamSfuClient', () => {
11
12
  console.log('MOCKING StreamSfuClient');
@@ -37,8 +38,11 @@ describe('Publisher', () => {
37
38
  const dispatcher = new Dispatcher();
38
39
  sfuClient = new StreamSfuClient({
39
40
  dispatcher,
40
- url: 'https://getstream.io/',
41
- wsEndpoint: 'https://getstream.io/ws',
41
+ sfuServer: {
42
+ url: 'https://getstream.io/',
43
+ ws_endpoint: 'https://getstream.io/ws',
44
+ edge_name: 'sfu-1',
45
+ },
42
46
  token: 'token',
43
47
  });
44
48
 
@@ -82,12 +86,8 @@ describe('Publisher', () => {
82
86
 
83
87
  const transceiver = new RTCRtpTransceiver();
84
88
  vi.spyOn(transceiver.sender, 'track', 'get').mockReturnValue(track);
85
- vi.spyOn(publisher['publisher'], 'addTransceiver').mockReturnValue(
86
- transceiver,
87
- );
88
- vi.spyOn(publisher['publisher'], 'getTransceivers').mockReturnValue([
89
- transceiver,
90
- ]);
89
+ vi.spyOn(publisher['pc'], 'addTransceiver').mockReturnValue(transceiver);
90
+ vi.spyOn(publisher['pc'], 'getTransceivers').mockReturnValue([transceiver]);
91
91
 
92
92
  sfuClient.updateMuteState = vi.fn();
93
93
 
@@ -141,4 +141,72 @@ describe('Publisher', () => {
141
141
  );
142
142
  expect(state.localParticipant?.videoDeviceId).toEqual('test-device-id-2');
143
143
  });
144
+
145
+ describe('Publisher migration', () => {
146
+ it('should update the sfuClient and peer connection configuration', async () => {
147
+ const newSfuClient = new StreamSfuClient({
148
+ dispatcher: new Dispatcher(),
149
+ sfuServer: {
150
+ url: 'https://getstream.io/',
151
+ ws_endpoint: 'https://getstream.io/ws',
152
+ edge_name: 'sfu-1',
153
+ },
154
+ token: 'token',
155
+ });
156
+
157
+ const newPeerConnectionConfig = {
158
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
159
+ };
160
+
161
+ vi.spyOn(publisher['pc'], 'setConfiguration');
162
+ // @ts-ignore
163
+ publisher['pc'].iceConnectionState = 'connected';
164
+ // @ts-ignore
165
+ vi.spyOn(publisher, 'negotiate').mockReturnValue(Promise.resolve());
166
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
167
+
168
+ await publisher.migrateTo(newSfuClient, newPeerConnectionConfig);
169
+
170
+ expect(publisher['sfuClient']).toEqual(newSfuClient);
171
+ expect(publisher['pc'].setConfiguration).toHaveBeenCalledWith(
172
+ newPeerConnectionConfig,
173
+ );
174
+ expect(publisher['negotiate']).toHaveBeenCalledWith({ iceRestart: true });
175
+ });
176
+
177
+ it('should initiate ICE Restart when there are published tracks', async () => {
178
+ vi.spyOn(publisher['pc'], 'getTransceivers').mockReturnValue([]);
179
+ // @ts-ignore
180
+ sfuClient['iceTrickleBuffer'] = new IceTrickleBuffer();
181
+ sfuClient.setPublisher = vi.fn().mockResolvedValue({
182
+ response: {
183
+ sessionId: 'new-session-id',
184
+ sdp: 'new-sdp',
185
+ iceRestart: false,
186
+ },
187
+ });
188
+
189
+ // @ts-ignore
190
+ publisher['pc'].iceConnectionState = 'connected';
191
+ vi.spyOn(publisher, 'isPublishing').mockReturnValue(true);
192
+ vi.spyOn(publisher, 'getCurrentTrackInfos').mockReturnValue([
193
+ // @ts-expect-error
194
+ { layers: [], trackType: TrackType.AUDIO, mid: '0' },
195
+ ]);
196
+
197
+ await publisher.migrateTo(sfuClient, {
198
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
199
+ });
200
+
201
+ expect(publisher['pc'].createOffer).toHaveBeenCalledWith({
202
+ iceRestart: true,
203
+ });
204
+ expect(publisher['pc'].setLocalDescription).toHaveBeenCalled();
205
+ expect(publisher['pc'].setRemoteDescription).toHaveBeenCalledWith({
206
+ type: 'answer',
207
+ sdp: 'new-sdp',
208
+ });
209
+ expect(sfuClient.setPublisher).toHaveBeenCalled();
210
+ });
211
+ });
144
212
  });
@@ -0,0 +1,121 @@
1
+ import './mocks/webrtc.mocks';
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { Dispatcher } from '../Dispatcher';
5
+ import { StreamSfuClient } from '../../StreamSfuClient';
6
+ import { Subscriber } from '../Subscriber';
7
+ import { CallState } from '../../store';
8
+
9
+ vi.mock('../../StreamSfuClient', () => {
10
+ console.log('MOCKING StreamSfuClient');
11
+ return {
12
+ StreamSfuClient: vi.fn(),
13
+ };
14
+ });
15
+
16
+ describe('Subscriber', () => {
17
+ let sfuClient: StreamSfuClient;
18
+ let subscriber: Subscriber;
19
+ let state = new CallState();
20
+
21
+ beforeEach(() => {
22
+ const dispatcher = new Dispatcher();
23
+ sfuClient = new StreamSfuClient({
24
+ dispatcher,
25
+ sfuServer: {
26
+ url: 'https://getstream.io/',
27
+ ws_endpoint: 'https://getstream.io/ws',
28
+ edge_name: 'sfu-1',
29
+ },
30
+ token: 'token',
31
+ });
32
+
33
+ subscriber = new Subscriber({
34
+ sfuClient,
35
+ dispatcher,
36
+ state,
37
+ connectionConfig: { iceServers: [] },
38
+ });
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.clearAllMocks();
43
+ vi.resetModules();
44
+ });
45
+
46
+ describe('Subscriber migration', () => {
47
+ it('should update the sfuClient and create a new peer connection', async () => {
48
+ const newSfuClient = new StreamSfuClient({
49
+ dispatcher: new Dispatcher(),
50
+ sfuServer: {
51
+ url: 'https://getstream.io/',
52
+ ws_endpoint: 'https://getstream.io/ws',
53
+ edge_name: 'sfu-1',
54
+ },
55
+ token: 'token',
56
+ });
57
+ const newConnectionConfig = { iceServers: [] };
58
+
59
+ const oldPeerConnection = subscriber['pc'];
60
+ vi.spyOn(oldPeerConnection, 'getReceivers').mockReturnValue([]);
61
+
62
+ await subscriber.migrateTo(newSfuClient, newConnectionConfig);
63
+ const newPeerConnection = subscriber['pc'];
64
+
65
+ expect(subscriber['sfuClient']).toBe(newSfuClient);
66
+ expect(newPeerConnection).not.toBe(oldPeerConnection);
67
+ });
68
+
69
+ it('should close the old peer connection once the new one connects', async () => {
70
+ let onConnectionStateChange: () => void = () => {};
71
+ let onTrack: (e: RTCTrackEvent) => void = () => {};
72
+ // @ts-ignore
73
+ vi.spyOn(subscriber, 'createPeerConnection').mockImplementation(() => {
74
+ const pc = new RTCPeerConnection();
75
+ vi.spyOn(pc, 'addEventListener').mockImplementation((event, cb) => {
76
+ if (event === 'connectionstatechange') {
77
+ // @ts-ignore
78
+ onConnectionStateChange = cb;
79
+ } else if (event === 'track') {
80
+ // @ts-ignore
81
+ onTrack = cb;
82
+ }
83
+ });
84
+ return pc;
85
+ });
86
+
87
+ const oldPeerConnection = subscriber['pc'];
88
+ vi.spyOn(oldPeerConnection, 'getReceivers').mockReturnValue([]);
89
+ vi.spyOn(oldPeerConnection, 'close');
90
+
91
+ await subscriber.migrateTo(sfuClient, { iceServers: [] });
92
+
93
+ const newPeerConnection = subscriber['pc'];
94
+ vi.spyOn(newPeerConnection, 'removeEventListener');
95
+ // @ts-ignore
96
+ newPeerConnection.connectionState = 'connected';
97
+
98
+ expect(onConnectionStateChange).toBeDefined();
99
+ expect(oldPeerConnection.close).not.toHaveBeenCalled();
100
+ onConnectionStateChange();
101
+
102
+ // @ts-ignore
103
+ onTrack(
104
+ // @ts-ignore
105
+ new RTCTrackEvent('video', {
106
+ track: new MediaStreamTrack(),
107
+ }),
108
+ );
109
+
110
+ expect(newPeerConnection.removeEventListener).toHaveBeenCalledWith(
111
+ 'connectionstatechange',
112
+ onConnectionStateChange,
113
+ );
114
+ expect(newPeerConnection.removeEventListener).toHaveBeenCalledWith(
115
+ 'track',
116
+ onTrack,
117
+ );
118
+ expect(oldPeerConnection.close).toHaveBeenCalled();
119
+ });
120
+ });
121
+ });
@@ -3,8 +3,18 @@ import { vi } from 'vitest';
3
3
  const RTCPeerConnectionMock = vi.fn((): Partial<RTCPeerConnection> => {
4
4
  return {
5
5
  addEventListener: vi.fn(),
6
+ removeEventListener: vi.fn(),
6
7
  getTransceivers: vi.fn(),
7
8
  addTransceiver: vi.fn(),
9
+ getConfiguration: vi.fn(),
10
+ setConfiguration: vi.fn(),
11
+ createOffer: vi.fn().mockResolvedValue({}),
12
+ createAnswer: vi.fn().mockResolvedValue({}),
13
+ setLocalDescription: vi.fn().mockResolvedValue({}),
14
+ setRemoteDescription: vi.fn().mockResolvedValue({}),
15
+ close: vi.fn(),
16
+ connectionState: 'connected',
17
+ getReceivers: vi.fn(),
8
18
  };
9
19
  });
10
20
  vi.stubGlobal('RTCPeerConnection', RTCPeerConnectionMock);
@@ -40,3 +50,13 @@ const RTCRtpTransceiverMock = vi.fn((): Partial<RTCRtpTransceiver> => {
40
50
  };
41
51
  });
42
52
  vi.stubGlobal('RTCRtpTransceiver', RTCRtpTransceiverMock);
53
+
54
+ const RTCTrackEvent = vi.fn(
55
+ (type: string, eventInitDict: RTCTrackEventInit): Partial<RTCTrackEvent> => {
56
+ return {
57
+ type,
58
+ ...eventInitDict,
59
+ };
60
+ },
61
+ );
62
+ vi.stubGlobal('RTCTrackEvent', RTCTrackEvent);
@@ -3,7 +3,12 @@ import {
3
3
  JoinCallRequest,
4
4
  JoinCallResponse,
5
5
  } from '../../gen/coordinator';
6
- import { JoinCallData } from '../../types';
6
+ import {
7
+ isStreamVideoLocalParticipant,
8
+ JoinCallData,
9
+ StreamVideoLocalParticipant,
10
+ StreamVideoParticipant,
11
+ } from '../../types';
7
12
  import { StreamClient } from '../../coordinator/connection/client';
8
13
  import { getLogger } from '../../logger';
9
14
 
@@ -51,6 +56,10 @@ const doJoin = async (
51
56
  // FIXME OL: remove this once cascading is enabled by default
52
57
  const cascadingModeParams = getCascadingModeParams();
53
58
  if (cascadingModeParams) {
59
+ // FIXME OL: remove after SFU migration is done
60
+ if (data?.migrating_from && cascadingModeParams['next_sfu_id']) {
61
+ cascadingModeParams['sfu_id'] = cascadingModeParams['next_sfu_id'];
62
+ }
54
63
  return httpClient.doAxiosRequest<JoinCallResponse, JoinCallRequest>(
55
64
  'post',
56
65
  `/call/${type}/${id}/join`,
@@ -103,7 +112,7 @@ const toRtcConfiguration = (config?: ICEServer[]) => {
103
112
 
104
113
  const getCascadingModeParams = () => {
105
114
  if (typeof window === 'undefined') return null;
106
- const params = new URLSearchParams(window.location?.search);
115
+ const params = new URLSearchParams(window.location.search);
107
116
  const cascadingEnabled = params.get('cascading') !== null;
108
117
  if (cascadingEnabled) {
109
118
  const rawParams: Record<string, string> = {};
@@ -114,3 +123,35 @@ const getCascadingModeParams = () => {
114
123
  }
115
124
  return null;
116
125
  };
126
+
127
+ /**
128
+ * Reconciles the local state of the source participant into the target participant.
129
+ *
130
+ * @param target the participant to reconcile into.
131
+ * @param source the participant to reconcile from.
132
+ */
133
+ export const reconcileParticipantLocalState = (
134
+ target: StreamVideoParticipant | StreamVideoLocalParticipant,
135
+ source?: StreamVideoParticipant | StreamVideoLocalParticipant,
136
+ ) => {
137
+ if (!source) return target;
138
+
139
+ target.audioStream = source.audioStream;
140
+ target.videoStream = source.videoStream;
141
+ target.screenShareStream = source.screenShareStream;
142
+
143
+ target.videoDimension = source.videoDimension;
144
+ target.screenShareDimension = source.screenShareDimension;
145
+ target.pinnedAt = source.pinnedAt;
146
+ target.reaction = source.reaction;
147
+ target.viewportVisibilityState = source.viewportVisibilityState;
148
+ if (
149
+ isStreamVideoLocalParticipant(source) &&
150
+ isStreamVideoLocalParticipant(target)
151
+ ) {
152
+ target.audioDeviceId = source.audioDeviceId;
153
+ target.videoDeviceId = source.videoDeviceId;
154
+ target.audioOutputDeviceId = source.audioOutputDeviceId;
155
+ }
156
+ return target;
157
+ };
package/src/rtc/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export * from './codecs';
2
2
  export * from './Dispatcher';
3
3
  export * from './IceTrickleBuffer';
4
- export * from './publisher';
5
- export * from './subscriber';
4
+ export * from './Publisher';
5
+ export * from './Subscriber';
6
6
  export * from './signal';
7
7
  export * from './videoLayers';
package/src/rtc/signal.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import WebSocket from 'isomorphic-ws';
1
2
  import { SfuEvent } from '../gen/video/sfu/event/events';
2
3
  import { getLogger } from '../logger';
3
4
 
@@ -11,15 +12,15 @@ export const createWebSocketSignalChannel = (opts: {
11
12
  ws.binaryType = 'arraybuffer'; // do we need this?
12
13
 
13
14
  ws.addEventListener('error', (e) => {
14
- logger?.('error', 'Signaling WS channel error', e);
15
+ logger('error', 'Signaling WS channel error', e);
15
16
  });
16
17
 
17
18
  ws.addEventListener('close', (e) => {
18
- logger?.('info', 'Signaling WS channel is closed', e);
19
+ logger('info', 'Signaling WS channel is closed', e);
19
20
  });
20
21
 
21
22
  ws.addEventListener('open', (e) => {
22
- logger?.('info', 'Signaling WS channel is open', e);
23
+ logger('info', 'Signaling WS channel is open', e);
23
24
  });
24
25
 
25
26
  if (onMessage) {
@@ -28,11 +29,11 @@ export const createWebSocketSignalChannel = (opts: {
28
29
  const message =
29
30
  e.data instanceof ArrayBuffer
30
31
  ? SfuEvent.fromBinary(new Uint8Array(e.data))
31
- : SfuEvent.fromJsonString(e.data);
32
+ : SfuEvent.fromJsonString(e.data.toString());
32
33
 
33
34
  onMessage(message);
34
35
  } catch (err) {
35
- logger?.(
36
+ logger(
36
37
  'error',
37
38
  'Failed to decode a message. Check whether the Proto models match.',
38
39
  { event: e, error: err },