@stream-io/video-client 1.55.1 → 1.56.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 +18 -0
- package/dist/index.browser.es.js +192 -103
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +192 -103
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +192 -103
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +4 -0
- package/dist/src/helpers/BlockedAudioTracker.d.ts +16 -1
- package/dist/src/reporting/ClientEventReporter.d.ts +1 -0
- package/dist/src/rtc/Publisher.d.ts +1 -0
- package/dist/src/rtc/types.d.ts +9 -2
- package/dist/src/store/CallState.d.ts +4 -0
- package/dist/src/store/rxUtils.d.ts +5 -0
- package/package.json +2 -2
- package/src/Call.ts +8 -0
- package/src/helpers/BlockedAudioTracker.ts +71 -13
- package/src/helpers/DynascaleManager.ts +14 -3
- package/src/helpers/__tests__/BlockedAudioTracker.test.ts +54 -6
- package/src/helpers/__tests__/DynascaleManager.test.ts +45 -0
- package/src/reporting/ClientEventReporter.ts +37 -31
- package/src/reporting/__tests__/ClientEventReporter.test.ts +159 -44
- package/src/rtc/BasePeerConnection.ts +1 -0
- package/src/rtc/Publisher.ts +78 -58
- package/src/rtc/__tests__/Publisher.test.ts +297 -0
- package/src/rtc/types.ts +13 -11
- package/src/store/CallState.ts +12 -17
- package/src/store/rxUtils.ts +15 -0
|
@@ -7,6 +7,7 @@ import { ErrorFromResponse } from '../../coordinator/connection/types';
|
|
|
7
7
|
import { SfuTimeoutError } from '../../errors';
|
|
8
8
|
import type { AxiosResponse } from 'axios';
|
|
9
9
|
import { PeerType, TrackType } from '../../gen/video/sfu/models/models';
|
|
10
|
+
import type { PeerConnectionStateChangeEvent } from '../../rtc';
|
|
10
11
|
|
|
11
12
|
vi.mock('../../devices', () => ({
|
|
12
13
|
getAudioBrowserPermission: () => ({ asStateObservable: () => of('granted') }),
|
|
@@ -15,6 +16,27 @@ vi.mock('../../devices', () => ({
|
|
|
15
16
|
|
|
16
17
|
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
17
18
|
|
|
19
|
+
const peerConnectionEvent = (
|
|
20
|
+
peerType: PeerType,
|
|
21
|
+
state: RTCPeerConnectionState,
|
|
22
|
+
iceConnectionState: RTCIceConnectionState,
|
|
23
|
+
): PeerConnectionStateChangeEvent => ({
|
|
24
|
+
peerType,
|
|
25
|
+
stateType: 'peerConnection',
|
|
26
|
+
state,
|
|
27
|
+
iceConnectionState,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const iceEvent = (
|
|
31
|
+
peerType: PeerType,
|
|
32
|
+
state: RTCIceConnectionState,
|
|
33
|
+
): PeerConnectionStateChangeEvent => ({
|
|
34
|
+
peerType,
|
|
35
|
+
stateType: 'ice',
|
|
36
|
+
state,
|
|
37
|
+
iceConnectionState: state,
|
|
38
|
+
});
|
|
39
|
+
|
|
18
40
|
describe('ClientEventReporter', () => {
|
|
19
41
|
const cid = 'default:call-1';
|
|
20
42
|
let doAxiosRequest: ReturnType<typeof vi.fn>;
|
|
@@ -24,6 +46,21 @@ describe('ClientEventReporter', () => {
|
|
|
24
46
|
const postedEvents = (): Array<Record<string, any>> =>
|
|
25
47
|
doAxiosRequest.mock.calls.map((call) => call[2].events[0]);
|
|
26
48
|
|
|
49
|
+
const reportPeerConnectionState = (
|
|
50
|
+
peerType: PeerType,
|
|
51
|
+
state: RTCPeerConnectionState,
|
|
52
|
+
iceConnectionState: RTCIceConnectionState,
|
|
53
|
+
) => {
|
|
54
|
+
reporter.onPeerConnectionStateChange(
|
|
55
|
+
cid,
|
|
56
|
+
peerConnectionEvent(peerType, state, iceConnectionState),
|
|
57
|
+
);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const reportIceState = (peerType: PeerType, state: RTCIceConnectionState) => {
|
|
61
|
+
reporter.onPeerConnectionStateChange(cid, iceEvent(peerType, state));
|
|
62
|
+
};
|
|
63
|
+
|
|
27
64
|
beforeEach(() => {
|
|
28
65
|
doAxiosRequest = vi.fn().mockResolvedValue({});
|
|
29
66
|
const streamClient = fromPartial<StreamClient>({
|
|
@@ -465,16 +502,16 @@ describe('ClientEventReporter', () => {
|
|
|
465
502
|
|
|
466
503
|
it('tracks a publisher peer connection connect', async () => {
|
|
467
504
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
505
|
+
reportPeerConnectionState(
|
|
506
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
507
|
+
'connecting',
|
|
508
|
+
'checking',
|
|
509
|
+
);
|
|
510
|
+
reportPeerConnectionState(
|
|
511
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
512
|
+
'connected',
|
|
513
|
+
'connected',
|
|
514
|
+
);
|
|
478
515
|
await flush();
|
|
479
516
|
|
|
480
517
|
const events = postedEvents().filter(
|
|
@@ -497,16 +534,8 @@ describe('ClientEventReporter', () => {
|
|
|
497
534
|
|
|
498
535
|
it('reports an ICE failure on the peer connection', async () => {
|
|
499
536
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
stateType: 'peerConnection',
|
|
503
|
-
state: 'connecting',
|
|
504
|
-
});
|
|
505
|
-
reporter.onPeerConnectionStateChange(cid, {
|
|
506
|
-
peerType: PeerType.SUBSCRIBER,
|
|
507
|
-
stateType: 'ice',
|
|
508
|
-
state: 'failed',
|
|
509
|
-
});
|
|
537
|
+
reportPeerConnectionState(PeerType.SUBSCRIBER, 'connecting', 'checking');
|
|
538
|
+
reportIceState(PeerType.SUBSCRIBER, 'failed');
|
|
510
539
|
await flush();
|
|
511
540
|
|
|
512
541
|
const completed = postedEvents().filter(
|
|
@@ -522,25 +551,111 @@ describe('ClientEventReporter', () => {
|
|
|
522
551
|
});
|
|
523
552
|
});
|
|
524
553
|
|
|
525
|
-
it('
|
|
554
|
+
it('reports a DTLS failure with the real ICE state (connected)', async () => {
|
|
526
555
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
556
|
+
reportPeerConnectionState(
|
|
557
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
558
|
+
'connecting',
|
|
559
|
+
'checking',
|
|
560
|
+
);
|
|
561
|
+
reportIceState(PeerType.PUBLISHER_UNSPECIFIED, 'connected');
|
|
562
|
+
// Aggregate connection fails while ICE stays connected -> genuine DTLS.
|
|
563
|
+
reportPeerConnectionState(
|
|
564
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
565
|
+
'failed',
|
|
566
|
+
'connected',
|
|
567
|
+
);
|
|
568
|
+
await flush();
|
|
569
|
+
|
|
570
|
+
const completed = postedEvents().filter(
|
|
571
|
+
(e) =>
|
|
572
|
+
e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
|
|
573
|
+
);
|
|
574
|
+
expect(completed).toHaveLength(1);
|
|
575
|
+
expect(completed[0]).toMatchObject({
|
|
576
|
+
outcome: 'failure',
|
|
577
|
+
peer_connection: 'publish',
|
|
578
|
+
retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
|
|
579
|
+
ice_state: 'CONNECTED',
|
|
531
580
|
});
|
|
581
|
+
});
|
|
532
582
|
|
|
533
|
-
|
|
534
|
-
reporter.
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
583
|
+
it('reports a DTLS failure with the real ICE state (never connected)', async () => {
|
|
584
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
585
|
+
reportPeerConnectionState(
|
|
586
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
587
|
+
'connecting',
|
|
588
|
+
'checking',
|
|
589
|
+
);
|
|
590
|
+
// ICE is still `checking` when the aggregate peer-connection state fails:
|
|
591
|
+
// the reported ice_state must be NOT_CONNECTED, not an assumed CONNECTED.
|
|
592
|
+
reportPeerConnectionState(
|
|
593
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
594
|
+
'failed',
|
|
595
|
+
'checking',
|
|
596
|
+
);
|
|
597
|
+
await flush();
|
|
598
|
+
|
|
599
|
+
const completed = postedEvents().filter(
|
|
600
|
+
(e) =>
|
|
601
|
+
e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
|
|
602
|
+
);
|
|
603
|
+
expect(completed).toHaveLength(1);
|
|
604
|
+
expect(completed[0]).toMatchObject({
|
|
605
|
+
outcome: 'failure',
|
|
606
|
+
peer_connection: 'publish',
|
|
607
|
+
retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
|
|
608
|
+
ice_state: 'NOT_CONNECTED',
|
|
538
609
|
});
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('reports the ICE state captured when the pair opened, with no ICE event', async () => {
|
|
613
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
614
|
+
// The pair opens with ICE already connected (carried on the connecting
|
|
615
|
+
// event); no separate ICE event is ever delivered.
|
|
616
|
+
reportPeerConnectionState(
|
|
617
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
618
|
+
'connecting',
|
|
619
|
+
'connected',
|
|
620
|
+
);
|
|
621
|
+
reportPeerConnectionState(
|
|
622
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
623
|
+
'failed',
|
|
624
|
+
'connected',
|
|
625
|
+
);
|
|
626
|
+
await flush();
|
|
627
|
+
|
|
628
|
+
const completed = postedEvents().filter(
|
|
629
|
+
(e) =>
|
|
630
|
+
e.stage === 'PeerConnectionConnect' && e.event_type === 'completed',
|
|
631
|
+
);
|
|
632
|
+
expect(completed).toHaveLength(1);
|
|
633
|
+
expect(completed[0]).toMatchObject({
|
|
634
|
+
outcome: 'failure',
|
|
635
|
+
retry_failure_code: 'DTLS_CONNECTIVITY_FAILED',
|
|
636
|
+
ice_state: 'CONNECTED',
|
|
543
637
|
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('opens a fresh peer connection pair after a new correlation', async () => {
|
|
641
|
+
reporter.startCorrelation(cid, 'first-attempt');
|
|
642
|
+
reportPeerConnectionState(
|
|
643
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
644
|
+
'connecting',
|
|
645
|
+
'checking',
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
reporter.startCorrelation(cid, 'full-rejoin');
|
|
649
|
+
reportPeerConnectionState(
|
|
650
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
651
|
+
'connecting',
|
|
652
|
+
'checking',
|
|
653
|
+
);
|
|
654
|
+
reportPeerConnectionState(
|
|
655
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
656
|
+
'connected',
|
|
657
|
+
'connected',
|
|
658
|
+
);
|
|
544
659
|
await flush();
|
|
545
660
|
|
|
546
661
|
const pc = postedEvents().filter(
|
|
@@ -566,16 +681,16 @@ describe('ClientEventReporter', () => {
|
|
|
566
681
|
it('marks a peer connection as previously connected on reconnect', async () => {
|
|
567
682
|
reporter.startCorrelation(cid, 'first-attempt');
|
|
568
683
|
const connect = () => {
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
684
|
+
reportPeerConnectionState(
|
|
685
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
686
|
+
'connecting',
|
|
687
|
+
'checking',
|
|
688
|
+
);
|
|
689
|
+
reportPeerConnectionState(
|
|
690
|
+
PeerType.PUBLISHER_UNSPECIFIED,
|
|
691
|
+
'connected',
|
|
692
|
+
'connected',
|
|
693
|
+
);
|
|
579
694
|
};
|
|
580
695
|
connect();
|
|
581
696
|
connect();
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -40,6 +40,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
40
40
|
private readonly clonedTracks = new Set<MediaStreamTrack>();
|
|
41
41
|
private publishOptions: PublishOption[];
|
|
42
42
|
private readonly selfSubEnabled: boolean;
|
|
43
|
+
private readonly transceiverLockKey = `pub.tx.${this.lock}`;
|
|
43
44
|
|
|
44
45
|
/**
|
|
45
46
|
* Constructs a new `Publisher` instance.
|
|
@@ -107,28 +108,31 @@ export class Publisher extends BasePeerConnection {
|
|
|
107
108
|
trackType: TrackType,
|
|
108
109
|
options: TrackPublishOptions = {},
|
|
109
110
|
) => {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// create a clone of the track as otherwise the same trackId will
|
|
118
|
-
// appear in the SDP in multiple transceivers
|
|
119
|
-
const trackToPublish = this.cloneTrack(track);
|
|
111
|
+
return withoutConcurrency(this.transceiverLockKey, async () => {
|
|
112
|
+
const publishOptions = this.publishOptions.filter(
|
|
113
|
+
(o) => o.trackType === trackType,
|
|
114
|
+
);
|
|
115
|
+
if (!publishOptions.length) {
|
|
116
|
+
throw new Error(`No publish options found for ${TrackType[trackType]}`);
|
|
117
|
+
}
|
|
120
118
|
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!
|
|
128
|
-
this.
|
|
119
|
+
for (const publishOption of publishOptions) {
|
|
120
|
+
// create a clone of the track as otherwise the same trackId will
|
|
121
|
+
// appear in the SDP in multiple transceivers
|
|
122
|
+
const trackToPublish = this.cloneTrack(track);
|
|
123
|
+
|
|
124
|
+
const bundle = this.transceiverCache.get(publishOption);
|
|
125
|
+
if (!bundle) {
|
|
126
|
+
await this.addTransceiver(trackToPublish, publishOption, options);
|
|
127
|
+
} else {
|
|
128
|
+
const previousTrack = bundle.transceiver.sender.track;
|
|
129
|
+
await this.updateTransceiver(bundle, trackToPublish, options);
|
|
130
|
+
if (!isReactNative()) {
|
|
131
|
+
this.stopTrack(previousTrack);
|
|
132
|
+
}
|
|
129
133
|
}
|
|
130
134
|
}
|
|
131
|
-
}
|
|
135
|
+
});
|
|
132
136
|
};
|
|
133
137
|
|
|
134
138
|
/**
|
|
@@ -139,6 +143,7 @@ export class Publisher extends BasePeerConnection {
|
|
|
139
143
|
publishOption: PublishOption,
|
|
140
144
|
options: TrackPublishOptions,
|
|
141
145
|
) => {
|
|
146
|
+
const trackType = publishOption.trackType;
|
|
142
147
|
const encodings = isAudioTrackType(publishOption.trackType)
|
|
143
148
|
? computeAudioLayers(publishOption, options)
|
|
144
149
|
: computeVideoLayers(track, publishOption);
|
|
@@ -149,6 +154,12 @@ export class Publisher extends BasePeerConnection {
|
|
|
149
154
|
direction: 'sendonly',
|
|
150
155
|
sendEncodings,
|
|
151
156
|
});
|
|
157
|
+
// claim the cache entry in the same task that created the transceiver:
|
|
158
|
+
// an await in between would let a concurrent caller add a second one
|
|
159
|
+
// for the same publish option
|
|
160
|
+
this.transceiverCache.add({ publishOption, transceiver, options });
|
|
161
|
+
this.trackIdToTrackType.set(track.id, trackType);
|
|
162
|
+
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
152
163
|
|
|
153
164
|
const params = transceiver.sender.getParameters();
|
|
154
165
|
params.degradationPreference =
|
|
@@ -156,11 +167,6 @@ export class Publisher extends BasePeerConnection {
|
|
|
156
167
|
'maintain-framerate';
|
|
157
168
|
await transceiver.sender.setParameters(params);
|
|
158
169
|
|
|
159
|
-
const trackType = publishOption.trackType;
|
|
160
|
-
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
161
|
-
this.transceiverCache.add({ publishOption, transceiver, options });
|
|
162
|
-
this.trackIdToTrackType.set(track.id, trackType);
|
|
163
|
-
|
|
164
170
|
await this.negotiate();
|
|
165
171
|
};
|
|
166
172
|
|
|
@@ -225,38 +231,40 @@ export class Publisher extends BasePeerConnection {
|
|
|
225
231
|
* Synchronizes the current Publisher state with the provided publish options.
|
|
226
232
|
*/
|
|
227
233
|
private syncPublishOptions = async () => {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
234
|
+
return withoutConcurrency(this.transceiverLockKey, async () => {
|
|
235
|
+
// enable publishing with new options -> [av1, vp9]
|
|
236
|
+
for (const publishOption of this.publishOptions) {
|
|
237
|
+
const { trackType } = publishOption;
|
|
238
|
+
if (!this.isPublishing(trackType)) continue;
|
|
239
|
+
if (this.transceiverCache.has(publishOption)) continue;
|
|
240
|
+
|
|
241
|
+
const item = this.transceiverCache.find(
|
|
242
|
+
(i) =>
|
|
243
|
+
!!i.transceiver.sender.track &&
|
|
244
|
+
i.publishOption.trackType === trackType,
|
|
245
|
+
);
|
|
246
|
+
if (!item) continue;
|
|
247
|
+
|
|
248
|
+
// take the track from the existing transceiver for the same track type,
|
|
249
|
+
// clone it and publish it with the new publish options
|
|
250
|
+
const track = this.cloneTrack(item.transceiver.sender.track!);
|
|
251
|
+
await this.addTransceiver(track, publishOption, item.options);
|
|
252
|
+
}
|
|
246
253
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
254
|
+
// stop publishing with options not required anymore -> [vp9]
|
|
255
|
+
for (const item of this.transceiverCache.items()) {
|
|
256
|
+
const { publishOption, transceiver } = item;
|
|
257
|
+
const hasPublishOption = this.publishOptions.some(
|
|
258
|
+
(option) =>
|
|
259
|
+
option.id === publishOption.id &&
|
|
260
|
+
option.trackType === publishOption.trackType,
|
|
261
|
+
);
|
|
262
|
+
if (hasPublishOption) continue;
|
|
263
|
+
// it is safe to stop the track here, it is a clone
|
|
264
|
+
this.stopTrack(transceiver.sender.track);
|
|
265
|
+
await this.updateTransceiver(item, null);
|
|
266
|
+
}
|
|
267
|
+
});
|
|
260
268
|
};
|
|
261
269
|
|
|
262
270
|
/**
|
|
@@ -456,13 +464,25 @@ export class Publisher extends BasePeerConnection {
|
|
|
456
464
|
private negotiate = async (options?: RTCOfferOptions): Promise<void> => {
|
|
457
465
|
return withoutConcurrency(`publisher.negotiate.${this.lock}`, async () => {
|
|
458
466
|
const offer = await this.pc.createOffer(options);
|
|
459
|
-
const tracks = this.getAnnouncedTracks(offer.sdp);
|
|
460
|
-
if (!tracks.length) throw new Error(`Can't negotiate without any tracks`);
|
|
461
|
-
|
|
462
467
|
try {
|
|
463
468
|
this.isIceRestarting = options?.iceRestart ?? false;
|
|
464
469
|
await this.pc.setLocalDescription(offer);
|
|
465
470
|
|
|
471
|
+
// the announced tracks have to be read after `setLocalDescription`.
|
|
472
|
+
// `transceiver.mid` is null until the offer is applied, and reading the
|
|
473
|
+
// tracks earlier makes `extractMid` guess the mid from the offer SDP.
|
|
474
|
+
// That guess goes stale after a rolled-back negotiation, and a mid that
|
|
475
|
+
// doesn't match the m-section carrying the track stops the SFU from
|
|
476
|
+
// correlating the two: it then answers with every offered codec instead
|
|
477
|
+
// of the announced one, and we publish the first codec of that list.
|
|
478
|
+
// Per https://www.w3.org/TR/webrtc/#set-description, applying a local
|
|
479
|
+
// offer associates the transceiver with its m-section and sets
|
|
480
|
+
// `[[Mid]]` to `[[JsepMid]]`; a rollback disassociates a transceiver
|
|
481
|
+
// that wasn't associated before and resets both slots to null.
|
|
482
|
+
const tracks = this.getAnnouncedTracks(offer.sdp);
|
|
483
|
+
if (!tracks.length)
|
|
484
|
+
throw new Error(`Can't negotiate without any tracks`);
|
|
485
|
+
|
|
466
486
|
const { sdp: baseSdp = '' } = offer;
|
|
467
487
|
const {
|
|
468
488
|
dangerouslyForceCodec,
|