@stream-io/video-client 1.5.0 → 1.6.0-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 +175 -0
- package/dist/index.browser.es.js +1986 -1482
- package/dist/index.browser.es.js.map +1 -1
- package/dist/index.cjs.js +1983 -1478
- package/dist/index.cjs.js.map +1 -1
- package/dist/index.es.js +1986 -1482
- package/dist/index.es.js.map +1 -1
- package/dist/src/Call.d.ts +93 -9
- package/dist/src/StreamSfuClient.d.ts +73 -56
- package/dist/src/StreamVideoClient.d.ts +2 -2
- package/dist/src/coordinator/connection/client.d.ts +3 -4
- package/dist/src/coordinator/connection/types.d.ts +5 -1
- package/dist/src/devices/InputMediaDeviceManager.d.ts +4 -0
- package/dist/src/devices/MicrophoneManager.d.ts +1 -1
- package/dist/src/events/callEventHandlers.d.ts +1 -3
- package/dist/src/events/internal.d.ts +4 -0
- package/dist/src/gen/video/sfu/event/events.d.ts +106 -4
- package/dist/src/gen/video/sfu/models/models.d.ts +64 -65
- package/dist/src/helpers/ensureExhausted.d.ts +1 -0
- package/dist/src/helpers/withResolvers.d.ts +14 -0
- package/dist/src/logger.d.ts +1 -0
- package/dist/src/rpc/createClient.d.ts +2 -0
- package/dist/src/rpc/index.d.ts +1 -0
- package/dist/src/rpc/retryable.d.ts +23 -0
- package/dist/src/rtc/Dispatcher.d.ts +1 -1
- package/dist/src/rtc/IceTrickleBuffer.d.ts +0 -1
- package/dist/src/rtc/Publisher.d.ts +24 -25
- package/dist/src/rtc/Subscriber.d.ts +12 -11
- package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -0
- package/dist/src/rtc/helpers/tracks.d.ts +3 -3
- package/dist/src/rtc/signal.d.ts +1 -1
- package/dist/src/store/CallState.d.ts +46 -2
- package/package.json +3 -3
- package/src/Call.ts +628 -566
- package/src/StreamSfuClient.ts +276 -246
- package/src/StreamVideoClient.ts +15 -16
- package/src/coordinator/connection/client.ts +25 -8
- package/src/coordinator/connection/connection.ts +1 -0
- package/src/coordinator/connection/types.ts +6 -0
- package/src/devices/CameraManager.ts +1 -1
- package/src/devices/InputMediaDeviceManager.ts +12 -3
- package/src/devices/MicrophoneManager.ts +3 -3
- package/src/devices/devices.ts +1 -1
- package/src/events/__tests__/mutes.test.ts +10 -13
- package/src/events/__tests__/participant.test.ts +75 -0
- package/src/events/callEventHandlers.ts +4 -7
- package/src/events/internal.ts +20 -3
- package/src/events/mutes.ts +5 -3
- package/src/events/participant.ts +48 -15
- package/src/gen/video/sfu/event/events.ts +451 -8
- package/src/gen/video/sfu/models/models.ts +211 -204
- package/src/helpers/ensureExhausted.ts +5 -0
- package/src/helpers/withResolvers.ts +43 -0
- package/src/logger.ts +3 -1
- package/src/rpc/__tests__/retryable.test.ts +72 -0
- package/src/rpc/createClient.ts +21 -0
- package/src/rpc/index.ts +1 -0
- package/src/rpc/retryable.ts +57 -0
- package/src/rtc/Dispatcher.ts +6 -2
- package/src/rtc/IceTrickleBuffer.ts +2 -2
- package/src/rtc/Publisher.ts +127 -163
- package/src/rtc/Subscriber.ts +92 -155
- package/src/rtc/__tests__/Publisher.test.ts +18 -95
- package/src/rtc/__tests__/Subscriber.test.ts +63 -99
- package/src/rtc/__tests__/videoLayers.test.ts +2 -2
- package/src/rtc/helpers/rtcConfiguration.ts +11 -0
- package/src/rtc/helpers/tracks.ts +27 -7
- package/src/rtc/signal.ts +3 -3
- package/src/rtc/videoLayers.ts +1 -10
- package/src/stats/SfuStatsReporter.ts +1 -0
- package/src/store/CallState.ts +109 -2
- package/src/store/__tests__/CallState.test.ts +48 -37
- package/dist/src/rtc/flows/join.d.ts +0 -20
- package/src/rtc/flows/join.ts +0 -65
package/src/StreamSfuClient.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import type { WebSocket } from 'ws';
|
|
2
|
-
import type {
|
|
3
|
-
FinishedUnaryCall,
|
|
4
|
-
MethodInfo,
|
|
5
|
-
NextUnaryFn,
|
|
6
|
-
RpcInterceptor,
|
|
7
|
-
RpcOptions,
|
|
8
|
-
UnaryCall,
|
|
9
|
-
} from '@protobuf-ts/runtime-rpc';
|
|
10
1
|
import { SignalServerClient } from './gen/video/sfu/signal_rpc/signal.client';
|
|
11
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createSignalClient,
|
|
4
|
+
retryable,
|
|
5
|
+
withHeaders,
|
|
6
|
+
withRequestLogger,
|
|
7
|
+
} from './rpc';
|
|
12
8
|
import {
|
|
13
9
|
createWebSocketSignalChannel,
|
|
14
10
|
Dispatcher,
|
|
15
11
|
IceTrickleBuffer,
|
|
16
12
|
} from './rtc';
|
|
17
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
JoinRequest,
|
|
15
|
+
JoinResponse,
|
|
16
|
+
SfuRequest,
|
|
17
|
+
} from './gen/video/sfu/event/events';
|
|
18
18
|
import {
|
|
19
19
|
ICERestartRequest,
|
|
20
20
|
SendAnswerRequest,
|
|
@@ -23,19 +23,16 @@ import {
|
|
|
23
23
|
TrackSubscriptionDetails,
|
|
24
24
|
UpdateMuteStatesRequest,
|
|
25
25
|
} from './gen/video/sfu/signal_rpc/signal';
|
|
26
|
+
import { ICETrickle, TrackType } from './gen/video/sfu/models/models';
|
|
27
|
+
import { generateUUIDv4, sleep } from './coordinator/connection/utils';
|
|
28
|
+
import { Credentials } from './gen/coordinator';
|
|
29
|
+
import { Logger } from './coordinator/connection/types';
|
|
30
|
+
import { getLogger, getLogLevel } from './logger';
|
|
31
|
+
import { withoutConcurrency } from './helpers/concurrency';
|
|
26
32
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
} from './gen/video/sfu/models/models';
|
|
31
|
-
import {
|
|
32
|
-
generateUUIDv4,
|
|
33
|
-
retryInterval,
|
|
34
|
-
sleep,
|
|
35
|
-
} from './coordinator/connection/utils';
|
|
36
|
-
import { SFUResponse } from './gen/coordinator';
|
|
37
|
-
import { LogLevel, Logger } from './coordinator/connection/types';
|
|
38
|
-
import { getLogger } from './logger';
|
|
33
|
+
promiseWithResolvers,
|
|
34
|
+
PromiseWithResolvers,
|
|
35
|
+
} from './helpers/withResolvers';
|
|
39
36
|
|
|
40
37
|
export type StreamSfuClientConstructor = {
|
|
41
38
|
/**
|
|
@@ -44,20 +41,31 @@ export type StreamSfuClientConstructor = {
|
|
|
44
41
|
dispatcher: Dispatcher;
|
|
45
42
|
|
|
46
43
|
/**
|
|
47
|
-
* The
|
|
44
|
+
* The credentials to use for the connection.
|
|
45
|
+
*/
|
|
46
|
+
credentials: Credentials;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `sessionId` to use for the connection.
|
|
48
50
|
*/
|
|
49
|
-
|
|
51
|
+
sessionId?: string;
|
|
50
52
|
|
|
51
53
|
/**
|
|
52
|
-
*
|
|
54
|
+
* A log tag to use for logging. Useful for debugging multiple instances.
|
|
53
55
|
*/
|
|
54
|
-
|
|
56
|
+
logTag: string;
|
|
55
57
|
|
|
56
58
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
+
* The timeout in milliseconds for waiting for the `joinResponse`.
|
|
60
|
+
* Defaults to 5000ms.
|
|
59
61
|
*/
|
|
60
|
-
|
|
62
|
+
joinResponseTimeout?: number;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Callback for when the WebSocket connection is closed.
|
|
66
|
+
* @param event the event.
|
|
67
|
+
*/
|
|
68
|
+
onSignalClose?: (event: CloseEvent) => void;
|
|
61
69
|
};
|
|
62
70
|
|
|
63
71
|
/**
|
|
@@ -66,9 +74,10 @@ export type StreamSfuClientConstructor = {
|
|
|
66
74
|
export class StreamSfuClient {
|
|
67
75
|
/**
|
|
68
76
|
* A buffer for ICE Candidates that are received before
|
|
69
|
-
* the
|
|
77
|
+
* the Publisher and Subscriber Peer Connections are ready to handle them.
|
|
70
78
|
*/
|
|
71
79
|
readonly iceTrickleBuffer = new IceTrickleBuffer();
|
|
80
|
+
|
|
72
81
|
/**
|
|
73
82
|
* The `sessionId` of the currently connected participant.
|
|
74
83
|
*/
|
|
@@ -79,46 +88,53 @@ export class StreamSfuClient {
|
|
|
79
88
|
*/
|
|
80
89
|
readonly edgeName: string;
|
|
81
90
|
|
|
82
|
-
/**
|
|
83
|
-
* The current token used for authenticating against the SFU.
|
|
84
|
-
*/
|
|
85
|
-
readonly token: string;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* The SFU server details the current client is connected to.
|
|
89
|
-
*/
|
|
90
|
-
readonly sfuServer: SFUResponse;
|
|
91
|
-
|
|
92
91
|
/**
|
|
93
92
|
* Holds the current WebSocket connection to the SFU.
|
|
94
93
|
*/
|
|
95
|
-
signalWs
|
|
94
|
+
private signalWs!: WebSocket;
|
|
96
95
|
|
|
97
96
|
/**
|
|
98
97
|
* Promise that resolves when the WebSocket connection is ready (open).
|
|
99
98
|
*/
|
|
100
|
-
signalReady
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* A flag indicating whether the client is currently migrating away
|
|
104
|
-
* from this SFU.
|
|
105
|
-
*/
|
|
106
|
-
isMigratingAway = false;
|
|
99
|
+
private signalReady!: Promise<WebSocket>;
|
|
107
100
|
|
|
108
101
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
102
|
+
* Flag to indicate if the client is in the process of leaving the call.
|
|
103
|
+
* This is set to `true` when the user initiates the leave process.
|
|
111
104
|
*/
|
|
112
|
-
|
|
105
|
+
isLeaving = false;
|
|
113
106
|
|
|
114
107
|
private readonly rpc: SignalServerClient;
|
|
115
108
|
private keepAliveInterval?: NodeJS.Timeout;
|
|
116
109
|
private connectionCheckTimeout?: NodeJS.Timeout;
|
|
110
|
+
private migrateAwayTimeout?: NodeJS.Timeout;
|
|
117
111
|
private pingIntervalInMs = 10 * 1000;
|
|
118
112
|
private unhealthyTimeoutInMs = this.pingIntervalInMs + 5 * 1000;
|
|
119
113
|
private lastMessageTimestamp?: Date;
|
|
114
|
+
private readonly restoreWebSocketConcurrencyTag = Symbol('recoverWebSocket');
|
|
120
115
|
private readonly unsubscribeIceTrickle: () => void;
|
|
116
|
+
private readonly onSignalClose: ((event: CloseEvent) => void) | undefined;
|
|
121
117
|
private readonly logger: Logger;
|
|
118
|
+
private readonly logTag: string;
|
|
119
|
+
private readonly credentials: Credentials;
|
|
120
|
+
private readonly dispatcher: Dispatcher;
|
|
121
|
+
private readonly joinResponseTimeout?: number;
|
|
122
|
+
/**
|
|
123
|
+
* Promise that resolves when the JoinResponse is received.
|
|
124
|
+
* Rejects after a certain threshold if the response is not received.
|
|
125
|
+
*/
|
|
126
|
+
joinResponseTask = promiseWithResolvers<JoinResponse>();
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Promise that resolves when the migration is complete.
|
|
130
|
+
* Rejects after a certain threshold if the migration is not complete.
|
|
131
|
+
*/
|
|
132
|
+
private migrationTask?: PromiseWithResolvers<void>;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* A controller to abort the current requests.
|
|
136
|
+
*/
|
|
137
|
+
private readonly abortController = new AbortController();
|
|
122
138
|
|
|
123
139
|
/**
|
|
124
140
|
* The normal closure code. Used for controlled shutdowns.
|
|
@@ -131,246 +147,322 @@ export class StreamSfuClient {
|
|
|
131
147
|
*/
|
|
132
148
|
static ERROR_CONNECTION_UNHEALTHY = 4001;
|
|
133
149
|
|
|
134
|
-
/**
|
|
135
|
-
* The error code used when the SFU connection is broken.
|
|
136
|
-
* Usually, this means that the WS connection has been closed unexpectedly.
|
|
137
|
-
* This error code is used to announce a fast-reconnect.
|
|
138
|
-
*/
|
|
139
|
-
static ERROR_CONNECTION_BROKEN = 4002; // used in fast-reconnects
|
|
140
|
-
|
|
141
150
|
/**
|
|
142
151
|
* Constructs a new SFU client.
|
|
143
|
-
*
|
|
144
|
-
* @param dispatcher the event dispatcher to use.
|
|
145
|
-
* @param sfuServer the SFU server to connect to.
|
|
146
|
-
* @param token the JWT token to use for authentication.
|
|
147
|
-
* @param sessionId the `sessionId` of the currently connected participant.
|
|
148
152
|
*/
|
|
149
153
|
constructor({
|
|
150
154
|
dispatcher,
|
|
151
|
-
|
|
152
|
-
token,
|
|
155
|
+
credentials,
|
|
153
156
|
sessionId,
|
|
157
|
+
logTag,
|
|
158
|
+
joinResponseTimeout = 5000,
|
|
159
|
+
onSignalClose,
|
|
154
160
|
}: StreamSfuClientConstructor) {
|
|
161
|
+
this.dispatcher = dispatcher;
|
|
155
162
|
this.sessionId = sessionId || generateUUIDv4();
|
|
156
|
-
this.
|
|
157
|
-
this.
|
|
158
|
-
|
|
159
|
-
this.
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
method: MethodInfo,
|
|
164
|
-
input: object,
|
|
165
|
-
options: RpcOptions,
|
|
166
|
-
): UnaryCall => {
|
|
167
|
-
this.logger('trace', `Calling SFU RPC method ${method.name}`, {
|
|
168
|
-
input,
|
|
169
|
-
options,
|
|
170
|
-
});
|
|
171
|
-
return next(method, input, options);
|
|
172
|
-
},
|
|
173
|
-
};
|
|
163
|
+
this.onSignalClose = onSignalClose;
|
|
164
|
+
this.credentials = credentials;
|
|
165
|
+
const { server, token } = credentials;
|
|
166
|
+
this.edgeName = server.edge_name;
|
|
167
|
+
this.joinResponseTimeout = joinResponseTimeout;
|
|
168
|
+
this.logTag = logTag;
|
|
169
|
+
this.logger = getLogger(['sfu-client', logTag]);
|
|
174
170
|
this.rpc = createSignalClient({
|
|
175
|
-
baseUrl:
|
|
171
|
+
baseUrl: server.url,
|
|
176
172
|
interceptors: [
|
|
177
173
|
withHeaders({
|
|
178
174
|
Authorization: `Bearer ${token}`,
|
|
179
175
|
}),
|
|
180
|
-
|
|
181
|
-
],
|
|
176
|
+
getLogLevel() === 'trace' && withRequestLogger(this.logger, 'trace'),
|
|
177
|
+
].filter((v) => !!v),
|
|
182
178
|
});
|
|
183
179
|
|
|
184
180
|
// Special handling for the ICETrickle kind of events.
|
|
185
|
-
//
|
|
186
|
-
// connection is established
|
|
187
|
-
//
|
|
181
|
+
// The SFU might trigger these events before the initial RTC
|
|
182
|
+
// connection is established or "JoinResponse" received.
|
|
183
|
+
// In that case, those events (ICE candidates) need to be buffered
|
|
184
|
+
// and later added to the appropriate PeerConnection
|
|
188
185
|
// once the remoteDescription is known and set.
|
|
189
186
|
this.unsubscribeIceTrickle = dispatcher.on('iceTrickle', (iceTrickle) => {
|
|
190
187
|
this.iceTrickleBuffer.push(iceTrickle);
|
|
191
188
|
});
|
|
192
189
|
|
|
190
|
+
this.createWebSocket();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private createWebSocket = () => {
|
|
193
194
|
this.signalWs = createWebSocketSignalChannel({
|
|
194
|
-
|
|
195
|
+
logTag: this.logTag,
|
|
196
|
+
endpoint: `${this.credentials.server.ws_endpoint}?tag=${this.logTag}`,
|
|
195
197
|
onMessage: (message) => {
|
|
196
198
|
this.lastMessageTimestamp = new Date();
|
|
197
199
|
this.scheduleConnectionCheck();
|
|
198
|
-
dispatcher.dispatch(message);
|
|
200
|
+
this.dispatcher.dispatch(message, this.logTag);
|
|
199
201
|
},
|
|
200
202
|
});
|
|
201
203
|
|
|
204
|
+
this.signalWs.addEventListener('close', this.handleWebSocketClose);
|
|
205
|
+
this.signalWs.addEventListener('error', this.restoreWebSocket);
|
|
206
|
+
|
|
202
207
|
this.signalReady = new Promise((resolve) => {
|
|
203
208
|
const onOpen = () => {
|
|
204
209
|
this.signalWs.removeEventListener('open', onOpen);
|
|
205
|
-
this.keepAlive();
|
|
206
210
|
resolve(this.signalWs);
|
|
207
211
|
};
|
|
208
212
|
this.signalWs.addEventListener('open', onOpen);
|
|
209
213
|
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
private cleanUpWebSocket = () => {
|
|
217
|
+
this.signalWs.removeEventListener('error', this.restoreWebSocket);
|
|
218
|
+
this.signalWs.removeEventListener('close', this.handleWebSocketClose);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
private restoreWebSocket = () => {
|
|
222
|
+
withoutConcurrency(this.restoreWebSocketConcurrencyTag, async () => {
|
|
223
|
+
this.logger('debug', 'Restoring SFU WS connection');
|
|
224
|
+
this.cleanUpWebSocket();
|
|
225
|
+
await sleep(500);
|
|
226
|
+
this.createWebSocket();
|
|
227
|
+
}).catch((err) => this.logger('debug', `Can't restore WS connection`, err));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
get isHealthy() {
|
|
231
|
+
return this.signalWs.readyState === WebSocket.OPEN;
|
|
210
232
|
}
|
|
211
233
|
|
|
212
|
-
|
|
213
|
-
this.
|
|
214
|
-
|
|
234
|
+
private handleWebSocketClose = (e: CloseEvent) => {
|
|
235
|
+
this.signalWs.removeEventListener('close', this.handleWebSocketClose);
|
|
236
|
+
clearInterval(this.keepAliveInterval);
|
|
237
|
+
clearTimeout(this.connectionCheckTimeout);
|
|
238
|
+
if (this.onSignalClose) {
|
|
239
|
+
this.onSignalClose(e);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
close = (code: number = StreamSfuClient.NORMAL_CLOSURE, reason?: string) => {
|
|
244
|
+
if (this.signalWs.readyState === WebSocket.OPEN) {
|
|
245
|
+
this.logger('debug', `Closing SFU WS connection: ${code} - ${reason}`);
|
|
215
246
|
this.signalWs.close(code, `js-client: ${reason}`);
|
|
247
|
+
this.cleanUpWebSocket();
|
|
216
248
|
}
|
|
249
|
+
this.dispose();
|
|
250
|
+
};
|
|
217
251
|
|
|
252
|
+
dispose = () => {
|
|
253
|
+
this.logger('debug', 'Disposing SFU client');
|
|
218
254
|
this.unsubscribeIceTrickle();
|
|
219
255
|
clearInterval(this.keepAliveInterval);
|
|
220
256
|
clearTimeout(this.connectionCheckTimeout);
|
|
257
|
+
clearTimeout(this.migrateAwayTimeout);
|
|
258
|
+
this.abortController.abort();
|
|
259
|
+
this.migrationTask?.resolve();
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
leaveAndClose = async (reason: string) => {
|
|
263
|
+
await this.joinResponseTask.promise;
|
|
264
|
+
try {
|
|
265
|
+
this.isLeaving = true;
|
|
266
|
+
await this.notifyLeave(reason);
|
|
267
|
+
} catch (err) {
|
|
268
|
+
this.logger('debug', 'Error notifying SFU about leaving call', err);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
this.close(StreamSfuClient.NORMAL_CLOSURE, reason.substring(0, 115));
|
|
221
272
|
};
|
|
222
273
|
|
|
223
|
-
updateSubscriptions = async (
|
|
274
|
+
updateSubscriptions = async (tracks: TrackSubscriptionDetails[]) => {
|
|
275
|
+
await this.joinResponseTask.promise;
|
|
224
276
|
return retryable(
|
|
225
|
-
() =>
|
|
226
|
-
|
|
227
|
-
sessionId: this.sessionId,
|
|
228
|
-
tracks: subscriptions,
|
|
229
|
-
}),
|
|
230
|
-
this.logger,
|
|
231
|
-
'debug',
|
|
277
|
+
() => this.rpc.updateSubscriptions({ sessionId: this.sessionId, tracks }),
|
|
278
|
+
this.abortController.signal,
|
|
232
279
|
);
|
|
233
280
|
};
|
|
234
281
|
|
|
235
282
|
setPublisher = async (data: Omit<SetPublisherRequest, 'sessionId'>) => {
|
|
283
|
+
await this.joinResponseTask.promise;
|
|
236
284
|
return retryable(
|
|
237
|
-
() =>
|
|
238
|
-
|
|
239
|
-
...data,
|
|
240
|
-
sessionId: this.sessionId,
|
|
241
|
-
}),
|
|
242
|
-
this.logger,
|
|
285
|
+
() => this.rpc.setPublisher({ ...data, sessionId: this.sessionId }),
|
|
286
|
+
this.abortController.signal,
|
|
243
287
|
);
|
|
244
288
|
};
|
|
245
289
|
|
|
246
290
|
sendAnswer = async (data: Omit<SendAnswerRequest, 'sessionId'>) => {
|
|
291
|
+
await this.joinResponseTask.promise;
|
|
247
292
|
return retryable(
|
|
248
|
-
() =>
|
|
249
|
-
|
|
250
|
-
...data,
|
|
251
|
-
sessionId: this.sessionId,
|
|
252
|
-
}),
|
|
253
|
-
this.logger,
|
|
293
|
+
() => this.rpc.sendAnswer({ ...data, sessionId: this.sessionId }),
|
|
294
|
+
this.abortController.signal,
|
|
254
295
|
);
|
|
255
296
|
};
|
|
256
297
|
|
|
257
298
|
iceTrickle = async (data: Omit<ICETrickle, 'sessionId'>) => {
|
|
299
|
+
await this.joinResponseTask.promise;
|
|
258
300
|
return retryable(
|
|
259
|
-
() =>
|
|
260
|
-
|
|
261
|
-
...data,
|
|
262
|
-
sessionId: this.sessionId,
|
|
263
|
-
}),
|
|
264
|
-
this.logger,
|
|
301
|
+
() => this.rpc.iceTrickle({ ...data, sessionId: this.sessionId }),
|
|
302
|
+
this.abortController.signal,
|
|
265
303
|
);
|
|
266
304
|
};
|
|
267
305
|
|
|
268
306
|
iceRestart = async (data: Omit<ICERestartRequest, 'sessionId'>) => {
|
|
307
|
+
await this.joinResponseTask.promise;
|
|
269
308
|
return retryable(
|
|
270
|
-
() =>
|
|
271
|
-
|
|
272
|
-
...data,
|
|
273
|
-
sessionId: this.sessionId,
|
|
274
|
-
}),
|
|
275
|
-
this.logger,
|
|
309
|
+
() => this.rpc.iceRestart({ ...data, sessionId: this.sessionId }),
|
|
310
|
+
this.abortController.signal,
|
|
276
311
|
);
|
|
277
312
|
};
|
|
278
313
|
|
|
279
314
|
updateMuteState = async (trackType: TrackType, muted: boolean) => {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
{
|
|
283
|
-
trackType,
|
|
284
|
-
muted,
|
|
285
|
-
},
|
|
286
|
-
],
|
|
287
|
-
});
|
|
315
|
+
await this.joinResponseTask.promise;
|
|
316
|
+
return this.updateMuteStates({ muteStates: [{ trackType, muted }] });
|
|
288
317
|
};
|
|
289
318
|
|
|
290
319
|
updateMuteStates = async (
|
|
291
320
|
data: Omit<UpdateMuteStatesRequest, 'sessionId'>,
|
|
292
321
|
) => {
|
|
322
|
+
await this.joinResponseTask.promise;
|
|
293
323
|
return retryable(
|
|
294
|
-
() =>
|
|
295
|
-
|
|
296
|
-
...data,
|
|
297
|
-
sessionId: this.sessionId,
|
|
298
|
-
}),
|
|
299
|
-
this.logger,
|
|
324
|
+
() => this.rpc.updateMuteStates({ ...data, sessionId: this.sessionId }),
|
|
325
|
+
this.abortController.signal,
|
|
300
326
|
);
|
|
301
327
|
};
|
|
302
328
|
|
|
303
329
|
sendStats = async (stats: Omit<SendStatsRequest, 'sessionId'>) => {
|
|
330
|
+
await this.joinResponseTask.promise;
|
|
304
331
|
return retryable(
|
|
305
|
-
() =>
|
|
306
|
-
|
|
307
|
-
...stats,
|
|
308
|
-
sessionId: this.sessionId,
|
|
309
|
-
}),
|
|
310
|
-
this.logger,
|
|
311
|
-
'debug',
|
|
332
|
+
() => this.rpc.sendStats({ ...stats, sessionId: this.sessionId }),
|
|
333
|
+
this.abortController.signal,
|
|
312
334
|
);
|
|
313
335
|
};
|
|
314
336
|
|
|
315
337
|
startNoiseCancellation = async () => {
|
|
338
|
+
await this.joinResponseTask.promise;
|
|
316
339
|
return retryable(
|
|
317
|
-
() =>
|
|
318
|
-
|
|
319
|
-
sessionId: this.sessionId,
|
|
320
|
-
}),
|
|
321
|
-
this.logger,
|
|
340
|
+
() => this.rpc.startNoiseCancellation({ sessionId: this.sessionId }),
|
|
341
|
+
this.abortController.signal,
|
|
322
342
|
);
|
|
323
343
|
};
|
|
324
344
|
|
|
325
345
|
stopNoiseCancellation = async () => {
|
|
346
|
+
await this.joinResponseTask.promise;
|
|
326
347
|
return retryable(
|
|
327
|
-
() =>
|
|
328
|
-
|
|
329
|
-
sessionId: this.sessionId,
|
|
330
|
-
}),
|
|
331
|
-
this.logger,
|
|
348
|
+
() => this.rpc.stopNoiseCancellation({ sessionId: this.sessionId }),
|
|
349
|
+
this.abortController.signal,
|
|
332
350
|
);
|
|
333
351
|
};
|
|
334
352
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
353
|
+
enterMigration = async (opts: { timeout?: number } = {}) => {
|
|
354
|
+
this.isLeaving = true;
|
|
355
|
+
const { timeout = 7 * 1000 } = opts;
|
|
356
|
+
|
|
357
|
+
this.migrationTask?.reject(new Error('Cancelled previous migration'));
|
|
358
|
+
const task = (this.migrationTask = promiseWithResolvers());
|
|
359
|
+
const unsubscribe = this.dispatcher.on(
|
|
360
|
+
'participantMigrationComplete',
|
|
361
|
+
() => {
|
|
362
|
+
unsubscribe();
|
|
363
|
+
clearTimeout(this.migrateAwayTimeout);
|
|
364
|
+
task.resolve();
|
|
365
|
+
},
|
|
366
|
+
);
|
|
367
|
+
this.migrateAwayTimeout = setTimeout(() => {
|
|
368
|
+
unsubscribe();
|
|
369
|
+
task.reject(
|
|
370
|
+
new Error(
|
|
371
|
+
`Migration (${this.logTag}) failed to complete in ${timeout}ms`,
|
|
372
|
+
),
|
|
373
|
+
);
|
|
374
|
+
}, timeout);
|
|
375
|
+
|
|
376
|
+
return task.promise;
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
join = async (
|
|
380
|
+
data: Omit<JoinRequest, 'sessionId' | 'token'>,
|
|
381
|
+
): Promise<JoinResponse> => {
|
|
382
|
+
// wait for the signal web socket to be ready before sending "joinRequest"
|
|
383
|
+
await this.signalReady;
|
|
384
|
+
if (this.joinResponseTask.isResolved || this.joinResponseTask.isRejected) {
|
|
385
|
+
// we need to lock the RPC requests until we receive a JoinResponse.
|
|
386
|
+
// that's why we have this primitive lock mechanism.
|
|
387
|
+
// the client starts with already initialized joinResponseTask,
|
|
388
|
+
// and this code creates a new one for the next join request.
|
|
389
|
+
this.joinResponseTask = promiseWithResolvers<JoinResponse>();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// capture a reference to the current joinResponseTask as it might
|
|
393
|
+
// be replaced with a new one in case a second join request is made
|
|
394
|
+
const current = this.joinResponseTask;
|
|
395
|
+
|
|
396
|
+
let timeoutId: NodeJS.Timeout;
|
|
397
|
+
const unsubscribe = this.dispatcher.on('joinResponse', (joinResponse) => {
|
|
398
|
+
this.logger('debug', 'Received joinResponse', joinResponse);
|
|
399
|
+
clearTimeout(timeoutId);
|
|
400
|
+
unsubscribe();
|
|
401
|
+
this.keepAlive();
|
|
402
|
+
current.resolve(joinResponse);
|
|
340
403
|
});
|
|
341
|
-
|
|
404
|
+
|
|
405
|
+
timeoutId = setTimeout(() => {
|
|
406
|
+
unsubscribe();
|
|
407
|
+
current.reject(new Error('Waiting for "joinResponse" has timed out'));
|
|
408
|
+
}, this.joinResponseTimeout);
|
|
409
|
+
|
|
410
|
+
await this.send(
|
|
342
411
|
SfuRequest.create({
|
|
343
412
|
requestPayload: {
|
|
344
413
|
oneofKind: 'joinRequest',
|
|
345
|
-
joinRequest
|
|
414
|
+
joinRequest: JoinRequest.create({
|
|
415
|
+
...data,
|
|
416
|
+
sessionId: this.sessionId,
|
|
417
|
+
token: this.credentials.token,
|
|
418
|
+
}),
|
|
346
419
|
},
|
|
347
420
|
}),
|
|
348
421
|
);
|
|
349
|
-
};
|
|
350
422
|
|
|
351
|
-
|
|
352
|
-
return this.signalReady.then((signal) => {
|
|
353
|
-
if (signal.readyState !== signal.OPEN) return;
|
|
354
|
-
this.logger(
|
|
355
|
-
'debug',
|
|
356
|
-
`Sending message to: ${this.edgeName}`,
|
|
357
|
-
SfuRequest.toJson(message),
|
|
358
|
-
);
|
|
359
|
-
signal.send(SfuRequest.toBinary(message));
|
|
360
|
-
});
|
|
423
|
+
return current.promise;
|
|
361
424
|
};
|
|
362
425
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
this.logger('trace', 'Sending healthCheckRequest to SFU');
|
|
367
|
-
const message = SfuRequest.create({
|
|
426
|
+
ping = async () => {
|
|
427
|
+
return this.send(
|
|
428
|
+
SfuRequest.create({
|
|
368
429
|
requestPayload: {
|
|
369
430
|
oneofKind: 'healthCheckRequest',
|
|
370
431
|
healthCheckRequest: {},
|
|
371
432
|
},
|
|
372
|
-
})
|
|
373
|
-
|
|
433
|
+
}),
|
|
434
|
+
);
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
private notifyLeave = async (reason: string) => {
|
|
438
|
+
return this.send(
|
|
439
|
+
SfuRequest.create({
|
|
440
|
+
requestPayload: {
|
|
441
|
+
oneofKind: 'leaveCallRequest',
|
|
442
|
+
leaveCallRequest: {
|
|
443
|
+
sessionId: this.sessionId,
|
|
444
|
+
reason,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
private send = async (message: SfuRequest) => {
|
|
452
|
+
await this.signalReady; // wait for the signal ws to be open
|
|
453
|
+
const msgJson = SfuRequest.toJson(message);
|
|
454
|
+
if (this.signalWs.readyState !== WebSocket.OPEN) {
|
|
455
|
+
this.logger('debug', 'Signal WS is not open. Skipping message', msgJson);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
this.logger('debug', `Sending message to: ${this.edgeName}`, msgJson);
|
|
459
|
+
this.signalWs.send(SfuRequest.toBinary(message));
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
private keepAlive = () => {
|
|
463
|
+
clearInterval(this.keepAliveInterval);
|
|
464
|
+
this.keepAliveInterval = setInterval(() => {
|
|
465
|
+
this.ping().catch((e) => {
|
|
374
466
|
this.logger('error', 'Error sending healthCheckRequest to SFU', e);
|
|
375
467
|
});
|
|
376
468
|
}, this.pingIntervalInMs);
|
|
@@ -393,65 +485,3 @@ export class StreamSfuClient {
|
|
|
393
485
|
}, this.unhealthyTimeoutInMs);
|
|
394
486
|
};
|
|
395
487
|
}
|
|
396
|
-
|
|
397
|
-
/**
|
|
398
|
-
* An internal interface which asserts that "retryable" SFU responses
|
|
399
|
-
* contain a field called "error".
|
|
400
|
-
* Ideally, this should be coming from the Protobuf definitions.
|
|
401
|
-
*/
|
|
402
|
-
interface SfuResponseWithError {
|
|
403
|
-
/**
|
|
404
|
-
* An optional error field which should be present in all SFU responses.
|
|
405
|
-
*/
|
|
406
|
-
error?: SfuError;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
const MAX_RETRIES = 5;
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Creates a closure which wraps the given RPC call and retries invoking
|
|
413
|
-
* the RPC until it succeeds or the maximum number of retries is reached.
|
|
414
|
-
*
|
|
415
|
-
* Between each retry, there would be a random delay in order to avoid
|
|
416
|
-
* request bursts towards the SFU.
|
|
417
|
-
*
|
|
418
|
-
* @param rpc the closure around the RPC call to execute.
|
|
419
|
-
* @param logger a logger instance to use.
|
|
420
|
-
* @param <I> the type of the request object.
|
|
421
|
-
* @param <O> the type of the response object.
|
|
422
|
-
*/
|
|
423
|
-
const retryable = async <I extends object, O extends SfuResponseWithError>(
|
|
424
|
-
rpc: () => UnaryCall<I, O>,
|
|
425
|
-
logger: Logger,
|
|
426
|
-
level: LogLevel = 'error',
|
|
427
|
-
) => {
|
|
428
|
-
let retryAttempt = 0;
|
|
429
|
-
let rpcCallResult: FinishedUnaryCall<I, O>;
|
|
430
|
-
do {
|
|
431
|
-
// don't delay the first invocation
|
|
432
|
-
if (retryAttempt > 0) {
|
|
433
|
-
await sleep(retryInterval(retryAttempt));
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
rpcCallResult = await rpc();
|
|
437
|
-
|
|
438
|
-
// if the RPC call failed, log the error and retry
|
|
439
|
-
if (rpcCallResult.response.error) {
|
|
440
|
-
logger(
|
|
441
|
-
level,
|
|
442
|
-
`SFU RPC Error (${rpcCallResult.method.name}):`,
|
|
443
|
-
rpcCallResult.response.error,
|
|
444
|
-
);
|
|
445
|
-
}
|
|
446
|
-
retryAttempt++;
|
|
447
|
-
} while (
|
|
448
|
-
rpcCallResult.response.error?.shouldRetry &&
|
|
449
|
-
retryAttempt < MAX_RETRIES
|
|
450
|
-
);
|
|
451
|
-
|
|
452
|
-
if (rpcCallResult.response.error) {
|
|
453
|
-
throw rpcCallResult.response.error;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
return rpcCallResult;
|
|
457
|
-
};
|