@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +175 -0
  2. package/dist/index.browser.es.js +1986 -1482
  3. package/dist/index.browser.es.js.map +1 -1
  4. package/dist/index.cjs.js +1983 -1478
  5. package/dist/index.cjs.js.map +1 -1
  6. package/dist/index.es.js +1986 -1482
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/src/Call.d.ts +93 -9
  9. package/dist/src/StreamSfuClient.d.ts +73 -56
  10. package/dist/src/StreamVideoClient.d.ts +2 -2
  11. package/dist/src/coordinator/connection/client.d.ts +3 -4
  12. package/dist/src/coordinator/connection/types.d.ts +5 -1
  13. package/dist/src/devices/InputMediaDeviceManager.d.ts +4 -0
  14. package/dist/src/devices/MicrophoneManager.d.ts +1 -1
  15. package/dist/src/events/callEventHandlers.d.ts +1 -3
  16. package/dist/src/events/internal.d.ts +4 -0
  17. package/dist/src/gen/video/sfu/event/events.d.ts +106 -4
  18. package/dist/src/gen/video/sfu/models/models.d.ts +64 -65
  19. package/dist/src/helpers/ensureExhausted.d.ts +1 -0
  20. package/dist/src/helpers/withResolvers.d.ts +14 -0
  21. package/dist/src/logger.d.ts +1 -0
  22. package/dist/src/rpc/createClient.d.ts +2 -0
  23. package/dist/src/rpc/index.d.ts +1 -0
  24. package/dist/src/rpc/retryable.d.ts +23 -0
  25. package/dist/src/rtc/Dispatcher.d.ts +1 -1
  26. package/dist/src/rtc/IceTrickleBuffer.d.ts +0 -1
  27. package/dist/src/rtc/Publisher.d.ts +24 -25
  28. package/dist/src/rtc/Subscriber.d.ts +12 -11
  29. package/dist/src/rtc/helpers/rtcConfiguration.d.ts +2 -0
  30. package/dist/src/rtc/helpers/tracks.d.ts +3 -3
  31. package/dist/src/rtc/signal.d.ts +1 -1
  32. package/dist/src/store/CallState.d.ts +46 -2
  33. package/package.json +3 -3
  34. package/src/Call.ts +628 -566
  35. package/src/StreamSfuClient.ts +276 -246
  36. package/src/StreamVideoClient.ts +15 -16
  37. package/src/coordinator/connection/client.ts +25 -8
  38. package/src/coordinator/connection/connection.ts +1 -0
  39. package/src/coordinator/connection/types.ts +6 -0
  40. package/src/devices/CameraManager.ts +1 -1
  41. package/src/devices/InputMediaDeviceManager.ts +12 -3
  42. package/src/devices/MicrophoneManager.ts +3 -3
  43. package/src/devices/devices.ts +1 -1
  44. package/src/events/__tests__/mutes.test.ts +10 -13
  45. package/src/events/__tests__/participant.test.ts +75 -0
  46. package/src/events/callEventHandlers.ts +4 -7
  47. package/src/events/internal.ts +20 -3
  48. package/src/events/mutes.ts +5 -3
  49. package/src/events/participant.ts +48 -15
  50. package/src/gen/video/sfu/event/events.ts +451 -8
  51. package/src/gen/video/sfu/models/models.ts +211 -204
  52. package/src/helpers/ensureExhausted.ts +5 -0
  53. package/src/helpers/withResolvers.ts +43 -0
  54. package/src/logger.ts +3 -1
  55. package/src/rpc/__tests__/retryable.test.ts +72 -0
  56. package/src/rpc/createClient.ts +21 -0
  57. package/src/rpc/index.ts +1 -0
  58. package/src/rpc/retryable.ts +57 -0
  59. package/src/rtc/Dispatcher.ts +6 -2
  60. package/src/rtc/IceTrickleBuffer.ts +2 -2
  61. package/src/rtc/Publisher.ts +127 -163
  62. package/src/rtc/Subscriber.ts +92 -155
  63. package/src/rtc/__tests__/Publisher.test.ts +18 -95
  64. package/src/rtc/__tests__/Subscriber.test.ts +63 -99
  65. package/src/rtc/__tests__/videoLayers.test.ts +2 -2
  66. package/src/rtc/helpers/rtcConfiguration.ts +11 -0
  67. package/src/rtc/helpers/tracks.ts +27 -7
  68. package/src/rtc/signal.ts +3 -3
  69. package/src/rtc/videoLayers.ts +1 -10
  70. package/src/stats/SfuStatsReporter.ts +1 -0
  71. package/src/store/CallState.ts +109 -2
  72. package/src/store/__tests__/CallState.test.ts +48 -37
  73. package/dist/src/rtc/flows/join.d.ts +0 -20
  74. package/src/rtc/flows/join.ts +0 -65
@@ -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 { createSignalClient, withHeaders } from './rpc';
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 { JoinRequest, SfuRequest } from './gen/video/sfu/event/events';
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
- Error as SfuError,
28
- ICETrickle,
29
- TrackType,
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 SFU server to connect to.
44
+ * The credentials to use for the connection.
45
+ */
46
+ credentials: Credentials;
47
+
48
+ /**
49
+ * `sessionId` to use for the connection.
48
50
  */
49
- sfuServer: SFUResponse;
51
+ sessionId?: string;
50
52
 
51
53
  /**
52
- * The JWT token to use for authentication.
54
+ * A log tag to use for logging. Useful for debugging multiple instances.
53
55
  */
54
- token: string;
56
+ logTag: string;
55
57
 
56
58
  /**
57
- * An optional `sessionId` to use for the connection.
58
- * If not provided, a random UUIDv4 will be generated.
59
+ * The timeout in milliseconds for waiting for the `joinResponse`.
60
+ * Defaults to 5000ms.
59
61
  */
60
- sessionId?: string;
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 PeerConnections are ready to handle them.
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: WebSocket;
94
+ private signalWs!: WebSocket;
96
95
 
97
96
  /**
98
97
  * Promise that resolves when the WebSocket connection is ready (open).
99
98
  */
100
- signalReady: Promise<WebSocket>;
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
- * A flag indicating that the client connection is broken for the current
110
- * client and that a fast-reconnect with a new client should be attempted.
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
- isFastReconnecting = false;
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
- sfuServer,
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.sfuServer = sfuServer;
157
- this.edgeName = sfuServer.edge_name;
158
- this.token = token;
159
- this.logger = getLogger(['sfu-client']);
160
- const logInterceptor: RpcInterceptor = {
161
- interceptUnary: (
162
- next: NextUnaryFn,
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: sfuServer.url,
171
+ baseUrl: server.url,
176
172
  interceptors: [
177
173
  withHeaders({
178
174
  Authorization: `Bearer ${token}`,
179
175
  }),
180
- logInterceptor,
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
- // These events might be triggered by the SFU before the initial RTC
186
- // connection is established. In that case, those events (ICE candidates)
187
- // need to be buffered and later added to the appropriate PeerConnection
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
- endpoint: sfuServer.ws_endpoint,
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
- close = (code: number, reason: string) => {
213
- this.logger('debug', `Closing SFU WS connection: ${code} - ${reason}`);
214
- if (this.signalWs.readyState !== this.signalWs.CLOSED) {
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 (subscriptions: TrackSubscriptionDetails[]) => {
274
+ updateSubscriptions = async (tracks: TrackSubscriptionDetails[]) => {
275
+ await this.joinResponseTask.promise;
224
276
  return retryable(
225
- () =>
226
- this.rpc.updateSubscriptions({
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
- this.rpc.setPublisher({
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
- this.rpc.sendAnswer({
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
- this.rpc.iceTrickle({
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
- this.rpc.iceRestart({
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
- return this.updateMuteStates({
281
- muteStates: [
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
- this.rpc.updateMuteStates({
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
- this.rpc.sendStats({
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
- this.rpc.startNoiseCancellation({
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
- this.rpc.stopNoiseCancellation({
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
- join = async (data: Omit<JoinRequest, 'sessionId' | 'token'>) => {
336
- const joinRequest = JoinRequest.create({
337
- ...data,
338
- sessionId: this.sessionId,
339
- token: this.token,
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
- return this.send(
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
- send = async (message: SfuRequest) => {
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
- private keepAlive = () => {
364
- clearInterval(this.keepAliveInterval);
365
- this.keepAliveInterval = setInterval(() => {
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
- this.send(message).catch((e) => {
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
- };