@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.
- package/CHANGELOG.md +7 -0
- package/dist/index.browser.es.js +2514 -1757
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +2534 -1755
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +2514 -1757
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +2 -3
- package/dist/src/StreamSfuClient.d.ts +23 -10
- package/dist/src/StreamVideoClient.d.ts +1 -4
- package/dist/src/client-details.d.ts +2 -1
- package/dist/src/coordinator/connection/types.d.ts +2 -2
- package/dist/src/coordinator/connection/utils.d.ts +1 -0
- package/dist/src/events/internal.d.ts +4 -0
- package/dist/src/gen/coordinator/index.d.ts +6 -0
- package/dist/src/gen/google/protobuf/struct.d.ts +8 -15
- package/dist/src/gen/google/protobuf/timestamp.d.ts +2 -9
- package/dist/src/gen/video/sfu/event/events.d.ts +121 -1
- package/dist/src/gen/video/sfu/models/models.d.ts +38 -1
- package/dist/src/gen/video/sfu/signal_rpc/signal.client.d.ts +3 -14
- package/dist/src/gen/video/sfu/signal_rpc/signal.d.ts +4 -12
- package/dist/src/logger.d.ts +4 -2
- package/dist/src/rtc/Dispatcher.d.ts +1 -2
- package/dist/src/rtc/{publisher.d.ts → Publisher.d.ts} +49 -15
- package/dist/src/rtc/Subscriber.d.ts +58 -0
- package/dist/src/rtc/__tests__/Subscriber.test.d.ts +1 -0
- package/dist/src/rtc/flows/join.d.ts +8 -1
- package/dist/src/rtc/index.d.ts +2 -2
- package/dist/src/rtc/signal.d.ts +1 -0
- package/dist/src/stats/state-store-stats-reporter.d.ts +3 -4
- package/dist/src/store/CallState.d.ts +10 -0
- package/package.json +3 -1
- package/src/Call.ts +215 -209
- package/src/StreamSfuClient.ts +48 -21
- package/src/StreamVideoClient.ts +7 -24
- package/src/client-details.ts +33 -1
- package/src/coordinator/connection/client.ts +6 -8
- package/src/coordinator/connection/types.ts +2 -3
- package/src/coordinator/connection/utils.ts +1 -0
- package/src/events/call.ts +0 -1
- package/src/events/callEventHandlers.ts +2 -0
- package/src/events/internal.ts +20 -0
- package/src/events/sessions.ts +0 -1
- package/src/gen/coordinator/index.ts +6 -0
- package/src/gen/google/protobuf/struct.ts +541 -333
- package/src/gen/google/protobuf/timestamp.ts +214 -148
- package/src/gen/video/sfu/event/events.ts +353 -3
- package/src/gen/video/sfu/models/models.ts +37 -0
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +160 -94
- package/src/gen/video/sfu/signal_rpc/signal.ts +1214 -731
- package/src/logger.ts +43 -30
- package/src/rtc/Dispatcher.ts +5 -9
- package/src/rtc/{publisher.ts → Publisher.ts} +245 -111
- package/src/rtc/Subscriber.ts +304 -0
- package/src/rtc/__tests__/{publisher.test.ts → Publisher.test.ts} +77 -9
- package/src/rtc/__tests__/Subscriber.test.ts +121 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +20 -0
- package/src/rtc/flows/join.ts +43 -2
- package/src/rtc/index.ts +2 -2
- package/src/rtc/signal.ts +6 -5
- package/src/rtc/videoLayers.ts +1 -4
- package/src/stats/state-store-stats-reporter.ts +3 -5
- package/src/store/CallState.ts +20 -0
- package/src/types.ts +0 -1
- package/dist/src/rtc/subscriber.d.ts +0 -9
- package/src/rtc/subscriber.ts +0 -107
- /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 '../
|
|
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
|
-
|
|
41
|
-
|
|
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['
|
|
86
|
-
|
|
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);
|
package/src/rtc/flows/join.ts
CHANGED
|
@@ -3,7 +3,12 @@ import {
|
|
|
3
3
|
JoinCallRequest,
|
|
4
4
|
JoinCallResponse,
|
|
5
5
|
} from '../../gen/coordinator';
|
|
6
|
-
import {
|
|
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
|
|
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 './
|
|
5
|
-
export * from './
|
|
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
|
|
15
|
+
logger('error', 'Signaling WS channel error', e);
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
ws.addEventListener('close', (e) => {
|
|
18
|
-
logger
|
|
19
|
+
logger('info', 'Signaling WS channel is closed', e);
|
|
19
20
|
});
|
|
20
21
|
|
|
21
22
|
ws.addEventListener('open', (e) => {
|
|
22
|
-
logger
|
|
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 },
|