@stream-io/video-client 1.54.1-beta.0 → 1.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/index.browser.es.js +9700 -8873
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +9707 -8880
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +9708 -8881
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +4 -4
- package/dist/src/StreamSfuClient.d.ts +11 -3
- package/dist/src/coordinator/connection/connection.d.ts +2 -1
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/BasePeerConnection.d.ts +2 -12
- package/dist/src/rtc/IceTrickleBuffer.d.ts +41 -3
- package/dist/src/rtc/Publisher.d.ts +1 -1
- package/dist/src/rtc/Subscriber.d.ts +2 -1
- package/dist/src/rtc/helpers/iceCandiates.d.ts +12 -0
- package/dist/src/rtc/types.d.ts +3 -0
- package/dist/src/stats/SfuStatsReporter.d.ts +32 -1
- package/dist/src/stats/rtc/StatsTracer.d.ts +38 -8
- package/dist/src/stats/rtc/Tracer.d.ts +9 -2
- package/dist/src/stats/rtc/types.d.ts +10 -4
- package/package.json +5 -3
- package/src/Call.ts +47 -44
- package/src/StreamSfuClient.ts +36 -21
- package/src/__tests__/StreamSfuClient.test.ts +159 -1
- package/src/__tests__/StreamVideoClient.api.test.ts +123 -97
- package/src/coordinator/connection/__tests__/connection.test.ts +69 -0
- package/src/coordinator/connection/connection.ts +28 -13
- package/src/gen/video/sfu/event/events.ts +0 -1
- package/src/gen/video/sfu/models/models.ts +0 -1
- package/src/gen/video/sfu/signal_rpc/signal.client.ts +0 -1
- package/src/gen/video/sfu/signal_rpc/signal.ts +0 -1
- package/src/helpers/MediaPlaybackWatchdog.ts +1 -0
- package/src/helpers/__tests__/browsers.test.ts +12 -12
- package/src/helpers/browsers.ts +5 -5
- package/src/helpers/client-details.ts +1 -1
- package/src/reporting/ClientEventReporter.ts +17 -12
- package/src/reporting/__tests__/ClientEventReporter.test.ts +52 -0
- package/src/rtc/BasePeerConnection.ts +15 -34
- package/src/rtc/IceTrickleBuffer.ts +105 -12
- package/src/rtc/Publisher.ts +26 -19
- package/src/rtc/Subscriber.ts +71 -37
- package/src/rtc/__tests__/Call.reconnect.test.ts +45 -45
- package/src/rtc/__tests__/IceTrickleBuffer.test.ts +127 -0
- package/src/rtc/__tests__/Publisher.test.ts +76 -31
- package/src/rtc/__tests__/Subscriber.test.ts +271 -20
- package/src/rtc/helpers/__tests__/iceCandiates.test.ts +88 -0
- package/src/rtc/helpers/degradationPreference.ts +1 -0
- package/src/rtc/helpers/iceCandiates.ts +35 -0
- package/src/rtc/helpers/sdp.ts +3 -2
- package/src/rtc/helpers/tracks.ts +2 -0
- package/src/rtc/types.ts +3 -0
- package/src/stats/SfuStatsReporter.ts +149 -49
- package/src/stats/__tests__/SfuStatsReporter.test.ts +235 -0
- package/src/stats/rtc/StatsTracer.ts +90 -32
- package/src/stats/rtc/Tracer.ts +23 -2
- package/src/stats/rtc/__tests__/StatsTracer.test.ts +213 -6
- package/src/stats/rtc/__tests__/Tracer.test.ts +34 -0
- package/src/stats/rtc/types.ts +11 -4
|
@@ -157,6 +157,7 @@ describe('Publisher', () => {
|
|
|
157
157
|
publishOption: publisher['publishOptions'][0],
|
|
158
158
|
transceiver,
|
|
159
159
|
options: {},
|
|
160
|
+
negotiated: true,
|
|
160
161
|
});
|
|
161
162
|
|
|
162
163
|
await publisher.publish(track, TrackType.VIDEO);
|
|
@@ -166,6 +167,75 @@ describe('Publisher', () => {
|
|
|
166
167
|
expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
|
|
167
168
|
expect(track.stop).toHaveBeenCalled();
|
|
168
169
|
});
|
|
170
|
+
|
|
171
|
+
it('should not renegotiate when reusing an already-negotiated transceiver', async () => {
|
|
172
|
+
const track = new MediaStreamTrack();
|
|
173
|
+
const clone = new MediaStreamTrack();
|
|
174
|
+
vi.spyOn(track, 'clone').mockReturnValue(clone);
|
|
175
|
+
|
|
176
|
+
const transceiver = new RTCRtpTransceiver();
|
|
177
|
+
// @ts-expect-error test setup
|
|
178
|
+
transceiver.sender.track = track;
|
|
179
|
+
publisher['transceiverCache'].add({
|
|
180
|
+
publishOption: publisher['publishOptions'][0],
|
|
181
|
+
transceiver,
|
|
182
|
+
options: {},
|
|
183
|
+
negotiated: true,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// @ts-expect-error - private method
|
|
187
|
+
const negotiateSpy = vi.spyOn(publisher, 'negotiate').mockResolvedValue();
|
|
188
|
+
|
|
189
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
190
|
+
|
|
191
|
+
expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
|
|
192
|
+
expect(negotiateSpy).not.toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should renegotiate on republish when a previous negotiation never reached the SFU (SetPublisher timeout)', async () => {
|
|
196
|
+
const track = new MediaStreamTrack();
|
|
197
|
+
const transceiver = new RTCRtpTransceiver();
|
|
198
|
+
// @ts-expect-error test setup
|
|
199
|
+
transceiver.sender.track = track;
|
|
200
|
+
const bundle = {
|
|
201
|
+
publishOption: publisher['publishOptions'][0],
|
|
202
|
+
transceiver,
|
|
203
|
+
options: {},
|
|
204
|
+
negotiated: false,
|
|
205
|
+
};
|
|
206
|
+
publisher['transceiverCache'].add(bundle);
|
|
207
|
+
|
|
208
|
+
vi.spyOn(publisher['pc'], 'createOffer')
|
|
209
|
+
// @ts-expect-error TS picks up the wrong overload
|
|
210
|
+
.mockResolvedValue({ sdp: 'offer-sdp', type: 'offer' });
|
|
211
|
+
vi.spyOn(publisher['pc'], 'setLocalDescription').mockResolvedValue();
|
|
212
|
+
vi.spyOn(publisher['pc'], 'setRemoteDescription').mockResolvedValue();
|
|
213
|
+
vi.spyOn(publisher, 'getAnnouncedTracks').mockReturnValue([
|
|
214
|
+
// @ts-expect-error incomplete data
|
|
215
|
+
{ trackId: '123' },
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
sfuClient.setPublisher = vi
|
|
219
|
+
.fn()
|
|
220
|
+
.mockRejectedValue(new Error('SetPublisherTimeout'));
|
|
221
|
+
await expect(publisher['negotiate']()).rejects.toThrow(
|
|
222
|
+
'SetPublisherTimeout',
|
|
223
|
+
);
|
|
224
|
+
expect(bundle.negotiated).toBe(false);
|
|
225
|
+
|
|
226
|
+
const clone = new MediaStreamTrack();
|
|
227
|
+
vi.spyOn(track, 'clone').mockReturnValue(clone);
|
|
228
|
+
sfuClient.setPublisher = vi
|
|
229
|
+
.fn()
|
|
230
|
+
.mockResolvedValue({ response: { sdp: 'answer-sdp' } });
|
|
231
|
+
|
|
232
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
233
|
+
|
|
234
|
+
expect(publisher['pc'].addTransceiver).not.toHaveBeenCalled();
|
|
235
|
+
expect(transceiver.sender.replaceTrack).toHaveBeenCalledWith(clone);
|
|
236
|
+
expect(sfuClient.setPublisher).toHaveBeenCalled();
|
|
237
|
+
expect(bundle.negotiated).toBe(true);
|
|
238
|
+
});
|
|
169
239
|
});
|
|
170
240
|
|
|
171
241
|
describe('Event Handling', () => {
|
|
@@ -525,37 +595,6 @@ describe('Publisher', () => {
|
|
|
525
595
|
expect(publisher['onReconnectionNeeded']).not.toHaveBeenCalled();
|
|
526
596
|
});
|
|
527
597
|
|
|
528
|
-
it(`isStable() returns false when ICE is 'new'`, () => {
|
|
529
|
-
// @ts-expect-error private api
|
|
530
|
-
publisher['pc'].iceConnectionState = 'new';
|
|
531
|
-
// default connectionState in mock is 'connected'
|
|
532
|
-
expect(publisher.isStable()).toBe(false);
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
it(`isStable() returns true when ICE is 'connected' and connectionState is 'connected'`, () => {
|
|
536
|
-
// @ts-expect-error private api
|
|
537
|
-
publisher['pc'].iceConnectionState = 'connected';
|
|
538
|
-
// @ts-expect-error private api
|
|
539
|
-
publisher['pc'].connectionState = 'connected';
|
|
540
|
-
expect(publisher.isStable()).toBe(true);
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
it(`isStable() returns true when ICE is 'completed' and connectionState is 'connected'`, () => {
|
|
544
|
-
// @ts-expect-error private api
|
|
545
|
-
publisher['pc'].iceConnectionState = 'completed';
|
|
546
|
-
// @ts-expect-error private api
|
|
547
|
-
publisher['pc'].connectionState = 'connected';
|
|
548
|
-
expect(publisher.isStable()).toBe(true);
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
it(`isStable() returns false when ICE is 'disconnected'`, () => {
|
|
552
|
-
// @ts-expect-error private api
|
|
553
|
-
publisher['pc'].iceConnectionState = 'disconnected';
|
|
554
|
-
// @ts-expect-error private api
|
|
555
|
-
publisher['pc'].connectionState = 'connected';
|
|
556
|
-
expect(publisher.isStable()).toBe(false);
|
|
557
|
-
});
|
|
558
|
-
|
|
559
598
|
it(`after connected→disconnected→connected cycle, subsequent 'failed' DOES trigger ICE restart (flag stays true)`, () => {
|
|
560
599
|
// @ts-expect-error private api
|
|
561
600
|
publisher['pc'].iceConnectionState = 'connected';
|
|
@@ -1488,6 +1527,7 @@ describe('Publisher', () => {
|
|
|
1488
1527
|
publishOption: publisher['publishOptions'][0],
|
|
1489
1528
|
transceiver,
|
|
1490
1529
|
options: {},
|
|
1530
|
+
negotiated: true,
|
|
1491
1531
|
});
|
|
1492
1532
|
|
|
1493
1533
|
// stopping seeds the bundle's videoSender from the current encoder
|
|
@@ -1546,11 +1586,13 @@ describe('Publisher', () => {
|
|
|
1546
1586
|
publishOption: publisher['publishOptions'][0],
|
|
1547
1587
|
transceiver: vp8Transceiver,
|
|
1548
1588
|
options: {},
|
|
1589
|
+
negotiated: true,
|
|
1549
1590
|
});
|
|
1550
1591
|
publisher['transceiverCache'].add({
|
|
1551
1592
|
publishOption: publisher['publishOptions'][1],
|
|
1552
1593
|
transceiver: vp9Transceiver,
|
|
1553
1594
|
options: {},
|
|
1595
|
+
negotiated: true,
|
|
1554
1596
|
});
|
|
1555
1597
|
|
|
1556
1598
|
await publisher.stopTracks(TrackType.VIDEO);
|
|
@@ -1620,6 +1662,7 @@ describe('Publisher', () => {
|
|
|
1620
1662
|
publishOption,
|
|
1621
1663
|
transceiver,
|
|
1622
1664
|
options: {},
|
|
1665
|
+
negotiated: true,
|
|
1623
1666
|
});
|
|
1624
1667
|
|
|
1625
1668
|
// SFU sends a changePublishQuality while we are not publishing.
|
|
@@ -1634,6 +1677,7 @@ describe('Publisher', () => {
|
|
|
1634
1677
|
{
|
|
1635
1678
|
publishOptionId: publishOption.id,
|
|
1636
1679
|
trackType: TrackType.VIDEO,
|
|
1680
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
1637
1681
|
layers: [
|
|
1638
1682
|
{
|
|
1639
1683
|
name: 'q',
|
|
@@ -1728,6 +1772,7 @@ describe('Publisher', () => {
|
|
|
1728
1772
|
{
|
|
1729
1773
|
publishOptionId: publishOption.id,
|
|
1730
1774
|
trackType: TrackType.VIDEO,
|
|
1775
|
+
degradationPreference: DegradationPreference.UNSPECIFIED,
|
|
1731
1776
|
layers: [
|
|
1732
1777
|
{
|
|
1733
1778
|
name: 'q',
|
|
@@ -141,26 +141,6 @@ describe('Subscriber', () => {
|
|
|
141
141
|
);
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
-
it(`isStable() returns true only when ICE is connected/completed and connectionState is connected`, () => {
|
|
145
|
-
// @ts-expect-error - private field
|
|
146
|
-
subscriber['pc'].iceConnectionState = 'connected';
|
|
147
|
-
// @ts-expect-error - private field
|
|
148
|
-
subscriber['pc'].connectionState = 'connected';
|
|
149
|
-
expect(subscriber.isStable()).toBe(true);
|
|
150
|
-
|
|
151
|
-
// @ts-expect-error - private field
|
|
152
|
-
subscriber['pc'].iceConnectionState = 'completed';
|
|
153
|
-
expect(subscriber.isStable()).toBe(true);
|
|
154
|
-
|
|
155
|
-
// @ts-expect-error - private field
|
|
156
|
-
subscriber['pc'].iceConnectionState = 'disconnected';
|
|
157
|
-
expect(subscriber.isStable()).toBe(false);
|
|
158
|
-
|
|
159
|
-
// @ts-expect-error - private field
|
|
160
|
-
subscriber['pc'].iceConnectionState = 'new';
|
|
161
|
-
expect(subscriber.isStable()).toBe(false);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
144
|
it(`iceHasEverConnected tracks lifetime connectivity`, () => {
|
|
165
145
|
expect(subscriber['iceHasEverConnected']).toBe(false);
|
|
166
146
|
simulatePriorIceConnected();
|
|
@@ -194,6 +174,277 @@ describe('Subscriber', () => {
|
|
|
194
174
|
});
|
|
195
175
|
});
|
|
196
176
|
|
|
177
|
+
describe('Subscriber negotiation', () => {
|
|
178
|
+
const subscriberOffer: SubscriberOffer = {
|
|
179
|
+
sdp: 'subscriber-offer-sdp',
|
|
180
|
+
iceRestart: false,
|
|
181
|
+
negotiationId: 10,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
sfuClient.sendAnswer = vi.fn().mockResolvedValue({ response: {} });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('resets isIceRestarting once a negotiation completes', async () => {
|
|
189
|
+
subscriber['isIceRestarting'] = true;
|
|
190
|
+
|
|
191
|
+
await subscriber['negotiate'](subscriberOffer);
|
|
192
|
+
|
|
193
|
+
expect(sfuClient.sendAnswer).toHaveBeenCalledWith({
|
|
194
|
+
peerType: PeerType.SUBSCRIBER,
|
|
195
|
+
sdp: '',
|
|
196
|
+
negotiationId: 10,
|
|
197
|
+
});
|
|
198
|
+
expect(subscriber['isIceRestarting']).toBe(false);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('resets isIceRestarting even when the negotiation fails', async () => {
|
|
202
|
+
subscriber['isIceRestarting'] = true;
|
|
203
|
+
sfuClient.sendAnswer = vi
|
|
204
|
+
.fn()
|
|
205
|
+
.mockRejectedValue(new Error('send answer failed'));
|
|
206
|
+
|
|
207
|
+
await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
|
|
208
|
+
'send answer failed',
|
|
209
|
+
);
|
|
210
|
+
expect(subscriber['isIceRestarting']).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rolls back the remote description when a negotiation fails mid-offer', async () => {
|
|
214
|
+
const setRemoteDescription = vi.fn().mockResolvedValue({});
|
|
215
|
+
subscriber['pc'].setRemoteDescription = setRemoteDescription;
|
|
216
|
+
// @ts-expect-error - readonly field
|
|
217
|
+
subscriber['pc'].signalingState = 'have-remote-offer';
|
|
218
|
+
sfuClient.sendAnswer = vi
|
|
219
|
+
.fn()
|
|
220
|
+
.mockRejectedValue(new Error('send answer failed'));
|
|
221
|
+
|
|
222
|
+
await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
|
|
223
|
+
'send answer failed',
|
|
224
|
+
);
|
|
225
|
+
expect(setRemoteDescription).toHaveBeenCalledWith({ type: 'rollback' });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('does not roll back when the peer connection never applied the offer', async () => {
|
|
229
|
+
// signalingState stays 'stable' because setRemoteDescription rejected
|
|
230
|
+
subscriber['pc'].setRemoteDescription = vi
|
|
231
|
+
.fn()
|
|
232
|
+
.mockRejectedValue(new Error('set remote description failed'));
|
|
233
|
+
|
|
234
|
+
await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
|
|
235
|
+
'set remote description failed',
|
|
236
|
+
);
|
|
237
|
+
expect(subscriber['pc'].setRemoteDescription).not.toHaveBeenCalledWith({
|
|
238
|
+
type: 'rollback',
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('propagates the original error even when the rollback itself fails', async () => {
|
|
243
|
+
subscriber['pc'].setRemoteDescription = vi
|
|
244
|
+
.fn()
|
|
245
|
+
.mockResolvedValueOnce({}) // applying the offer succeeds
|
|
246
|
+
.mockRejectedValueOnce(new Error('rollback failed')); // rollback fails
|
|
247
|
+
// @ts-expect-error - readonly field
|
|
248
|
+
subscriber['pc'].signalingState = 'have-remote-offer';
|
|
249
|
+
sfuClient.sendAnswer = vi
|
|
250
|
+
.fn()
|
|
251
|
+
.mockRejectedValue(new Error('send answer failed'));
|
|
252
|
+
|
|
253
|
+
await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
|
|
254
|
+
'send answer failed',
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('restores the previous generation in the buffer when a negotiation rolls back', async () => {
|
|
259
|
+
const sdp = (ufrag: string) =>
|
|
260
|
+
`v=0\r\na=ice-ufrag:${ufrag}\r\na=ice-pwd:pwd\r\n`;
|
|
261
|
+
const updateActiveGeneration = vi.spyOn(
|
|
262
|
+
sfuClient.iceTrickleBuffer,
|
|
263
|
+
'updateActiveGeneration',
|
|
264
|
+
);
|
|
265
|
+
// previously-committed generation u0; the new (failing) offer is u1
|
|
266
|
+
// @ts-expect-error - overriding readonly mock field
|
|
267
|
+
subscriber['pc'].currentRemoteDescription = {
|
|
268
|
+
type: 'offer',
|
|
269
|
+
sdp: sdp('u0'),
|
|
270
|
+
};
|
|
271
|
+
// @ts-expect-error - overriding readonly mock field
|
|
272
|
+
subscriber['pc'].remoteDescription = { type: 'offer', sdp: sdp('u1') };
|
|
273
|
+
// @ts-expect-error - readonly field
|
|
274
|
+
subscriber['pc'].signalingState = 'have-remote-offer';
|
|
275
|
+
sfuClient.sendAnswer = vi
|
|
276
|
+
.fn()
|
|
277
|
+
.mockRejectedValue(new Error('send answer failed'));
|
|
278
|
+
|
|
279
|
+
await expect(subscriber['negotiate'](subscriberOffer)).rejects.toThrow(
|
|
280
|
+
'send answer failed',
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// advanced to the new generation, then restored to the rolled-back one
|
|
284
|
+
expect(updateActiveGeneration).toHaveBeenCalledWith(
|
|
285
|
+
PeerType.SUBSCRIBER,
|
|
286
|
+
sdp('u1'),
|
|
287
|
+
);
|
|
288
|
+
expect(updateActiveGeneration).toHaveBeenLastCalledWith(
|
|
289
|
+
PeerType.SUBSCRIBER,
|
|
290
|
+
sdp('u0'),
|
|
291
|
+
);
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
describe('negotiation failure recovery', () => {
|
|
296
|
+
const offer = SubscriberOffer.create({
|
|
297
|
+
sdp: 'offer-sdp',
|
|
298
|
+
iceRestart: false,
|
|
299
|
+
});
|
|
300
|
+
const dispatchOffer = () =>
|
|
301
|
+
dispatcher.dispatch(
|
|
302
|
+
SfuEvent.create({
|
|
303
|
+
eventPayload: {
|
|
304
|
+
oneofKind: 'subscriberOffer',
|
|
305
|
+
subscriberOffer: offer,
|
|
306
|
+
},
|
|
307
|
+
}) as DispatchableMessage<'subscriberOffer'>,
|
|
308
|
+
'test',
|
|
309
|
+
);
|
|
310
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
311
|
+
|
|
312
|
+
it('retries via ICE restart on a single negotiation failure', async () => {
|
|
313
|
+
// @ts-expect-error - private method
|
|
314
|
+
subscriber.negotiate = vi.fn().mockRejectedValue(new Error('boom'));
|
|
315
|
+
// @ts-expect-error - protected method
|
|
316
|
+
subscriber.tryRestartIce = vi.fn();
|
|
317
|
+
subscriber['onReconnectionNeeded'] = vi.fn();
|
|
318
|
+
|
|
319
|
+
dispatchOffer();
|
|
320
|
+
await flush();
|
|
321
|
+
|
|
322
|
+
// @ts-expect-error - protected method
|
|
323
|
+
expect(subscriber.tryRestartIce).toHaveBeenCalledTimes(1);
|
|
324
|
+
expect(subscriber['onReconnectionNeeded']).not.toHaveBeenCalled();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('escalates to REJOIN after repeated failures instead of looping ICE restarts', async () => {
|
|
328
|
+
// @ts-expect-error - private method
|
|
329
|
+
subscriber.negotiate = vi.fn().mockRejectedValue(new Error('boom'));
|
|
330
|
+
// @ts-expect-error - protected method
|
|
331
|
+
subscriber.tryRestartIce = vi.fn();
|
|
332
|
+
subscriber['onReconnectionNeeded'] = vi.fn();
|
|
333
|
+
|
|
334
|
+
// three consecutive failures (the configured ceiling)
|
|
335
|
+
for (let i = 0; i < 3; i++) {
|
|
336
|
+
dispatchOffer();
|
|
337
|
+
await flush();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// the first two fall through to an ICE restart; the third gives up and rejoins
|
|
341
|
+
// @ts-expect-error - protected method
|
|
342
|
+
expect(subscriber.tryRestartIce).toHaveBeenCalledTimes(2);
|
|
343
|
+
expect(subscriber['onReconnectionNeeded']).toHaveBeenCalledWith(
|
|
344
|
+
WebsocketReconnectStrategy.REJOIN,
|
|
345
|
+
ReconnectReason.SUBSCRIBER_NEGOTIATION_FAILED,
|
|
346
|
+
PeerType.SUBSCRIBER,
|
|
347
|
+
);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('resets the failure counter after a successful negotiation', async () => {
|
|
351
|
+
// @ts-expect-error - private method
|
|
352
|
+
subscriber.negotiate = vi
|
|
353
|
+
.fn()
|
|
354
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
355
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
356
|
+
.mockResolvedValueOnce(undefined)
|
|
357
|
+
.mockRejectedValueOnce(new Error('boom'))
|
|
358
|
+
.mockRejectedValueOnce(new Error('boom'));
|
|
359
|
+
// @ts-expect-error - protected method
|
|
360
|
+
subscriber.tryRestartIce = vi.fn();
|
|
361
|
+
subscriber['onReconnectionNeeded'] = vi.fn();
|
|
362
|
+
|
|
363
|
+
for (let i = 0; i < 5; i++) {
|
|
364
|
+
dispatchOffer();
|
|
365
|
+
await flush();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// four failures total, but never three in a row, so no REJOIN
|
|
369
|
+
expect(subscriber['onReconnectionNeeded']).not.toHaveBeenCalled();
|
|
370
|
+
// @ts-expect-error - protected method
|
|
371
|
+
expect(subscriber.tryRestartIce).toHaveBeenCalledTimes(4);
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe('ICE candidate trickling', () => {
|
|
376
|
+
const trickle = (ufrag: string, candidate: string) => ({
|
|
377
|
+
peerType: PeerType.SUBSCRIBER,
|
|
378
|
+
iceCandidate: JSON.stringify({ usernameFragment: ufrag, candidate }),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const sdp = (ufrag: string) =>
|
|
382
|
+
`v=0\r\na=ice-ufrag:${ufrag}\r\na=ice-pwd:pwd\r\n`;
|
|
383
|
+
|
|
384
|
+
const setRemoteUfrag = (ufrag: string) => {
|
|
385
|
+
// @ts-expect-error - overriding readonly remoteDescription on the mock
|
|
386
|
+
subscriber['pc'].remoteDescription = { type: 'offer', sdp: sdp(ufrag) };
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
390
|
+
|
|
391
|
+
it('declares the active generation from the remote description and adds emitted candidates', async () => {
|
|
392
|
+
const addIceCandidate = vi
|
|
393
|
+
.spyOn(subscriber['pc'], 'addIceCandidate')
|
|
394
|
+
.mockResolvedValue();
|
|
395
|
+
const updateActiveGeneration = vi.spyOn(
|
|
396
|
+
sfuClient.iceTrickleBuffer,
|
|
397
|
+
'updateActiveGeneration',
|
|
398
|
+
);
|
|
399
|
+
setRemoteUfrag('u1');
|
|
400
|
+
sfuClient.iceTrickleBuffer.push(trickle('u1', 'c1'));
|
|
401
|
+
|
|
402
|
+
subscriber['addTrickledIceCandidates']();
|
|
403
|
+
await flush();
|
|
404
|
+
|
|
405
|
+
expect(updateActiveGeneration).toHaveBeenCalledWith(
|
|
406
|
+
PeerType.SUBSCRIBER,
|
|
407
|
+
sdp('u1'),
|
|
408
|
+
);
|
|
409
|
+
expect(addIceCandidate).toHaveBeenCalledWith({
|
|
410
|
+
usernameFragment: 'u1',
|
|
411
|
+
candidate: 'c1',
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it('does not re-add a superseded-generation candidate after an ICE restart', async () => {
|
|
416
|
+
const addIceCandidate = vi
|
|
417
|
+
.spyOn(subscriber['pc'], 'addIceCandidate')
|
|
418
|
+
.mockResolvedValue();
|
|
419
|
+
|
|
420
|
+
setRemoteUfrag('u0');
|
|
421
|
+
sfuClient.iceTrickleBuffer.push(trickle('u0', 'c0'));
|
|
422
|
+
subscriber['addTrickledIceCandidates']();
|
|
423
|
+
await flush();
|
|
424
|
+
expect(addIceCandidate).toHaveBeenCalledWith({
|
|
425
|
+
usernameFragment: 'u0',
|
|
426
|
+
candidate: 'c0',
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
addIceCandidate.mockClear();
|
|
430
|
+
|
|
431
|
+
// ICE restart -> generation u1; the old u0 candidate must not be re-added
|
|
432
|
+
setRemoteUfrag('u1');
|
|
433
|
+
sfuClient.iceTrickleBuffer.push(trickle('u1', 'c1'));
|
|
434
|
+
subscriber['addTrickledIceCandidates']();
|
|
435
|
+
await flush();
|
|
436
|
+
|
|
437
|
+
expect(addIceCandidate).toHaveBeenCalledWith({
|
|
438
|
+
usernameFragment: 'u1',
|
|
439
|
+
candidate: 'c1',
|
|
440
|
+
});
|
|
441
|
+
expect(addIceCandidate).not.toHaveBeenCalledWith({
|
|
442
|
+
usernameFragment: 'u0',
|
|
443
|
+
candidate: 'c0',
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
197
448
|
describe('OnTrack', () => {
|
|
198
449
|
it('should add unknown tracks to the to the call state', () => {
|
|
199
450
|
const mediaStream = new MediaStream();
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getCandidateUfrag, parseIceUfrag, toJSON } from '../iceCandiates';
|
|
3
|
+
|
|
4
|
+
describe('iceCandiates helpers', () => {
|
|
5
|
+
describe('toJSON', () => {
|
|
6
|
+
it('parses the ufrag from the candidate string when usernameFragment is missing (react-native-webrtc)', () => {
|
|
7
|
+
const candidate = {
|
|
8
|
+
candidate:
|
|
9
|
+
'candidate:1 1 udp 100 1.2.3.4 5000 typ host ufrag ABC network-id 1',
|
|
10
|
+
sdpMid: '0',
|
|
11
|
+
} as RTCIceCandidate;
|
|
12
|
+
|
|
13
|
+
const result = JSON.parse(toJSON(candidate));
|
|
14
|
+
expect(result.usernameFragment).toBe('ABC');
|
|
15
|
+
expect(result.sdpMid).toBe('0');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('leaves usernameFragment undefined when it is missing and the candidate has no ufrag token', () => {
|
|
19
|
+
const candidate = {
|
|
20
|
+
candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host generation 0',
|
|
21
|
+
} as unknown as RTCIceCandidate;
|
|
22
|
+
|
|
23
|
+
const result = JSON.parse(toJSON(candidate));
|
|
24
|
+
expect(result.usernameFragment).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('parseIceUfrag', () => {
|
|
29
|
+
it('extracts the ice-ufrag from an SDP', () => {
|
|
30
|
+
const sdp = 'v=0\r\na=ice-ufrag:F7gIaBcD\r\na=ice-pwd:somepwd\r\n';
|
|
31
|
+
expect(parseIceUfrag(sdp)).toBe('F7gIaBcD');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('returns undefined when the SDP has no ice-ufrag', () => {
|
|
35
|
+
expect(parseIceUfrag('v=0\r\na=ice-pwd:somepwd\r\n')).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('returns undefined for an undefined SDP', () => {
|
|
39
|
+
expect(parseIceUfrag(undefined)).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('getCandidateUfrag', () => {
|
|
44
|
+
it('uses usernameFragment when present', () => {
|
|
45
|
+
expect(
|
|
46
|
+
getCandidateUfrag({
|
|
47
|
+
candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host',
|
|
48
|
+
usernameFragment: 'DEF',
|
|
49
|
+
}),
|
|
50
|
+
).toBe('DEF');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('prefers usernameFragment over the candidate-string ufrag token', () => {
|
|
54
|
+
expect(
|
|
55
|
+
getCandidateUfrag({
|
|
56
|
+
candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host ufrag ABC',
|
|
57
|
+
usernameFragment: 'DEF',
|
|
58
|
+
}),
|
|
59
|
+
).toBe('DEF');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('falls back to the ufrag token in the candidate string when usernameFragment is absent', () => {
|
|
63
|
+
expect(
|
|
64
|
+
getCandidateUfrag({
|
|
65
|
+
candidate:
|
|
66
|
+
'candidate:1 1 udp 100 1.2.3.4 5000 typ host ufrag ABC network-id 1',
|
|
67
|
+
}),
|
|
68
|
+
).toBe('ABC');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('reads the ufrag from a realistic SFU trickled candidate string', () => {
|
|
72
|
+
// The SFU embeds the generation as a `ufrag` token in the candidate
|
|
73
|
+
// string, so the consumer can classify it without a usernameFragment.
|
|
74
|
+
const candidate =
|
|
75
|
+
'candidate:842163049 1 udp 1677729535 203.0.113.1 56789 typ srflx raddr 0.0.0.0 rport 0 generation 0 ufrag F7gIaBcD network-id 1 network-cost 10';
|
|
76
|
+
expect(getCandidateUfrag({ candidate })).toBe('F7gIaBcD');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('returns undefined when neither a usernameFragment nor a ufrag token is present', () => {
|
|
80
|
+
expect(
|
|
81
|
+
getCandidateUfrag({
|
|
82
|
+
candidate: 'candidate:1 1 udp 100 1.2.3.4 5000 typ host',
|
|
83
|
+
}),
|
|
84
|
+
).toBeUndefined();
|
|
85
|
+
expect(getCandidateUfrag({})).toBeUndefined();
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts the ICE candidate to a JSON string.
|
|
3
|
+
*/
|
|
4
|
+
export const toJSON = (candidate: RTCIceCandidate) => {
|
|
5
|
+
if (!candidate.usernameFragment) {
|
|
6
|
+
// react-native-webrtc doesn't include usernameFragment in the candidate
|
|
7
|
+
const usernameFragment = parseUfragFromCandidate(candidate.candidate);
|
|
8
|
+
return JSON.stringify({ ...candidate, usernameFragment });
|
|
9
|
+
}
|
|
10
|
+
return JSON.stringify(candidate.toJSON());
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extracts the ICE ufrag from an SDP, or `undefined` when absent.
|
|
15
|
+
*/
|
|
16
|
+
export const parseIceUfrag = (sdp: string | undefined): string | undefined => {
|
|
17
|
+
return sdp?.match(/^a=ice-ufrag:(\S+)/m)?.[1];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Extracts the ICE ufrag (generation) a trickled candidate was gathered under.
|
|
22
|
+
*/
|
|
23
|
+
export const getCandidateUfrag = (ice: RTCIceCandidateInit) => {
|
|
24
|
+
return ice.usernameFragment ?? parseUfragFromCandidate(ice.candidate);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parses the `ufrag` token from a raw ICE candidate string
|
|
29
|
+
* (e.g. `candidate:... ufrag <value> ...`). Returns `undefined` when absent.
|
|
30
|
+
*/
|
|
31
|
+
const parseUfragFromCandidate = (candidate: string | undefined) => {
|
|
32
|
+
const segments = candidate?.split(' ') ?? [];
|
|
33
|
+
const index = segments.indexOf('ufrag');
|
|
34
|
+
return index !== -1 ? segments[index + 1] : undefined;
|
|
35
|
+
};
|
package/src/rtc/helpers/sdp.ts
CHANGED
|
@@ -158,11 +158,12 @@ export const removeCodecsExcept = (
|
|
|
158
158
|
// If a specific fmtp profile is requested, only keep payloads whose fmtp config matches it
|
|
159
159
|
if (fmtpProfileToKeep) {
|
|
160
160
|
const filtered = new Set<number>();
|
|
161
|
-
const required =
|
|
161
|
+
const required = fmtpProfileToKeep.split(';');
|
|
162
162
|
for (const fmtp of media.fmtp) {
|
|
163
|
+
const actual = new Set(fmtp.config.split(';'));
|
|
163
164
|
if (
|
|
164
165
|
payloadsToKeep.has(fmtp.payload) &&
|
|
165
|
-
required.
|
|
166
|
+
required.every((part) => actual.has(part))
|
|
166
167
|
) {
|
|
167
168
|
filtered.add(fmtp.payload);
|
|
168
169
|
}
|
|
@@ -23,6 +23,7 @@ export const trackTypeToParticipantStreamKey = (
|
|
|
23
23
|
throw new Error('Track type is unspecified');
|
|
24
24
|
default:
|
|
25
25
|
ensureExhausted(trackType, 'Unknown track type');
|
|
26
|
+
return undefined;
|
|
26
27
|
}
|
|
27
28
|
};
|
|
28
29
|
|
|
@@ -40,6 +41,7 @@ export const muteTypeToTrackType = (
|
|
|
40
41
|
return TrackType.SCREEN_SHARE_AUDIO;
|
|
41
42
|
default:
|
|
42
43
|
ensureExhausted(muteType, 'Unknown mute type');
|
|
44
|
+
return undefined;
|
|
43
45
|
}
|
|
44
46
|
};
|
|
45
47
|
|
package/src/rtc/types.ts
CHANGED
|
@@ -28,6 +28,8 @@ export const ReconnectReason = {
|
|
|
28
28
|
CONNECTION_FAILED: 'connection_failed',
|
|
29
29
|
/** `restartIce()` rejected. */
|
|
30
30
|
RESTART_ICE_FAILED: 'restart_ice_failed',
|
|
31
|
+
/** Subscriber renegotiation kept failing, escalate to REJOIN. */
|
|
32
|
+
SUBSCRIBER_NEGOTIATION_FAILED: 'subscriber_negotiation_failed',
|
|
31
33
|
/** SFU `goAway` event, migrate to a new SFU. */
|
|
32
34
|
GO_AWAY: 'go_away',
|
|
33
35
|
/** Network came back online after going offline. */
|
|
@@ -110,6 +112,7 @@ export type PublishBundle = {
|
|
|
110
112
|
transceiver: RTCRtpTransceiver;
|
|
111
113
|
options: TrackPublishOptions;
|
|
112
114
|
videoSender?: VideoSender;
|
|
115
|
+
negotiated?: boolean;
|
|
113
116
|
};
|
|
114
117
|
|
|
115
118
|
export type TrackLayersCache = {
|