@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
|
@@ -169,70 +169,70 @@ describe('browsers', () => {
|
|
|
169
169
|
|
|
170
170
|
it('should return true for supported Chrome version', async () => {
|
|
171
171
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
172
|
-
browser: { name: 'Chrome', version: '
|
|
172
|
+
browser: { name: 'Chrome', version: '136' },
|
|
173
173
|
} as ClientDetails);
|
|
174
174
|
expect(await isSupportedBrowser()).toBe(true);
|
|
175
175
|
});
|
|
176
176
|
|
|
177
177
|
it('should return true for supported Chrome detailed version', async () => {
|
|
178
178
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
179
|
-
browser: { name: 'Chrome', version: '
|
|
179
|
+
browser: { name: 'Chrome', version: '136.0.7204.158' },
|
|
180
180
|
} as ClientDetails);
|
|
181
181
|
expect(await isSupportedBrowser()).toBe(true);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
184
|
it('should return false for unsupported Chrome version', async () => {
|
|
185
185
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
186
|
-
browser: { name: 'Chrome', version: '
|
|
186
|
+
browser: { name: 'Chrome', version: '135' },
|
|
187
187
|
} as ClientDetails);
|
|
188
188
|
expect(await isSupportedBrowser()).toBe(false);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
191
|
it('should return false for unsupported Chrome detailed version', async () => {
|
|
192
192
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
193
|
-
browser: { name: 'Chrome', version: '
|
|
193
|
+
browser: { name: 'Chrome', version: '135.0.1234.99' },
|
|
194
194
|
} as ClientDetails);
|
|
195
195
|
expect(await isSupportedBrowser()).toBe(false);
|
|
196
196
|
});
|
|
197
197
|
|
|
198
198
|
it('should return true for supported Edge version', async () => {
|
|
199
199
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
200
|
-
browser: { name: 'Edge', version: '
|
|
200
|
+
browser: { name: 'Edge', version: '136' },
|
|
201
201
|
} as ClientDetails);
|
|
202
202
|
expect(await isSupportedBrowser()).toBe(true);
|
|
203
203
|
});
|
|
204
204
|
|
|
205
205
|
it('should return false for unsupported Edge version', async () => {
|
|
206
206
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
207
|
-
browser: { name: 'Edge', version: '
|
|
207
|
+
browser: { name: 'Edge', version: '135' },
|
|
208
208
|
} as ClientDetails);
|
|
209
209
|
expect(await isSupportedBrowser()).toBe(false);
|
|
210
210
|
});
|
|
211
211
|
|
|
212
212
|
it('should return true for supported Firefox version', async () => {
|
|
213
213
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
214
|
-
browser: { name: 'Firefox', version: '
|
|
214
|
+
browser: { name: 'Firefox', version: '137' },
|
|
215
215
|
} as ClientDetails);
|
|
216
216
|
expect(await isSupportedBrowser()).toBe(true);
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
it('should return false for unsupported Firefox version', async () => {
|
|
220
220
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
221
|
-
browser: { name: 'Firefox', version: '
|
|
221
|
+
browser: { name: 'Firefox', version: '136' },
|
|
222
222
|
} as ClientDetails);
|
|
223
223
|
expect(await isSupportedBrowser()).toBe(false);
|
|
224
224
|
});
|
|
225
225
|
|
|
226
226
|
it('should return true for supported Safari version', async () => {
|
|
227
227
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
228
|
-
browser: { name: 'Safari', version: '
|
|
228
|
+
browser: { name: 'Safari', version: '18' },
|
|
229
229
|
} as ClientDetails);
|
|
230
230
|
expect(await isSupportedBrowser()).toBe(true);
|
|
231
231
|
});
|
|
232
232
|
|
|
233
233
|
it('should return false for unsupported Safari version', async () => {
|
|
234
234
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
235
|
-
browser: { name: 'Safari', version: '
|
|
235
|
+
browser: { name: 'Safari', version: '17' },
|
|
236
236
|
} as ClientDetails);
|
|
237
237
|
expect(await isSupportedBrowser()).toBe(false);
|
|
238
238
|
});
|
|
@@ -253,14 +253,14 @@ describe('browsers', () => {
|
|
|
253
253
|
|
|
254
254
|
it('should return true for supported WebView version (WebView on Android)', async () => {
|
|
255
255
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
256
|
-
browser: { name: 'WebView', version: '
|
|
256
|
+
browser: { name: 'WebView', version: '136' },
|
|
257
257
|
} as ClientDetails);
|
|
258
258
|
expect(await isSupportedBrowser()).toBe(true);
|
|
259
259
|
});
|
|
260
260
|
|
|
261
261
|
it('should return false for unsupported WebView version (WebView on Android)', async () => {
|
|
262
262
|
vi.mocked(getClientDetails).mockResolvedValue({
|
|
263
|
-
browser: { name: 'WebView', version: '
|
|
263
|
+
browser: { name: 'WebView', version: '135' },
|
|
264
264
|
} as ClientDetails);
|
|
265
265
|
expect(await isSupportedBrowser()).toBe(false);
|
|
266
266
|
});
|
package/src/helpers/browsers.ts
CHANGED
|
@@ -62,11 +62,11 @@ export const isSupportedBrowser = async (): Promise<boolean> => {
|
|
|
62
62
|
const [major] = browser.version.split('.');
|
|
63
63
|
const version = parseInt(major, 10);
|
|
64
64
|
return (
|
|
65
|
-
(name.includes('chrome') && version >=
|
|
66
|
-
(name.includes('edge') && version >=
|
|
67
|
-
(name.includes('firefox') && version >=
|
|
68
|
-
(name.includes('safari') && version >=
|
|
65
|
+
(name.includes('chrome') && version >= 136) ||
|
|
66
|
+
(name.includes('edge') && version >= 136) ||
|
|
67
|
+
(name.includes('firefox') && version >= 137) ||
|
|
68
|
+
(name.includes('safari') && version >= 18) ||
|
|
69
69
|
(name.includes('webkit') && version >= 605) || // WebView on iOS
|
|
70
|
-
(name.includes('webview') && version >=
|
|
70
|
+
(name.includes('webview') && version >= 136) // WebView on Android
|
|
71
71
|
);
|
|
72
72
|
};
|
|
@@ -219,7 +219,7 @@ export class ClientEventReporter {
|
|
|
219
219
|
joinAttemptIdSnapshot: this.joinAttemptIds.get(cid),
|
|
220
220
|
};
|
|
221
221
|
|
|
222
|
-
this.
|
|
222
|
+
this.sendForCall(cid, {
|
|
223
223
|
...this.buildCommon(cid, 'MediaDevicePermission', pair),
|
|
224
224
|
...this.sessionIdField(cid),
|
|
225
225
|
microphone_permission_status: readPermissionStatus(
|
|
@@ -322,7 +322,7 @@ export class ClientEventReporter {
|
|
|
322
322
|
};
|
|
323
323
|
|
|
324
324
|
const resolvedSfuId = this.getSfuId(cid);
|
|
325
|
-
this.
|
|
325
|
+
this.sendForCall(cid, {
|
|
326
326
|
...this.buildCommon(cid, stage, pair),
|
|
327
327
|
...this.sessionIdField(cid),
|
|
328
328
|
...(resolvedSfuId && { sfu_id: resolvedSfuId }),
|
|
@@ -395,7 +395,7 @@ export class ClientEventReporter {
|
|
|
395
395
|
const coordinatorConnectId = this.coordinatorConnectId;
|
|
396
396
|
const ctx = this.callContexts.get(cid);
|
|
397
397
|
|
|
398
|
-
this.
|
|
398
|
+
this.sendForCall(cid, {
|
|
399
399
|
user_id: this.streamClient.userID || this.coordinatorConnectUserId,
|
|
400
400
|
type: ctx?.callType,
|
|
401
401
|
id: ctx?.callId,
|
|
@@ -454,7 +454,7 @@ export class ClientEventReporter {
|
|
|
454
454
|
joinReasonSnapshot: this.joinReasons.get(cid),
|
|
455
455
|
};
|
|
456
456
|
this.coordinatorPairs.set(cid, pair);
|
|
457
|
-
this.
|
|
457
|
+
this.sendForCall(cid, {
|
|
458
458
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
459
459
|
...(pair.joinReasonSnapshot && {
|
|
460
460
|
join_reason: pair.joinReasonSnapshot,
|
|
@@ -469,7 +469,7 @@ export class ClientEventReporter {
|
|
|
469
469
|
private succeedCoordinator = (cid: string) => {
|
|
470
470
|
const pair = this.coordinatorPairs.get(cid);
|
|
471
471
|
if (!pair) return;
|
|
472
|
-
this.
|
|
472
|
+
this.sendForCall(cid, {
|
|
473
473
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
474
474
|
...this.sessionIdField(cid),
|
|
475
475
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
@@ -488,7 +488,7 @@ export class ClientEventReporter {
|
|
|
488
488
|
return;
|
|
489
489
|
}
|
|
490
490
|
const { reason, code } = pair.lastError;
|
|
491
|
-
this.
|
|
491
|
+
this.sendForCall(cid, {
|
|
492
492
|
...this.buildCommon(cid, 'CoordinatorJoin', pair),
|
|
493
493
|
...this.sessionIdField(cid),
|
|
494
494
|
...(pair.joinReasonSnapshot && { join_reason: pair.joinReasonSnapshot }),
|
|
@@ -513,7 +513,7 @@ export class ClientEventReporter {
|
|
|
513
513
|
};
|
|
514
514
|
this.wsPairs.set(cid, pair);
|
|
515
515
|
const sfuId = this.getSfuId(cid);
|
|
516
|
-
this.
|
|
516
|
+
this.sendForCall(cid, {
|
|
517
517
|
...this.buildCommon(cid, 'WSJoin', pair),
|
|
518
518
|
...this.sessionIdField(cid),
|
|
519
519
|
...(sfuId && { sfu_id: sfuId }),
|
|
@@ -529,7 +529,7 @@ export class ClientEventReporter {
|
|
|
529
529
|
const pair = this.wsPairs.get(cid);
|
|
530
530
|
if (!pair) return;
|
|
531
531
|
const sfuId = this.getSfuId(cid);
|
|
532
|
-
this.
|
|
532
|
+
this.sendForCall(cid, {
|
|
533
533
|
...this.buildCommon(cid, 'WSJoin', pair),
|
|
534
534
|
...this.sessionIdField(cid),
|
|
535
535
|
...(sfuId && { sfu_id: sfuId }),
|
|
@@ -552,7 +552,7 @@ export class ClientEventReporter {
|
|
|
552
552
|
const { reason, code } = pair.lastError;
|
|
553
553
|
const sfuId = this.getSfuId(cid);
|
|
554
554
|
|
|
555
|
-
this.
|
|
555
|
+
this.sendForCall(cid, {
|
|
556
556
|
...this.buildCommon(cid, 'WSJoin', pair),
|
|
557
557
|
...this.sessionIdField(cid),
|
|
558
558
|
event_type: 'completed',
|
|
@@ -627,7 +627,7 @@ export class ClientEventReporter {
|
|
|
627
627
|
};
|
|
628
628
|
this.peerConnectionPairs.set(key, pair);
|
|
629
629
|
|
|
630
|
-
this.
|
|
630
|
+
this.sendForCall(cid, {
|
|
631
631
|
...this.buildCommon(cid, 'PeerConnectionConnect', pair),
|
|
632
632
|
...this.sessionIdField(cid),
|
|
633
633
|
peer_connection: role,
|
|
@@ -648,7 +648,7 @@ export class ClientEventReporter {
|
|
|
648
648
|
const pair = this.peerConnectionPairs.get(key);
|
|
649
649
|
if (!pair) return;
|
|
650
650
|
|
|
651
|
-
this.
|
|
651
|
+
this.sendForCall(cid, {
|
|
652
652
|
...this.buildCommon(cid, 'PeerConnectionConnect', pair),
|
|
653
653
|
...this.sessionIdField(cid),
|
|
654
654
|
peer_connection: role,
|
|
@@ -676,7 +676,7 @@ export class ClientEventReporter {
|
|
|
676
676
|
const pair = this.peerConnectionPairs.get(key);
|
|
677
677
|
if (!pair) return;
|
|
678
678
|
|
|
679
|
-
this.
|
|
679
|
+
this.sendForCall(cid, {
|
|
680
680
|
...this.buildCommon(cid, 'PeerConnectionConnect', pair),
|
|
681
681
|
...this.sessionIdField(cid),
|
|
682
682
|
peer_connection: role,
|
|
@@ -738,6 +738,11 @@ export class ClientEventReporter {
|
|
|
738
738
|
void this.sendWithRetry(body);
|
|
739
739
|
};
|
|
740
740
|
|
|
741
|
+
private sendForCall = (cid: string, body: Record<string, unknown>) => {
|
|
742
|
+
if (!this.callContexts.has(cid)) return;
|
|
743
|
+
this.send(body);
|
|
744
|
+
};
|
|
745
|
+
|
|
741
746
|
private sendWithRetry = async (
|
|
742
747
|
body: Record<string, unknown>,
|
|
743
748
|
): Promise<boolean> => {
|
|
@@ -617,6 +617,58 @@ describe('ClientEventReporter', () => {
|
|
|
617
617
|
expect(ws1.every((e) => e.sfu_id === 'sfu-1')).toBe(true);
|
|
618
618
|
expect(ws2.every((e) => e.sfu_id === 'sfu-2')).toBe(true);
|
|
619
619
|
});
|
|
620
|
+
|
|
621
|
+
describe('drops events for an unregistered call', () => {
|
|
622
|
+
it('does not emit stage events after the call is unregistered', async () => {
|
|
623
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
624
|
+
reporter.unregisterCall(cid);
|
|
625
|
+
await flush();
|
|
626
|
+
doAxiosRequest.mockClear();
|
|
627
|
+
|
|
628
|
+
await reporter.track(cid, 'WSJoin', () => Promise.resolve('ok'));
|
|
629
|
+
reporter.reportFirstFrame(cid, TrackType.VIDEO, 'track-1');
|
|
630
|
+
await flush();
|
|
631
|
+
|
|
632
|
+
expect(doAxiosRequest).not.toHaveBeenCalled();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('does not emit a stage completion when unregistered mid-flight', async () => {
|
|
636
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
637
|
+
await flush();
|
|
638
|
+
doAxiosRequest.mockClear();
|
|
639
|
+
|
|
640
|
+
await reporter.track(cid, 'WSJoin', async () => {
|
|
641
|
+
reporter.unregisterCall(cid);
|
|
642
|
+
return 'ok';
|
|
643
|
+
});
|
|
644
|
+
await flush();
|
|
645
|
+
|
|
646
|
+
const completed = postedEvents().filter(
|
|
647
|
+
(e) => e.stage === 'WSJoin' && e.event_type === 'completed',
|
|
648
|
+
);
|
|
649
|
+
expect(completed).toHaveLength(0);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('does not emit JoinInitiated for an unregistered call', async () => {
|
|
653
|
+
reporter.unregisterCall(cid);
|
|
654
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
655
|
+
await flush();
|
|
656
|
+
|
|
657
|
+
const joinInitiated = postedEvents().filter(
|
|
658
|
+
(e) => e.stage === 'JoinInitiated',
|
|
659
|
+
);
|
|
660
|
+
expect(joinInitiated).toHaveLength(0);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it('still emits CoordinatorWS events (connection-scoped, not call-gated)', async () => {
|
|
664
|
+
reporter.unregisterCall(cid);
|
|
665
|
+
await reporter.trackCoordinatorWs(() => Promise.resolve('ok'));
|
|
666
|
+
await flush();
|
|
667
|
+
|
|
668
|
+
const ws = postedEvents().filter((e) => e.stage === 'CoordinatorWS');
|
|
669
|
+
expect(ws.length).toBeGreaterThan(0);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
620
672
|
});
|
|
621
673
|
|
|
622
674
|
describe('ClientEventReporter (disabled)', () => {
|
|
@@ -13,6 +13,7 @@ import { StreamSfuClient } from '../StreamSfuClient';
|
|
|
13
13
|
import { AllSfuEvents, Dispatcher } from './Dispatcher';
|
|
14
14
|
import { withoutConcurrency } from '../helpers/concurrency';
|
|
15
15
|
import { StatsTracer, Tracer, traceRTCPeerConnection } from '../stats';
|
|
16
|
+
import { toJSON } from './helpers/iceCandiates';
|
|
16
17
|
import {
|
|
17
18
|
BasePeerConnectionOpts,
|
|
18
19
|
OnIceConnected,
|
|
@@ -37,7 +38,7 @@ export abstract class BasePeerConnection {
|
|
|
37
38
|
protected tag: string;
|
|
38
39
|
protected sfuClient: StreamSfuClient;
|
|
39
40
|
|
|
40
|
-
|
|
41
|
+
protected onReconnectionNeeded?: OnReconnectionNeeded;
|
|
41
42
|
private onIceConnected?: OnIceConnected;
|
|
42
43
|
private onPeerConnectionStateChange?: OnPeerConnectionStateChange;
|
|
43
44
|
protected onRemoteTrackUnmute?: OnRemoteTrackUnmute;
|
|
@@ -224,11 +225,17 @@ export abstract class BasePeerConnection {
|
|
|
224
225
|
* Appends the trickled ICE candidates to the `RTCPeerConnection`.
|
|
225
226
|
*/
|
|
226
227
|
protected addTrickledIceCandidates = () => {
|
|
228
|
+
// Declare the ICE generation this negotiation established so the buffer
|
|
229
|
+
// only replays candidates of the current generation.
|
|
227
230
|
const { iceTrickleBuffer } = this.sfuClient;
|
|
231
|
+
const sdp = this.pc.remoteDescription?.sdp;
|
|
232
|
+
iceTrickleBuffer.updateActiveGeneration(this.peerType, sdp);
|
|
233
|
+
|
|
234
|
+
const { subscriber, publisher } = iceTrickleBuffer;
|
|
228
235
|
const observable =
|
|
229
236
|
this.peerType === PeerType.SUBSCRIBER
|
|
230
|
-
?
|
|
231
|
-
:
|
|
237
|
+
? subscriber.candidates
|
|
238
|
+
: publisher.candidates;
|
|
232
239
|
|
|
233
240
|
this.unsubscribeIceTrickle?.();
|
|
234
241
|
this.unsubscribeIceTrickle = createSafeAsyncSubscription(
|
|
@@ -283,20 +290,6 @@ export abstract class BasePeerConnection {
|
|
|
283
290
|
return !failedStates.has(iceState) && !failedStates.has(connectionState);
|
|
284
291
|
};
|
|
285
292
|
|
|
286
|
-
/**
|
|
287
|
-
* Returns true only when the peer connection is currently fully established
|
|
288
|
-
* (ICE `connected`/`completed` AND connection state `connected`).
|
|
289
|
-
* Transient states like `disconnected`, `checking`, or `new` return false.
|
|
290
|
-
*/
|
|
291
|
-
isStable = () => {
|
|
292
|
-
const iceState = this.pc.iceConnectionState;
|
|
293
|
-
const connectionState = this.pc.connectionState;
|
|
294
|
-
return (
|
|
295
|
-
(iceState === 'connected' || iceState === 'completed') &&
|
|
296
|
-
connectionState === 'connected'
|
|
297
|
-
);
|
|
298
|
-
};
|
|
299
|
-
|
|
300
293
|
/**
|
|
301
294
|
* Handles the ICECandidate event and
|
|
302
295
|
* Initiates an ICE Trickle process with the SFU.
|
|
@@ -308,7 +301,7 @@ export abstract class BasePeerConnection {
|
|
|
308
301
|
return;
|
|
309
302
|
}
|
|
310
303
|
|
|
311
|
-
const iceCandidate =
|
|
304
|
+
const iceCandidate = toJSON(candidate);
|
|
312
305
|
this.sfuClient
|
|
313
306
|
.iceTrickle({ peerType: this.peerType, iceCandidate })
|
|
314
307
|
.catch((err) => {
|
|
@@ -317,20 +310,6 @@ export abstract class BasePeerConnection {
|
|
|
317
310
|
});
|
|
318
311
|
};
|
|
319
312
|
|
|
320
|
-
/**
|
|
321
|
-
* Converts the ICE candidate to a JSON string.
|
|
322
|
-
*/
|
|
323
|
-
private asJSON = (candidate: RTCIceCandidate): string => {
|
|
324
|
-
if (!candidate.usernameFragment) {
|
|
325
|
-
// react-native-webrtc doesn't include usernameFragment in the candidate
|
|
326
|
-
const segments = candidate.candidate.split(' ');
|
|
327
|
-
const ufragIndex = segments.findIndex((s) => s === 'ufrag') + 1;
|
|
328
|
-
const usernameFragment = segments[ufragIndex];
|
|
329
|
-
return JSON.stringify({ ...candidate, usernameFragment });
|
|
330
|
-
}
|
|
331
|
-
return JSON.stringify(candidate.toJSON());
|
|
332
|
-
};
|
|
333
|
-
|
|
334
313
|
/**
|
|
335
314
|
* Handles the ConnectionStateChange event.
|
|
336
315
|
*/
|
|
@@ -343,8 +322,10 @@ export abstract class BasePeerConnection {
|
|
|
343
322
|
});
|
|
344
323
|
if (this.tracer && (state === 'connected' || state === 'failed')) {
|
|
345
324
|
try {
|
|
346
|
-
|
|
347
|
-
|
|
325
|
+
// Sample stats into the delivery chain at connect/fail. The reporter
|
|
326
|
+
// ships and commits the un-acked chain, so we must not trace the delta
|
|
327
|
+
// separately here (that would double-send it and corrupt the chain).
|
|
328
|
+
await this.stats.takeSample();
|
|
348
329
|
} catch (err) {
|
|
349
330
|
this.tracer.trace('getstatsOnFailure', (err as Error).toString());
|
|
350
331
|
}
|
|
@@ -1,33 +1,126 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Observable, Subject } from 'rxjs';
|
|
2
2
|
import { ICETrickle } from '../gen/video/sfu/event/events';
|
|
3
3
|
import { PeerType } from '../gen/video/sfu/models/models';
|
|
4
4
|
import { videoLoggerSystem } from '../logger';
|
|
5
|
+
import { getCandidateUfrag, parseIceUfrag } from './helpers/iceCandiates';
|
|
6
|
+
import { ensureExhausted } from '../helpers/ensureExhausted';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* A buffer for ICE Candidates. Used for ICE Trickle:
|
|
8
10
|
* - https://bloggeek.me/webrtcglossary/trickle-ice/
|
|
11
|
+
*
|
|
12
|
+
* The buffer is generation-aware: each peer connection tells it which ICE
|
|
13
|
+
* generation is current via `updateActiveGeneration` (whenever it applies an
|
|
14
|
+
* offer/answer). Candidate streams then emit only candidates of the active
|
|
15
|
+
* generation, hold candidates of a not-yet-applied (future) generation until
|
|
16
|
+
* it becomes active, and drop candidates of a superseded generation so they
|
|
17
|
+
* are never replayed. Candidates with no detectable generation, or before any
|
|
18
|
+
* generation is set, are emitted as-is (fail open).
|
|
9
19
|
*/
|
|
10
20
|
export class IceTrickleBuffer {
|
|
11
|
-
readonly
|
|
12
|
-
readonly
|
|
21
|
+
readonly subscriber = new CandidateGenerationBuffer();
|
|
22
|
+
readonly publisher = new CandidateGenerationBuffer();
|
|
13
23
|
|
|
14
24
|
push = (iceTrickle: ICETrickle) => {
|
|
15
25
|
const iceCandidate = toIceCandidate(iceTrickle);
|
|
16
26
|
if (!iceCandidate) return;
|
|
17
27
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
const { peerType } = iceTrickle;
|
|
29
|
+
switch (peerType) {
|
|
30
|
+
case PeerType.SUBSCRIBER:
|
|
31
|
+
this.subscriber.push(iceCandidate);
|
|
32
|
+
break;
|
|
33
|
+
case PeerType.PUBLISHER_UNSPECIFIED:
|
|
34
|
+
this.publisher.push(iceCandidate);
|
|
35
|
+
break;
|
|
36
|
+
default:
|
|
37
|
+
ensureExhausted(peerType, `ICETrickle, Unknown peer type`);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Declares the ICE generation that is now current for the given peer type,
|
|
43
|
+
* derived from the `ice-ufrag` of the just-applied remote description.
|
|
44
|
+
* Candidates of superseded generations are evicted; candidates of the active
|
|
45
|
+
* generation flow to subscribers.
|
|
46
|
+
*/
|
|
47
|
+
updateActiveGeneration = (peerType: PeerType, sdp: string | undefined) => {
|
|
48
|
+
const ufrag = parseIceUfrag(sdp);
|
|
49
|
+
switch (peerType) {
|
|
50
|
+
case PeerType.SUBSCRIBER:
|
|
51
|
+
this.subscriber.updateActiveGeneration(ufrag);
|
|
52
|
+
break;
|
|
53
|
+
case PeerType.PUBLISHER_UNSPECIFIED:
|
|
54
|
+
this.publisher.updateActiveGeneration(ufrag);
|
|
55
|
+
break;
|
|
56
|
+
default:
|
|
57
|
+
ensureExhausted(peerType, `updateActiveGeneration, Unknown peer type`);
|
|
25
58
|
}
|
|
26
59
|
};
|
|
27
60
|
|
|
28
61
|
dispose = () => {
|
|
29
|
-
this.
|
|
30
|
-
this.
|
|
62
|
+
this.subscriber.dispose();
|
|
63
|
+
this.publisher.dispose();
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Per-peer-connection generation-aware candidate store. Retains trickled
|
|
69
|
+
* candidates and replays the active generation to each new subscriber, then
|
|
70
|
+
* forwards matching live candidates.
|
|
71
|
+
*/
|
|
72
|
+
class CandidateGenerationBuffer {
|
|
73
|
+
private readonly store: RTCIceCandidateInit[] = [];
|
|
74
|
+
private readonly live = new Subject<RTCIceCandidateInit>();
|
|
75
|
+
private readonly seenUfrags = new Set<string>();
|
|
76
|
+
private activeUfrag: string | undefined;
|
|
77
|
+
|
|
78
|
+
readonly candidates = new Observable<RTCIceCandidateInit>((subscriber) => {
|
|
79
|
+
for (const candidate of this.store.slice()) {
|
|
80
|
+
if (this.isCurrent(candidate)) subscriber.next(candidate);
|
|
81
|
+
}
|
|
82
|
+
const subscription = this.live.subscribe((candidate) => {
|
|
83
|
+
if (this.isCurrent(candidate)) subscriber.next(candidate);
|
|
84
|
+
});
|
|
85
|
+
return () => subscription.unsubscribe();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
push = (candidate: RTCIceCandidateInit) => {
|
|
89
|
+
this.store.push(candidate);
|
|
90
|
+
this.live.next(candidate);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
updateActiveGeneration = (ufrag: string | undefined) => {
|
|
94
|
+
if (ufrag) this.seenUfrags.add(ufrag);
|
|
95
|
+
this.activeUfrag = ufrag;
|
|
96
|
+
// evict candidates from superseded generations (a generation we have
|
|
97
|
+
// applied before but is no longer current); keep future generations.
|
|
98
|
+
for (let i = this.store.length - 1; i >= 0; i--) {
|
|
99
|
+
const candidateUfrag = getCandidateUfrag(this.store[i]);
|
|
100
|
+
if (
|
|
101
|
+
candidateUfrag &&
|
|
102
|
+
candidateUfrag !== this.activeUfrag &&
|
|
103
|
+
this.seenUfrags.has(candidateUfrag)
|
|
104
|
+
) {
|
|
105
|
+
this.store.splice(i, 1);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
dispose = () => {
|
|
111
|
+
this.store.length = 0;
|
|
112
|
+
this.live.complete();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A candidate belongs to the current generation when its ufrag matches the
|
|
117
|
+
* active one. Fail open when either the candidate's generation or the active
|
|
118
|
+
* generation is unknown, so untagged candidates are never withheld.
|
|
119
|
+
*/
|
|
120
|
+
private isCurrent = (candidate: RTCIceCandidateInit): boolean => {
|
|
121
|
+
const candidateUfrag = getCandidateUfrag(candidate);
|
|
122
|
+
if (!candidateUfrag || !this.activeUfrag) return true;
|
|
123
|
+
return candidateUfrag === this.activeUfrag;
|
|
31
124
|
};
|
|
32
125
|
}
|
|
33
126
|
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -1,34 +1,34 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type {
|
|
3
|
-
BasePeerConnectionOpts,
|
|
4
|
-
PublishBundle,
|
|
5
|
-
TrackPublishOptions,
|
|
6
|
-
} from './types';
|
|
7
|
-
import { NegotiationError } from './NegotiationError';
|
|
8
|
-
import { TransceiverCache } from './TransceiverCache';
|
|
1
|
+
import { VideoSender } from '../gen/video/sfu/event/events';
|
|
9
2
|
import {
|
|
10
3
|
PeerType,
|
|
11
4
|
PublishOption,
|
|
12
5
|
TrackInfo,
|
|
13
6
|
TrackType,
|
|
14
7
|
} from '../gen/video/sfu/models/models';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
toSvcEncodings,
|
|
20
|
-
toVideoLayers,
|
|
21
|
-
} from './layers';
|
|
8
|
+
import { isFirefox } from '../helpers/browsers';
|
|
9
|
+
import { withoutConcurrency } from '../helpers/concurrency';
|
|
10
|
+
import { isReactNative } from '../helpers/platforms';
|
|
11
|
+
import { BasePeerConnection } from './BasePeerConnection';
|
|
22
12
|
import { isSvcCodec } from './codecs';
|
|
23
13
|
import {
|
|
24
14
|
fromRTCDegradationPreference,
|
|
25
15
|
toRTCDegradationPreference,
|
|
26
16
|
} from './helpers/degradationPreference';
|
|
27
|
-
import { isAudioTrackType } from './helpers/tracks';
|
|
28
17
|
import { extractMid, removeCodecsExcept, setStartBitrate } from './helpers/sdp';
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
31
|
-
|
|
18
|
+
import { isAudioTrackType } from './helpers/tracks';
|
|
19
|
+
import {
|
|
20
|
+
computeAudioLayers,
|
|
21
|
+
computeVideoLayers,
|
|
22
|
+
toSvcEncodings,
|
|
23
|
+
toVideoLayers,
|
|
24
|
+
} from './layers';
|
|
25
|
+
import { NegotiationError } from './NegotiationError';
|
|
26
|
+
import { TransceiverCache } from './TransceiverCache';
|
|
27
|
+
import type {
|
|
28
|
+
BasePeerConnectionOpts,
|
|
29
|
+
PublishBundle,
|
|
30
|
+
TrackPublishOptions,
|
|
31
|
+
} from './types';
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* The `Publisher` is responsible for publishing/unpublishing media streams to/from the SFU
|