@stream-io/video-client 1.54.1-beta.0 → 1.55.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.
- package/CHANGELOG.md +14 -0
- package/dist/index.browser.es.js +9672 -8865
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +9673 -8866
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +9674 -8867
- 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 +1 -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 +2 -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 +22 -0
- package/src/coordinator/connection/connection.ts +8 -5
- 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 +19 -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 +2 -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 +2 -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
|
@@ -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. */
|