@stream-io/video-client 1.59.1 → 1.61.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 +744 -156
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +744 -155
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +744 -156
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +34 -8
- package/dist/src/coordinator/connection/types.d.ts +16 -0
- package/dist/src/events/call.d.ts +0 -11
- package/dist/src/gen/coordinator/index.d.ts +73 -0
- package/dist/src/reporting/ClientEventReporter.d.ts +11 -1
- package/dist/src/ringing/RingStatePoller.d.ts +46 -0
- package/dist/src/ringing/RingTimeout.d.ts +27 -0
- package/dist/src/ringing/index.d.ts +4 -0
- package/dist/src/ringing/reconcileRingState.d.ts +18 -0
- package/dist/src/ringing/resolveOwnRingOutcome.d.ts +32 -0
- package/dist/src/rtc/Publisher.d.ts +18 -0
- package/dist/src/rtc/TransceiverCache.d.ts +10 -0
- package/dist/src/rtc/e2ee/E2EEManager.d.ts +10 -1
- package/dist/src/stats/utils.d.ts +6 -0
- package/dist/src/store/CallState.d.ts +10 -1
- package/dist/src/types.d.ts +30 -1
- package/index.ts +1 -0
- package/package.json +1 -1
- package/src/Call.ts +272 -108
- package/src/StreamSfuClient.ts +6 -2
- package/src/__tests__/Call.autodrop.test.ts +116 -64
- package/src/__tests__/Call.getRingState.test.ts +87 -0
- package/src/__tests__/Call.lifecycle.test.ts +498 -5
- package/src/__tests__/Call.ringSettled.test.ts +71 -0
- package/src/__tests__/Call.ringingLifecycle.test.ts +242 -0
- package/src/__tests__/StreamSfuClient.test.ts +48 -0
- package/src/coordinator/connection/types.ts +19 -0
- package/src/events/__tests__/call.test.ts +2 -217
- package/src/events/call.ts +1 -73
- package/src/events/callEventHandlers.ts +12 -23
- package/src/gen/coordinator/index.ts +67 -0
- package/src/reporting/ClientEventReporter.ts +30 -4
- package/src/reporting/__tests__/ClientEventReporter.test.ts +184 -7
- package/src/ringing/RingStatePoller.ts +177 -0
- package/src/ringing/RingTimeout.ts +90 -0
- package/src/ringing/__tests__/RingStatePoller.test.ts +462 -0
- package/src/ringing/__tests__/RingTimeout.test.ts +199 -0
- package/src/ringing/__tests__/reconcileRingState.test.ts +330 -0
- package/src/ringing/__tests__/resolveOwnRingOutcome.test.ts +103 -0
- package/src/ringing/index.ts +4 -0
- package/src/ringing/reconcileRingState.ts +108 -0
- package/src/ringing/resolveOwnRingOutcome.ts +58 -0
- package/src/rtc/Publisher.ts +66 -12
- package/src/rtc/TransceiverCache.ts +17 -0
- package/src/rtc/__tests__/Publisher.test.ts +209 -0
- package/src/rtc/__tests__/mocks/webrtc.mocks.ts +1 -0
- package/src/rtc/e2ee/E2EEManager.ts +10 -1
- package/src/rtc/e2ee/EncryptionManager.ts +1 -1
- package/src/rtc/e2ee/SPEC.md +1 -1
- package/src/stats/__tests__/utils.test.ts +33 -0
- package/src/stats/utils.ts +15 -0
- package/src/store/CallState.ts +25 -0
- package/src/store/__tests__/CallState.test.ts +59 -0
- package/src/types.ts +37 -1
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { Call } from '../Call';
|
|
2
|
+
import type { JoinSource } from '../reporting';
|
|
3
|
+
import { CallingState } from '../store';
|
|
4
|
+
import type { CallLeaveOptions } from '../types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decides what a ringing call should do next, based on the current call state.
|
|
8
|
+
*
|
|
9
|
+
* The `call.accepted`, `call.rejected` and `call.missed` handlers and the ring
|
|
10
|
+
* state poller both run this. They differ only in how the state got there: the
|
|
11
|
+
* handlers rely on `CallState.updateFromEvent`, which runs before them, and the
|
|
12
|
+
* poller applies the polled ring state itself.
|
|
13
|
+
*
|
|
14
|
+
* @param call the call to reconcile.
|
|
15
|
+
* @param joinSource which of the two triggered this run, reported on the
|
|
16
|
+
* caller's join: `ring-ws` for the event handlers, `ring-poll-api` for the
|
|
17
|
+
* poller.
|
|
18
|
+
* @returns whether the ring reached a terminal state. A failed join is not
|
|
19
|
+
* terminal: the caller should keep trying while the ring is open.
|
|
20
|
+
*/
|
|
21
|
+
export const reconcileRingState = async (
|
|
22
|
+
call: Call,
|
|
23
|
+
joinSource: JoinSource,
|
|
24
|
+
): Promise<boolean> => {
|
|
25
|
+
if (call.state.callingState !== CallingState.RINGING) return true;
|
|
26
|
+
return call.isCreatedByMe
|
|
27
|
+
? reconcileAsCaller(call, joinSource)
|
|
28
|
+
: reconcileAsCallee(call);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const reconcileAsCaller = async (
|
|
32
|
+
call: Call,
|
|
33
|
+
joinSource: JoinSource,
|
|
34
|
+
): Promise<boolean> => {
|
|
35
|
+
const { session, members, endedAt } = call.state;
|
|
36
|
+
const currentUserId = call.currentUserId;
|
|
37
|
+
|
|
38
|
+
// checked before `accepted_by`: an ended session cannot be joined
|
|
39
|
+
if (endedAt || session?.ended_at) {
|
|
40
|
+
call.logger.info('ring: the call has ended, leaving');
|
|
41
|
+
// `leave` reports the remote end to callingx off `reason: 'ended'`
|
|
42
|
+
return leave(call, {
|
|
43
|
+
reject: false,
|
|
44
|
+
reason: 'ended',
|
|
45
|
+
message: 'ring: call ended',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const acceptedBy = session?.accepted_by ?? {};
|
|
50
|
+
if (Object.keys(acceptedBy).some((userId) => userId !== currentUserId)) {
|
|
51
|
+
call.logger.info('ring: the call was accepted, joining');
|
|
52
|
+
try {
|
|
53
|
+
await call.join({ joinSource });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
// `doJoin` restores the ringing state when a join fails, so the ring is
|
|
56
|
+
// still open. Report it unsettled and let the next poll retry.
|
|
57
|
+
call.logger.error('Failed to join an accepted call', err);
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const otherMembers = members
|
|
64
|
+
.filter((member) => member.user_id !== currentUserId)
|
|
65
|
+
.map((member) => member.user_id);
|
|
66
|
+
if (otherMembers.length === 0) return false;
|
|
67
|
+
|
|
68
|
+
const rejectedBy = session?.rejected_by ?? {};
|
|
69
|
+
if (otherMembers.every((userId) => rejectedBy[userId])) {
|
|
70
|
+
call.logger.info('ring: everyone rejected, leaving');
|
|
71
|
+
return leave(call, {
|
|
72
|
+
reject: true,
|
|
73
|
+
reason: 'cancel',
|
|
74
|
+
message: 'ring: everyone rejected',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return false;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// the current user's own accept or reject, on this or another device, is
|
|
82
|
+
// handled by `resolveOwnRingOutcome`, and `call.ended` by `watchCallEnded`.
|
|
83
|
+
const reconcileAsCallee = async (call: Call): Promise<boolean> => {
|
|
84
|
+
const createdById = call.state.createdBy?.id;
|
|
85
|
+
const rejectedBy = call.state.session?.rejected_by ?? {};
|
|
86
|
+
if (createdById && rejectedBy[createdById]) {
|
|
87
|
+
call.logger.info('ring: the caller cancelled, leaving');
|
|
88
|
+
return leave(call, {
|
|
89
|
+
reason: 'ended',
|
|
90
|
+
message: 'ring: creator rejected',
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// `false` when the call could not be left, so the ring stays open for a retry.
|
|
97
|
+
const leave = async (
|
|
98
|
+
call: Call,
|
|
99
|
+
options: CallLeaveOptions,
|
|
100
|
+
): Promise<boolean> => {
|
|
101
|
+
try {
|
|
102
|
+
await call.leave(options);
|
|
103
|
+
return true;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
call.logger.error('Failed to leave a ringing call', err);
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CallingState } from '../store';
|
|
2
|
+
import type { CallSessionResponse } from '../gen/coordinator';
|
|
3
|
+
|
|
4
|
+
export type OwnRingOutcome = {
|
|
5
|
+
/**
|
|
6
|
+
* Whether the current user accepted or rejected the ring, on this device or
|
|
7
|
+
* on another one. Either way the ring no longer needs to time out.
|
|
8
|
+
*/
|
|
9
|
+
settledByMe: boolean;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Set when this device should stop ringing and leave, carrying the reason to
|
|
13
|
+
* report to the native call UI.
|
|
14
|
+
*/
|
|
15
|
+
leaveReason?: 'answeredElsewhere' | 'rejected';
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type OwnRingOutcomeInput = {
|
|
19
|
+
/** The call session to read the accept and reject maps from. */
|
|
20
|
+
session: CallSessionResponse | undefined;
|
|
21
|
+
/** The connected user, or `undefined` when there is none. */
|
|
22
|
+
currentUserId: string | undefined;
|
|
23
|
+
/** The current calling state. */
|
|
24
|
+
callingState: CallingState;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Decides what a ringing call should do about the current user's own accept or
|
|
29
|
+
* reject, which may have happened on another device.
|
|
30
|
+
*
|
|
31
|
+
* Accepting on *this* device also lands in `accepted_by`, so an acceptance only
|
|
32
|
+
* means another device took the call when this one is still ringing.
|
|
33
|
+
*
|
|
34
|
+
* Only meaningful for a ringing call; the caller checks that.
|
|
35
|
+
*/
|
|
36
|
+
export const resolveOwnRingOutcome = ({
|
|
37
|
+
session,
|
|
38
|
+
currentUserId,
|
|
39
|
+
callingState,
|
|
40
|
+
}: OwnRingOutcomeInput): OwnRingOutcome => {
|
|
41
|
+
if (!currentUserId) return { settledByMe: false };
|
|
42
|
+
|
|
43
|
+
const acceptedByMe = Boolean(session?.accepted_by[currentUserId]);
|
|
44
|
+
const rejectedByMe = Boolean(session?.rejected_by[currentUserId]);
|
|
45
|
+
if (!acceptedByMe && !rejectedByMe) return { settledByMe: false };
|
|
46
|
+
|
|
47
|
+
const answeredElsewhere =
|
|
48
|
+
acceptedByMe && callingState === CallingState.RINGING;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
settledByMe: true,
|
|
52
|
+
leaveReason: answeredElsewhere
|
|
53
|
+
? 'answeredElsewhere'
|
|
54
|
+
: rejectedByMe
|
|
55
|
+
? 'rejected'
|
|
56
|
+
: undefined,
|
|
57
|
+
};
|
|
58
|
+
};
|
package/src/rtc/Publisher.ts
CHANGED
|
@@ -150,7 +150,9 @@ export class Publisher extends BasePeerConnection {
|
|
|
150
150
|
const sendEncodings = isSvcCodec(publishOption.codec?.name)
|
|
151
151
|
? toSvcEncodings(encodings)
|
|
152
152
|
: encodings;
|
|
153
|
-
|
|
153
|
+
// An E2EE sender must never have media before its transform is attached,
|
|
154
|
+
// including when another track or an ICE restart negotiates this PC.
|
|
155
|
+
const transceiver = this.pc.addTransceiver(this.e2ee ? track.kind : track, {
|
|
154
156
|
direction: 'sendonly',
|
|
155
157
|
sendEncodings,
|
|
156
158
|
});
|
|
@@ -161,23 +163,75 @@ export class Publisher extends BasePeerConnection {
|
|
|
161
163
|
this.trackIdToTrackType.set(track.id, trackType);
|
|
162
164
|
this.logger.debug(`Added ${TrackType[trackType]} transceiver`);
|
|
163
165
|
|
|
164
|
-
const params = transceiver.sender.getParameters();
|
|
165
|
-
params.degradationPreference =
|
|
166
|
-
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
167
|
-
'maintain-framerate';
|
|
168
|
-
await transceiver.sender.setParameters(params);
|
|
169
166
|
if (this.e2ee) {
|
|
170
|
-
this.
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
TrackType[publishOption.trackType],
|
|
174
|
-
);
|
|
175
|
-
this.logger.debug('E2EE encryptor attached to sender');
|
|
167
|
+
await this.initEncryptedSender(transceiver, track, publishOption);
|
|
168
|
+
} else {
|
|
169
|
+
await this.setDegradationPreference(transceiver.sender, publishOption);
|
|
176
170
|
}
|
|
177
171
|
|
|
178
172
|
await this.negotiate();
|
|
179
173
|
};
|
|
180
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Attaches the encryptor, applies sender parameters, then attaches the track.
|
|
177
|
+
*
|
|
178
|
+
* `encrypt` must run before the first await: Chrome short-circuits a sender
|
|
179
|
+
* whose encoded streams aren't created in the task that created it, and the
|
|
180
|
+
* track goes on last so the sender has no media until both steps succeed.
|
|
181
|
+
*/
|
|
182
|
+
private initEncryptedSender = async (
|
|
183
|
+
transceiver: RTCRtpTransceiver,
|
|
184
|
+
track: MediaStreamTrack,
|
|
185
|
+
publishOption: PublishOption,
|
|
186
|
+
) => {
|
|
187
|
+
try {
|
|
188
|
+
const { sender } = transceiver;
|
|
189
|
+
const codec = publishOption.codec?.name.toLowerCase();
|
|
190
|
+
this.e2ee!.encrypt(sender, codec, TrackType[publishOption.trackType]);
|
|
191
|
+
this.logger.debug('E2EE encryptor attached to sender');
|
|
192
|
+
await this.setDegradationPreference(sender, publishOption);
|
|
193
|
+
await sender.replaceTrack(track);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
this.retireTransceiver(transceiver, track);
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
private setDegradationPreference = async (
|
|
201
|
+
sender: RTCRtpSender,
|
|
202
|
+
publishOption: PublishOption,
|
|
203
|
+
) => {
|
|
204
|
+
const params = sender.getParameters();
|
|
205
|
+
params.degradationPreference =
|
|
206
|
+
toRTCDegradationPreference(publishOption.degradationPreference) ??
|
|
207
|
+
'maintain-framerate';
|
|
208
|
+
await sender.setParameters(params);
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Discards a sender whose E2EE initialization failed.
|
|
213
|
+
*
|
|
214
|
+
* A partially initialized sender may carry no transform, so publish() must
|
|
215
|
+
* not reuse it: retiring it from the cache is what keeps a later publish or
|
|
216
|
+
* ICE restart from putting cleartext on the wire. Cleanup cannot throw, so
|
|
217
|
+
* the initialization failure is what reaches the caller.
|
|
218
|
+
*/
|
|
219
|
+
private retireTransceiver = (
|
|
220
|
+
transceiver: RTCRtpTransceiver,
|
|
221
|
+
track: MediaStreamTrack,
|
|
222
|
+
) => {
|
|
223
|
+
this.transceiverCache.remove(transceiver);
|
|
224
|
+
this.trackIdToTrackType.delete(track.id);
|
|
225
|
+
// on React Native a clone shares its native source, so stopping it here
|
|
226
|
+
// would stop the track the caller passed in. dispose() releases it.
|
|
227
|
+
if (!isReactNative()) this.stopTrack(track);
|
|
228
|
+
try {
|
|
229
|
+
transceiver.stop();
|
|
230
|
+
} catch (err) {
|
|
231
|
+
this.logger.debug('Failed to stop a retired transceiver', err);
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
|
|
181
235
|
/**
|
|
182
236
|
* Updates the transceiver with the given track and track type.
|
|
183
237
|
*/
|
|
@@ -21,6 +21,23 @@ export class TransceiverCache {
|
|
|
21
21
|
this.transceiverOrder.push(bundle.transceiver);
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Retires a failed sender from the reusable cache.
|
|
26
|
+
*
|
|
27
|
+
* A transceiver that never reached an m-section also leaves the creation
|
|
28
|
+
* history: once stopped, it is left out of every later offer, so counting
|
|
29
|
+
* it would shift the `mid` index of every transceiver added after it. One
|
|
30
|
+
* that is already associated keeps its slot, since its m-section survives
|
|
31
|
+
* as a rejected one.
|
|
32
|
+
*/
|
|
33
|
+
remove = (transceiver: RTCRtpTransceiver) => {
|
|
34
|
+
const index = this.cache.findIndex((b) => b.transceiver === transceiver);
|
|
35
|
+
if (index !== -1) this.cache.splice(index, 1);
|
|
36
|
+
if (transceiver.mid) return;
|
|
37
|
+
const orderIndex = this.transceiverOrder.indexOf(transceiver);
|
|
38
|
+
if (orderIndex !== -1) this.transceiverOrder.splice(orderIndex, 1);
|
|
39
|
+
};
|
|
40
|
+
|
|
24
41
|
/**
|
|
25
42
|
* Gets the transceiver for the given publish option.
|
|
26
43
|
*/
|
|
@@ -26,6 +26,7 @@ import { TransceiverCache } from '../TransceiverCache';
|
|
|
26
26
|
import { promiseWithResolvers } from '../../helpers/promise';
|
|
27
27
|
import { settled } from '../../helpers/concurrency';
|
|
28
28
|
import { isFirefox } from '../../helpers/browsers';
|
|
29
|
+
import { isReactNative } from '../../helpers/platforms';
|
|
29
30
|
|
|
30
31
|
vi.mock('../../StreamSfuClient', () => {
|
|
31
32
|
console.log('MOCKING StreamSfuClient');
|
|
@@ -40,6 +41,12 @@ vi.mock('../../helpers/browsers', async (importOriginal) => {
|
|
|
40
41
|
return { ...actual, isFirefox: vi.fn().mockReturnValue(false) };
|
|
41
42
|
});
|
|
42
43
|
|
|
44
|
+
vi.mock('../../helpers/platforms', async (importOriginal) => {
|
|
45
|
+
const actual =
|
|
46
|
+
await importOriginal<typeof import('../../helpers/platforms')>();
|
|
47
|
+
return { ...actual, isReactNative: vi.fn().mockReturnValue(false) };
|
|
48
|
+
});
|
|
49
|
+
|
|
43
50
|
describe('Publisher', () => {
|
|
44
51
|
const sessionId = 'session-id-test';
|
|
45
52
|
let publisher: Publisher;
|
|
@@ -106,6 +113,208 @@ describe('Publisher', () => {
|
|
|
106
113
|
});
|
|
107
114
|
|
|
108
115
|
describe('Publishing', () => {
|
|
116
|
+
describe('E2EE initialization failures', () => {
|
|
117
|
+
afterEach(() => {
|
|
118
|
+
vi.mocked(isReactNative).mockReturnValue(false);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const setup = async () => {
|
|
122
|
+
await publisher.dispose();
|
|
123
|
+
const e2ee = { encrypt: vi.fn(), decrypt: vi.fn() };
|
|
124
|
+
publisher = new Publisher(
|
|
125
|
+
{
|
|
126
|
+
sfuClient,
|
|
127
|
+
dispatcher,
|
|
128
|
+
state,
|
|
129
|
+
tag: 'test',
|
|
130
|
+
enableTracing: false,
|
|
131
|
+
e2ee,
|
|
132
|
+
},
|
|
133
|
+
[
|
|
134
|
+
fromPartial<PublishOption>({
|
|
135
|
+
id: 1,
|
|
136
|
+
trackType: TrackType.VIDEO,
|
|
137
|
+
codec: { name: 'vp9' },
|
|
138
|
+
}),
|
|
139
|
+
],
|
|
140
|
+
);
|
|
141
|
+
const transceivers: RTCRtpTransceiver[] = [];
|
|
142
|
+
vi.mocked(publisher['pc'].addTransceiver).mockImplementation((kind) => {
|
|
143
|
+
expect(kind).toBe('video');
|
|
144
|
+
const transceiver = new RTCRtpTransceiver();
|
|
145
|
+
vi.mocked(transceiver.sender.replaceTrack).mockImplementation(
|
|
146
|
+
async (track) => {
|
|
147
|
+
expect(e2ee.encrypt).toHaveBeenCalledWith(
|
|
148
|
+
transceiver.sender,
|
|
149
|
+
'vp9',
|
|
150
|
+
'VIDEO',
|
|
151
|
+
);
|
|
152
|
+
Object.assign(transceiver.sender, { track });
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
transceivers.push(transceiver);
|
|
156
|
+
return transceiver;
|
|
157
|
+
});
|
|
158
|
+
const track = new MediaStreamTrack();
|
|
159
|
+
vi.mocked(track.clone).mockImplementation(() => new MediaStreamTrack());
|
|
160
|
+
const realNegotiate = publisher['negotiate'];
|
|
161
|
+
const negotiate = vi.fn<Publisher['negotiate']>().mockResolvedValue();
|
|
162
|
+
publisher['negotiate'] = negotiate;
|
|
163
|
+
return { e2ee, transceivers, track, negotiate, realNegotiate };
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
it.each([
|
|
167
|
+
'encrypt',
|
|
168
|
+
'getParameters',
|
|
169
|
+
'setParameters',
|
|
170
|
+
'replaceTrack',
|
|
171
|
+
] as const)(
|
|
172
|
+
'retires the sender after %s fails and attaches encryption on retry',
|
|
173
|
+
async (failurePoint) => {
|
|
174
|
+
const { e2ee, transceivers, track, negotiate } = await setup();
|
|
175
|
+
const error = new Error('initialization failed');
|
|
176
|
+
e2ee.encrypt.mockImplementationOnce((sender: RTCRtpSender) => {
|
|
177
|
+
expect(sender.track).toBeNull();
|
|
178
|
+
if (failurePoint === 'encrypt') throw error;
|
|
179
|
+
if (failurePoint === 'getParameters') {
|
|
180
|
+
vi.mocked(sender.getParameters).mockImplementationOnce(() => {
|
|
181
|
+
throw error;
|
|
182
|
+
});
|
|
183
|
+
} else {
|
|
184
|
+
vi.mocked(sender[failurePoint]).mockRejectedValueOnce(error);
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
|
|
188
|
+
error,
|
|
189
|
+
);
|
|
190
|
+
const failed = transceivers[0];
|
|
191
|
+
expect(failed.sender.track).toBeNull();
|
|
192
|
+
expect(failed.stop).toHaveBeenCalledOnce();
|
|
193
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(0);
|
|
194
|
+
expect(publisher['clonedTracks'].size).toBe(0);
|
|
195
|
+
expect(negotiate).not.toHaveBeenCalled();
|
|
196
|
+
|
|
197
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
198
|
+
expect(transceivers).toHaveLength(2);
|
|
199
|
+
expect(e2ee.encrypt).toHaveBeenLastCalledWith(
|
|
200
|
+
transceivers[1].sender,
|
|
201
|
+
'vp9',
|
|
202
|
+
'VIDEO',
|
|
203
|
+
);
|
|
204
|
+
expect(transceivers[1].sender.track).not.toBeNull();
|
|
205
|
+
expect(publisher['transceiverCache'].indexOf(failed)).toBe(-1);
|
|
206
|
+
expect(publisher['transceiverCache'].indexOf(transceivers[1])).toBe(
|
|
207
|
+
0,
|
|
208
|
+
);
|
|
209
|
+
expect(negotiate).toHaveBeenCalledOnce();
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
it('keeps the mid index of a retired sender that reached an m-section', async () => {
|
|
214
|
+
const { e2ee, transceivers, track } = await setup();
|
|
215
|
+
const error = new Error('initialization failed');
|
|
216
|
+
e2ee.encrypt.mockImplementationOnce((sender: RTCRtpSender) => {
|
|
217
|
+
Object.assign(transceivers[0], { mid: '0' });
|
|
218
|
+
vi.mocked(sender.replaceTrack).mockRejectedValueOnce(error);
|
|
219
|
+
});
|
|
220
|
+
await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
|
|
221
|
+
error,
|
|
222
|
+
);
|
|
223
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(0);
|
|
224
|
+
expect(publisher['transceiverCache'].indexOf(transceivers[0])).toBe(0);
|
|
225
|
+
|
|
226
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
227
|
+
expect(publisher['transceiverCache'].indexOf(transceivers[1])).toBe(1);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('reports the initialization failure even when cleanup throws', async () => {
|
|
231
|
+
const { e2ee, transceivers, track } = await setup();
|
|
232
|
+
const error = new Error('initialization failed');
|
|
233
|
+
e2ee.encrypt.mockImplementationOnce(() => {
|
|
234
|
+
vi.mocked(transceivers[0].stop).mockImplementationOnce(() => {
|
|
235
|
+
throw new Error('peer connection is closed');
|
|
236
|
+
});
|
|
237
|
+
throw error;
|
|
238
|
+
});
|
|
239
|
+
await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
|
|
240
|
+
error,
|
|
241
|
+
);
|
|
242
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(0);
|
|
243
|
+
expect(publisher['clonedTracks'].size).toBe(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('leaves the cloned track alone on React Native', async () => {
|
|
247
|
+
vi.mocked(isReactNative).mockReturnValue(true);
|
|
248
|
+
const { e2ee, transceivers, track } = await setup();
|
|
249
|
+
const error = new Error('initialization failed');
|
|
250
|
+
e2ee.encrypt.mockImplementationOnce(() => {
|
|
251
|
+
throw error;
|
|
252
|
+
});
|
|
253
|
+
await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
|
|
254
|
+
error,
|
|
255
|
+
);
|
|
256
|
+
const [clone] = publisher['clonedTracks'];
|
|
257
|
+
expect(clone.stop).not.toHaveBeenCalled();
|
|
258
|
+
expect(transceivers[0].stop).toHaveBeenCalledOnce();
|
|
259
|
+
expect(publisher['transceiverCache'].items()).toHaveLength(0);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('reuses an encrypted sender when only negotiation failed', async () => {
|
|
263
|
+
const { e2ee, transceivers, track, negotiate } = await setup();
|
|
264
|
+
const error = new Error('negotiation failed');
|
|
265
|
+
negotiate.mockRejectedValueOnce(error);
|
|
266
|
+
await expect(publisher.publish(track, TrackType.VIDEO)).rejects.toBe(
|
|
267
|
+
error,
|
|
268
|
+
);
|
|
269
|
+
expect(transceivers[0].sender.track).not.toBeNull();
|
|
270
|
+
expect(transceivers[0].stop).not.toHaveBeenCalled();
|
|
271
|
+
await publisher.publish(track, TrackType.VIDEO);
|
|
272
|
+
expect(transceivers).toHaveLength(1);
|
|
273
|
+
expect(e2ee.encrypt).toHaveBeenCalledOnce();
|
|
274
|
+
expect(negotiate).toHaveBeenCalledTimes(2);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('has no media to expose if ICE restart negotiates during parameter setup', async () => {
|
|
278
|
+
const { e2ee, transceivers, track, negotiate, realNegotiate } =
|
|
279
|
+
await setup();
|
|
280
|
+
negotiate.mockImplementation(realNegotiate);
|
|
281
|
+
publisher['publishOptions'].push(
|
|
282
|
+
fromPartial<PublishOption>({
|
|
283
|
+
id: 2,
|
|
284
|
+
trackType: TrackType.VIDEO,
|
|
285
|
+
codec: { name: 'vp9' },
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
sfuClient.setPublisher = vi
|
|
289
|
+
.fn()
|
|
290
|
+
.mockResolvedValue({ response: { sdp: 'answer-sdp' } });
|
|
291
|
+
const parameters = promiseWithResolvers<void>();
|
|
292
|
+
e2ee.encrypt
|
|
293
|
+
.mockImplementationOnce(() => {})
|
|
294
|
+
.mockImplementationOnce((sender: RTCRtpSender) => {
|
|
295
|
+
vi.mocked(sender.setParameters).mockReturnValueOnce(
|
|
296
|
+
parameters.promise,
|
|
297
|
+
);
|
|
298
|
+
});
|
|
299
|
+
const publish = publisher.publish(track, TrackType.VIDEO);
|
|
300
|
+
await vi.waitFor(() => expect(e2ee.encrypt).toHaveBeenCalledTimes(2));
|
|
301
|
+
const [published, pending] = transceivers;
|
|
302
|
+
expect(pending.sender.track).toBeNull();
|
|
303
|
+
expect(pending.sender.replaceTrack).not.toHaveBeenCalled();
|
|
304
|
+
|
|
305
|
+
await publisher.restartIce();
|
|
306
|
+
const restart = vi.mocked(sfuClient.setPublisher).mock.calls[1][0];
|
|
307
|
+
expect(restart.tracks.map((t) => t.trackId)).toEqual([
|
|
308
|
+
published.sender.track!.id,
|
|
309
|
+
]);
|
|
310
|
+
|
|
311
|
+
parameters.resolve();
|
|
312
|
+
await publish;
|
|
313
|
+
expect(pending.sender.track).not.toBeNull();
|
|
314
|
+
expect(sfuClient.setPublisher).toHaveBeenCalledTimes(3);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
109
318
|
it('should throw when publishing ended tracks', async () => {
|
|
110
319
|
const track = new MediaStreamTrack();
|
|
111
320
|
// @ts-expect-error readonly field
|
|
@@ -5,7 +5,16 @@
|
|
|
5
5
|
* attaching their own encoded transform in these two methods.
|
|
6
6
|
*/
|
|
7
7
|
export interface E2EEManager {
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Synchronously attach a transform that encrypts the sender's outgoing frames.
|
|
10
|
+
* Must support a sender without a track, and throw if attachment fails.
|
|
11
|
+
*
|
|
12
|
+
* `sender.track` is always null here: the track is attached only after this
|
|
13
|
+
* returns, so implementations must not read it. Use `codec` and `trackType`
|
|
14
|
+
* instead.
|
|
15
|
+
*
|
|
16
|
+
* `trackType` only groups perf stats, keeping a camera and screen share apart.
|
|
17
|
+
*/
|
|
9
18
|
encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void;
|
|
10
19
|
/** `trackType` only groups perf stats, keeping a peer's audio and video apart. */
|
|
11
20
|
decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void;
|
|
@@ -263,9 +263,9 @@ export class EncryptionManager
|
|
|
263
263
|
}
|
|
264
264
|
|
|
265
265
|
if ((this.piped ??= new WeakSet()).has(target)) return;
|
|
266
|
-
this.piped.add(target);
|
|
267
266
|
// @ts-expect-error createEncodedStreams is not in the standard typedefs
|
|
268
267
|
const { readable, writable } = target.createEncodedStreams();
|
|
268
|
+
this.piped.add(target);
|
|
269
269
|
this.worker.postMessage(
|
|
270
270
|
{ type: 'cmd.setup_transform', ...options, readable, writable },
|
|
271
271
|
[readable, writable],
|
package/src/rtc/e2ee/SPEC.md
CHANGED
|
@@ -79,7 +79,7 @@ await call.join();
|
|
|
79
79
|
- **Removal comes in per-epoch and per-user forms, and the names must stay distinct.** `removeKey(userId, keyIndex)` retires one epoch; `removeAllKeys(userId)` revokes the participant entirely. Do not render these as overloads of one name: dropping the index argument would then silently mean "revoke everything", which is the opposite of the caller's intent and impossible to catch in review.
|
|
80
80
|
- **Every key operation's outcome is tabulated in Appendix B.** Implement the edge cases (removing the latest or active epoch, a failed import, an absent index) from that table rather than from prose.
|
|
81
81
|
- **Join request carries `e2ee: true`** so the backend knows the call is encrypted.
|
|
82
|
-
- The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. Keeping
|
|
82
|
+
- The internal attach points (`encrypt(sender, codec, trackType)` / `decrypt(receiver, userId, trackType)`) are called by the RTC layer, not by apps. The publisher creates an E2EE sender without a track and attaches media only after `encrypt` returns and sender parameters succeed. `encrypt` must synchronously attach the transform or throw, and must support a sender whose `track` is null. A failed initialization retires that transceiver so retries cannot bypass attachment. Keeping these points behind a small interface lets an integrator plug in a different scheme (e.g. SFrame).
|
|
83
83
|
|
|
84
84
|
---
|
|
85
85
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getStreamClientId } from '../utils';
|
|
3
|
+
import { SdkType } from '../../gen/video/sfu/models/models';
|
|
4
|
+
|
|
5
|
+
describe('getStreamClientId', () => {
|
|
6
|
+
const sdk = (type: SdkType) => ({
|
|
7
|
+
type,
|
|
8
|
+
major: '1',
|
|
9
|
+
minor: '43',
|
|
10
|
+
patch: '1',
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('names the React SDK', () => {
|
|
14
|
+
expect(getStreamClientId(sdk(SdkType.REACT))).toBe(
|
|
15
|
+
'stream-video-react-v1.43.1',
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('names the React Native SDK', () => {
|
|
20
|
+
expect(getStreamClientId(sdk(SdkType.REACT_NATIVE))).toBe(
|
|
21
|
+
'stream-video-react-native-v1.43.1',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('falls back to the plain JS client for every other SDK type', () => {
|
|
26
|
+
expect(getStreamClientId(sdk(SdkType.PLAIN_JAVASCRIPT))).toBe(
|
|
27
|
+
'stream-video-js-v1.43.1',
|
|
28
|
+
);
|
|
29
|
+
expect(getStreamClientId(undefined)).toBe(
|
|
30
|
+
'stream-video-js-v0.0.0-development',
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
});
|
package/src/stats/utils.ts
CHANGED
|
@@ -51,3 +51,18 @@ export const getSdkName = (sdk: Sdk | undefined) => {
|
|
|
51
51
|
export const getSdkVersion = (sdk: Sdk | undefined) => {
|
|
52
52
|
return sdk ? `${sdk.major}.${sdk.minor}.${sdk.patch}` : '0.0.0-development';
|
|
53
53
|
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Returns the identifier the SFU receives in the `X-Stream-Client` header,
|
|
57
|
+
* e.g. `stream-video-react-v1.43.1`. Matches the format the coordinator
|
|
58
|
+
* already gets through the client's user agent.
|
|
59
|
+
*/
|
|
60
|
+
export const getStreamClientId = (sdk: Sdk | undefined) => {
|
|
61
|
+
const sdkPlatforms: Partial<Record<SdkType, string>> = {
|
|
62
|
+
[SdkType.REACT]: 'react',
|
|
63
|
+
[SdkType.REACT_NATIVE]: 'react-native',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const platform = (sdk && sdkPlatforms[sdk.type]) || 'js';
|
|
67
|
+
return `stream-video-${platform}-v${getSdkVersion(sdk)}`;
|
|
68
|
+
};
|
package/src/store/CallState.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
CallSessionParticipantLeftEvent,
|
|
38
38
|
CallSessionResponse,
|
|
39
39
|
CallSettingsResponse,
|
|
40
|
+
GetCallRingStateResponse,
|
|
40
41
|
ClosedCaptionEvent,
|
|
41
42
|
EgressResponse,
|
|
42
43
|
MemberResponse,
|
|
@@ -1352,6 +1353,30 @@ export class CallState {
|
|
|
1352
1353
|
this.setCurrentValue(this.thumbnailsSubject, call.thumbnails);
|
|
1353
1354
|
};
|
|
1354
1355
|
|
|
1356
|
+
/**
|
|
1357
|
+
* Merges a polled ring state into the current session. Only the ring fields
|
|
1358
|
+
* are touched: the endpoint does not return the session roster.
|
|
1359
|
+
*
|
|
1360
|
+
* @internal
|
|
1361
|
+
*
|
|
1362
|
+
* @param ringState the ring state as returned by the coordinator.
|
|
1363
|
+
*/
|
|
1364
|
+
updateFromRingState = (ringState: GetCallRingStateResponse) => {
|
|
1365
|
+
this.setCurrentValue(this.sessionSubject, (session) => {
|
|
1366
|
+
if (!session || session.id !== ringState.session_id) return session;
|
|
1367
|
+
return {
|
|
1368
|
+
...session,
|
|
1369
|
+
accepted_by: ringState.accepted_by,
|
|
1370
|
+
rejected_by: ringState.rejected_by,
|
|
1371
|
+
missed_by: ringState.missed_by,
|
|
1372
|
+
ended_at: ringState.session_ended_at ?? session.ended_at,
|
|
1373
|
+
};
|
|
1374
|
+
});
|
|
1375
|
+
if (ringState.call_ended_at) {
|
|
1376
|
+
this.setEndedAt(new Date(ringState.call_ended_at));
|
|
1377
|
+
}
|
|
1378
|
+
};
|
|
1379
|
+
|
|
1355
1380
|
/**
|
|
1356
1381
|
* Updates the call state with the data received from the SFU server.
|
|
1357
1382
|
*
|