@stream-io/video-client 1.11.11 → 1.11.13

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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  This file was generated using [@jscutlery/semver](https://github.com/jscutlery/semver).
4
4
 
5
+ ## [1.11.13](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.11.12...@stream-io/video-client-1.11.13) (2024-12-03)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * use worker to prevent timer throttling ([#1557](https://github.com/GetStream/stream-video-js/issues/1557)) ([c11c3ca](https://github.com/GetStream/stream-video-js/commit/c11c3caf455787fe531c83601bad71e7a0a0e9b9))
11
+
12
+ ## [1.11.12](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.11.11...@stream-io/video-client-1.11.12) (2024-12-03)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * handle timeout on SFU WS connections ([#1600](https://github.com/GetStream/stream-video-js/issues/1600)) ([5f2db7b](https://github.com/GetStream/stream-video-js/commit/5f2db7bd5cfdf57cdc04d6a6ed752f43e5b06657))
18
+
5
19
  ## [1.11.11](https://github.com/GetStream/stream-video-js/compare/@stream-io/video-client-1.11.10...@stream-io/video-client-1.11.11) (2024-11-29)
6
20
 
7
21
 
@@ -3297,7 +3297,7 @@ const retryable = async (rpc, signal) => {
3297
3297
  return result;
3298
3298
  };
3299
3299
 
3300
- const version = "1.11.11";
3300
+ const version = "1.11.13";
3301
3301
  const [major, minor, patch] = version.split('.');
3302
3302
  let sdkInfo = {
3303
3303
  type: SdkType.PLAIN_JAVASCRIPT,
@@ -6374,6 +6374,32 @@ const createWebSocketSignalChannel = (opts) => {
6374
6374
  return ws;
6375
6375
  };
6376
6376
 
6377
+ /**
6378
+ * Saving a long-lived reference to a promise that can reject can be unsafe,
6379
+ * since rejecting the promise causes an unhandled rejection error (even if the
6380
+ * rejection is handled everywhere promise result is expected).
6381
+ *
6382
+ * To avoid that, we add both resolution and rejection handlers to the promise.
6383
+ * That way, the saved promise never rejects. A callback is provided as return
6384
+ * value to build a *new* promise, that resolves and rejects along with
6385
+ * the original promise.
6386
+ * @param promise Promise to wrap, which possibly rejects
6387
+ * @returns Callback to build a new promise, which resolves and rejects along
6388
+ * with the original promise
6389
+ */
6390
+ function makeSafePromise(promise) {
6391
+ let isPending = true;
6392
+ const safePromise = promise
6393
+ .then((result) => ({ status: 'resolved', result }), (error) => ({ status: 'rejected', error }))
6394
+ .finally(() => (isPending = false));
6395
+ const unwrapPromise = () => safePromise.then((fulfillment) => {
6396
+ if (fulfillment.status === 'rejected')
6397
+ throw fulfillment.error;
6398
+ return fulfillment.result;
6399
+ });
6400
+ unwrapPromise.checkPending = () => isPending;
6401
+ return unwrapPromise;
6402
+ }
6377
6403
  /**
6378
6404
  * Creates a new promise with resolvers.
6379
6405
  *
@@ -6406,6 +6432,151 @@ const promiseWithResolvers = () => {
6406
6432
  };
6407
6433
  };
6408
6434
 
6435
+ const uninitialized = Symbol('uninitialized');
6436
+ /**
6437
+ * Lazily creates a value using a provided factory
6438
+ */
6439
+ function lazy(factory) {
6440
+ let value = uninitialized;
6441
+ return () => {
6442
+ if (value === uninitialized) {
6443
+ value = factory();
6444
+ }
6445
+ return value;
6446
+ };
6447
+ }
6448
+
6449
+ const timerWorker = {
6450
+ src: `var timerIdMapping = new Map();
6451
+ self.addEventListener('message', function (event) {
6452
+ var request = event.data;
6453
+ switch (request.type) {
6454
+ case 'setTimeout':
6455
+ case 'setInterval':
6456
+ timerIdMapping.set(request.id, (request.type === 'setTimeout' ? setTimeout : setInterval)(function () {
6457
+ tick(request.id);
6458
+ if (request.type === 'setTimeout') {
6459
+ timerIdMapping.delete(request.id);
6460
+ }
6461
+ }, request.timeout));
6462
+ break;
6463
+ case 'clearTimeout':
6464
+ case 'clearInterval':
6465
+ (request.type === 'clearTimeout' ? clearTimeout : clearInterval)(timerIdMapping.get(request.id));
6466
+ timerIdMapping.delete(request.id);
6467
+ break;
6468
+ }
6469
+ });
6470
+ function tick(id) {
6471
+ var message = { type: 'tick', id: id };
6472
+ self.postMessage(message);
6473
+ }`,
6474
+ };
6475
+
6476
+ class TimerWorker {
6477
+ constructor() {
6478
+ this.currentTimerId = 1;
6479
+ this.callbacks = new Map();
6480
+ this.fallback = false;
6481
+ }
6482
+ setup({ useTimerWorker = true } = {}) {
6483
+ if (!useTimerWorker) {
6484
+ this.fallback = true;
6485
+ return;
6486
+ }
6487
+ try {
6488
+ const source = timerWorker.src;
6489
+ const blob = new Blob([source], {
6490
+ type: 'application/javascript; charset=utf-8',
6491
+ });
6492
+ const script = URL.createObjectURL(blob);
6493
+ this.worker = new Worker(script, { name: 'str-timer-worker' });
6494
+ this.worker.addEventListener('message', (event) => {
6495
+ const { type, id } = event.data;
6496
+ if (type === 'tick') {
6497
+ this.callbacks.get(id)?.();
6498
+ }
6499
+ });
6500
+ }
6501
+ catch (err) {
6502
+ getLogger(['timer-worker'])('error', err);
6503
+ this.fallback = true;
6504
+ }
6505
+ }
6506
+ destroy() {
6507
+ this.callbacks.clear();
6508
+ this.worker?.terminate();
6509
+ this.worker = undefined;
6510
+ this.fallback = false;
6511
+ }
6512
+ get ready() {
6513
+ return this.fallback || Boolean(this.worker);
6514
+ }
6515
+ setInterval(callback, timeout) {
6516
+ return this.setTimer('setInterval', callback, timeout);
6517
+ }
6518
+ clearInterval(id) {
6519
+ this.clearTimer('clearInterval', id);
6520
+ }
6521
+ setTimeout(callback, timeout) {
6522
+ return this.setTimer('setTimeout', callback, timeout);
6523
+ }
6524
+ clearTimeout(id) {
6525
+ this.clearTimer('clearTimeout', id);
6526
+ }
6527
+ setTimer(type, callback, timeout) {
6528
+ if (!this.ready) {
6529
+ this.setup();
6530
+ }
6531
+ if (this.fallback) {
6532
+ return (type === 'setTimeout' ? setTimeout : setInterval)(callback, timeout);
6533
+ }
6534
+ const id = this.getTimerId();
6535
+ this.callbacks.set(id, () => {
6536
+ callback();
6537
+ // Timeouts are one-off operations, so no need to keep callback reference
6538
+ // after timer has fired
6539
+ if (type === 'setTimeout') {
6540
+ this.callbacks.delete(id);
6541
+ }
6542
+ });
6543
+ this.sendMessage({ type, id, timeout });
6544
+ return id;
6545
+ }
6546
+ clearTimer(type, id) {
6547
+ if (!id) {
6548
+ return;
6549
+ }
6550
+ if (!this.ready) {
6551
+ this.setup();
6552
+ }
6553
+ if (this.fallback) {
6554
+ (type === 'clearTimeout' ? clearTimeout : clearInterval)(id);
6555
+ return;
6556
+ }
6557
+ this.callbacks.delete(id);
6558
+ this.sendMessage({ type, id });
6559
+ }
6560
+ getTimerId() {
6561
+ return this.currentTimerId++;
6562
+ }
6563
+ sendMessage(message) {
6564
+ if (!this.worker) {
6565
+ throw new Error("Cannot use timer worker before it's set up");
6566
+ }
6567
+ this.worker.postMessage(message);
6568
+ }
6569
+ }
6570
+ let timerWorkerEnabled = false;
6571
+ const enableTimerWorker = () => {
6572
+ timerWorkerEnabled = true;
6573
+ };
6574
+ const getTimers = lazy(() => {
6575
+ const instance = new TimerWorker();
6576
+ instance.setup({ useTimerWorker: timerWorkerEnabled });
6577
+ return instance;
6578
+ });
6579
+
6409
6580
  /**
6410
6581
  * The client used for exchanging information with the SFU.
6411
6582
  */
@@ -6426,7 +6597,6 @@ class StreamSfuClient {
6426
6597
  this.isLeaving = false;
6427
6598
  this.pingIntervalInMs = 10 * 1000;
6428
6599
  this.unhealthyTimeoutInMs = this.pingIntervalInMs + 5 * 1000;
6429
- this.restoreWebSocketConcurrencyTag = Symbol('recoverWebSocket');
6430
6600
  /**
6431
6601
  * Promise that resolves when the JoinResponse is received.
6432
6602
  * Rejects after a certain threshold if the response is not received.
@@ -6447,31 +6617,25 @@ class StreamSfuClient {
6447
6617
  },
6448
6618
  });
6449
6619
  this.signalWs.addEventListener('close', this.handleWebSocketClose);
6450
- this.signalWs.addEventListener('error', this.restoreWebSocket);
6451
- this.signalReady = new Promise((resolve) => {
6452
- const onOpen = () => {
6453
- this.signalWs.removeEventListener('open', onOpen);
6454
- resolve(this.signalWs);
6455
- };
6456
- this.signalWs.addEventListener('open', onOpen);
6457
- });
6620
+ this.signalReady = makeSafePromise(Promise.race([
6621
+ new Promise((resolve) => {
6622
+ const onOpen = () => {
6623
+ this.signalWs.removeEventListener('open', onOpen);
6624
+ resolve(this.signalWs);
6625
+ };
6626
+ this.signalWs.addEventListener('open', onOpen);
6627
+ }),
6628
+ new Promise((resolve, reject) => {
6629
+ setTimeout(() => reject(new Error('SFU WS connection timed out')), this.joinResponseTimeout);
6630
+ }),
6631
+ ]));
6458
6632
  };
6459
6633
  this.cleanUpWebSocket = () => {
6460
- this.signalWs.removeEventListener('error', this.restoreWebSocket);
6461
6634
  this.signalWs.removeEventListener('close', this.handleWebSocketClose);
6462
6635
  };
6463
- this.restoreWebSocket = () => {
6464
- withoutConcurrency(this.restoreWebSocketConcurrencyTag, async () => {
6465
- await this.networkAvailableTask?.promise;
6466
- this.logger('debug', 'Restoring SFU WS connection');
6467
- this.cleanUpWebSocket();
6468
- await sleep(500);
6469
- this.createWebSocket();
6470
- }).catch((err) => this.logger('debug', `Can't restore WS connection`, err));
6471
- };
6472
6636
  this.handleWebSocketClose = () => {
6473
6637
  this.signalWs.removeEventListener('close', this.handleWebSocketClose);
6474
- clearInterval(this.keepAliveInterval);
6638
+ getTimers().clearInterval(this.keepAliveInterval);
6475
6639
  clearTimeout(this.connectionCheckTimeout);
6476
6640
  this.onSignalClose?.();
6477
6641
  };
@@ -6562,7 +6726,7 @@ class StreamSfuClient {
6562
6726
  };
6563
6727
  this.join = async (data) => {
6564
6728
  // wait for the signal web socket to be ready before sending "joinRequest"
6565
- await this.signalReady;
6729
+ await this.signalReady();
6566
6730
  if (this.joinResponseTask.isResolved || this.joinResponseTask.isRejected) {
6567
6731
  // we need to lock the RPC requests until we receive a JoinResponse.
6568
6732
  // that's why we have this primitive lock mechanism.
@@ -6617,7 +6781,7 @@ class StreamSfuClient {
6617
6781
  }));
6618
6782
  };
6619
6783
  this.send = async (message) => {
6620
- await this.signalReady; // wait for the signal ws to be open
6784
+ await this.signalReady(); // wait for the signal ws to be open
6621
6785
  const msgJson = SfuRequest.toJson(message);
6622
6786
  if (this.signalWs.readyState !== WebSocket.OPEN) {
6623
6787
  this.logger('debug', 'Signal WS is not open. Skipping message', msgJson);
@@ -6627,8 +6791,9 @@ class StreamSfuClient {
6627
6791
  this.signalWs.send(SfuRequest.toBinary(message));
6628
6792
  };
6629
6793
  this.keepAlive = () => {
6630
- clearInterval(this.keepAliveInterval);
6631
- this.keepAliveInterval = setInterval(() => {
6794
+ const timers = getTimers();
6795
+ timers.clearInterval(this.keepAliveInterval);
6796
+ this.keepAliveInterval = timers.setInterval(() => {
6632
6797
  this.ping().catch((e) => {
6633
6798
  this.logger('error', 'Error sending healthCheckRequest to SFU', e);
6634
6799
  });
@@ -8209,20 +8374,6 @@ function canQueryPermissions() {
8209
8374
  !!navigator.permissions?.query);
8210
8375
  }
8211
8376
 
8212
- const uninitialized = Symbol('uninitialized');
8213
- /**
8214
- * Lazily creates a value using a provided factory
8215
- */
8216
- function lazy(factory) {
8217
- let value = uninitialized;
8218
- return () => {
8219
- if (value === uninitialized) {
8220
- value = factory();
8221
- }
8222
- return value;
8223
- };
8224
- }
8225
-
8226
8377
  /**
8227
8378
  * Returns an Observable that emits the list of available devices
8228
8379
  * that meet the given constraints.
@@ -9834,6 +9985,7 @@ class Call {
9834
9985
  this.reconnectAttempts = 0;
9835
9986
  this.reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
9836
9987
  this.fastReconnectDeadlineSeconds = 0;
9988
+ this.disconnectionTimeoutSeconds = 0;
9837
9989
  this.lastOfflineTimestamp = 0;
9838
9990
  // maintain the order of publishing tracks to restore them after a reconnection
9839
9991
  // it shouldn't contain duplicates
@@ -10350,6 +10502,10 @@ class Call {
10350
10502
  */
10351
10503
  this.handleSfuSignalClose = (sfuClient) => {
10352
10504
  this.logger('debug', '[Reconnect] SFU signal connection closed');
10505
+ // SFU WS closed before we finished current join, no need to schedule reconnect
10506
+ // because join operation will fail
10507
+ if (this.state.callingState === CallingState.JOINING)
10508
+ return;
10353
10509
  // normal close, no need to reconnect
10354
10510
  if (sfuClient.isLeaving)
10355
10511
  return;
@@ -10365,10 +10521,21 @@ class Call {
10365
10521
  * @param strategy the reconnection strategy to use.
10366
10522
  */
10367
10523
  this.reconnect = async (strategy) => {
10524
+ if (this.state.callingState === CallingState.RECONNECTING ||
10525
+ this.state.callingState === CallingState.RECONNECTING_FAILED)
10526
+ return;
10368
10527
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
10369
10528
  this.logger('info', `[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[strategy]}`);
10529
+ let reconnectStartTime = Date.now();
10370
10530
  this.reconnectStrategy = strategy;
10371
10531
  do {
10532
+ if (this.disconnectionTimeoutSeconds > 0 &&
10533
+ (Date.now() - reconnectStartTime) / 1000 >
10534
+ this.disconnectionTimeoutSeconds) {
10535
+ this.logger('warn', '[Reconnect] Stopping reconnection attempts after reaching disconnection timeout');
10536
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
10537
+ return;
10538
+ }
10372
10539
  // we don't increment reconnect attempts for the FAST strategy.
10373
10540
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
10374
10541
  this.reconnectAttempts++;
@@ -10468,7 +10635,7 @@ class Call {
10468
10635
  const currentPublisher = this.publisher;
10469
10636
  currentSubscriber?.detachEventHandlers();
10470
10637
  currentPublisher?.detachEventHandlers();
10471
- const migrationTask = currentSfuClient.enterMigration();
10638
+ const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
10472
10639
  try {
10473
10640
  const currentSfu = currentSfuClient.edgeName;
10474
10641
  await this.join({ ...this.joinCallData, migrating_from: currentSfu });
@@ -10484,7 +10651,7 @@ class Call {
10484
10651
  // Wait for the migration to complete, then close the previous SFU client
10485
10652
  // and the peer connection instances. In case of failure, the migration
10486
10653
  // task would throw an error and REJOIN would be attempted.
10487
- await migrationTask;
10654
+ await migrationTask();
10488
10655
  // in MIGRATE, we can consider the call as joined only after
10489
10656
  // `participantMigrationComplete` event is received, signaled by
10490
10657
  // the `migrationTask`
@@ -11340,6 +11507,14 @@ class Call {
11340
11507
  this.dynascaleManager.setVideoTrackSubscriptionOverrides(enabled ? undefined : { enabled: false });
11341
11508
  this.dynascaleManager.applyTrackSubscriptions();
11342
11509
  };
11510
+ /**
11511
+ * Sets the maximum amount of time a user can remain waiting for a reconnect
11512
+ * after a network disruption
11513
+ * @param timeoutSeconds Timeout in seconds, or 0 to keep reconnecting indefinetely
11514
+ */
11515
+ this.setDisconnectionTimeout = (timeoutSeconds) => {
11516
+ this.disconnectionTimeoutSeconds = timeoutSeconds;
11517
+ };
11343
11518
  this.type = type;
11344
11519
  this.id = id;
11345
11520
  this.cid = `${type}:${id}`;
@@ -11495,33 +11670,6 @@ class Call {
11495
11670
 
11496
11671
  var https = null;
11497
11672
 
11498
- /**
11499
- * Saving a long-lived reference to a promise that can reject can be unsafe,
11500
- * since rejecting the promise causes an unhandled rejection error (even if the
11501
- * rejection is handled everywhere promise result is expected).
11502
- *
11503
- * To avoid that, we add both resolution and rejection handlers to the promise.
11504
- * That way, the saved promise never rejects. A callback is provided as return
11505
- * value to build a *new* promise, that resolves and rejects along with
11506
- * the original promise.
11507
- * @param promise Promise to wrap, which possibly rejects
11508
- * @returns Callback to build a new promise, which resolves and rejects along
11509
- * with the original promise
11510
- */
11511
- function makeSafePromise(promise) {
11512
- let isPending = true;
11513
- const safePromise = promise
11514
- .then((result) => ({ status: 'resolved', result }), (error) => ({ status: 'rejected', error }))
11515
- .finally(() => (isPending = false));
11516
- const unwrapPromise = () => safePromise.then((fulfillment) => {
11517
- if (fulfillment.status === 'rejected')
11518
- throw fulfillment.error;
11519
- return fulfillment.result;
11520
- });
11521
- unwrapPromise.checkPending = () => isPending;
11522
- return unwrapPromise;
11523
- }
11524
-
11525
11673
  /**
11526
11674
  * StableWSConnection - A WS connection that reconnects upon failure.
11527
11675
  * - the browser will sometimes report that you're online or offline
@@ -11774,9 +11922,12 @@ class StableWSConnection {
11774
11922
  * Schedules a next health check ping for websocket.
11775
11923
  */
11776
11924
  this.scheduleNextPing = () => {
11925
+ const timers = getTimers();
11926
+ if (this.healthCheckTimeoutRef) {
11927
+ timers.clearTimeout(this.healthCheckTimeoutRef);
11928
+ }
11777
11929
  // 30 seconds is the recommended interval (messenger uses this)
11778
- clearTimeout(this.healthCheckTimeoutRef);
11779
- this.healthCheckTimeoutRef = setTimeout(() => {
11930
+ this.healthCheckTimeoutRef = timers.setTimeout(() => {
11780
11931
  // send the healthcheck..., server replies with a health check event
11781
11932
  const data = [{ type: 'health.check', client_id: this.client.clientID }];
11782
11933
  // try to send on the connection
@@ -11919,8 +12070,12 @@ class StableWSConnection {
11919
12070
  this.isConnecting = false;
11920
12071
  this.isDisconnected = true;
11921
12072
  // start by removing all the listeners
11922
- clearInterval(this.healthCheckTimeoutRef);
11923
- clearInterval(this.connectionCheckTimeoutRef);
12073
+ if (this.healthCheckTimeoutRef) {
12074
+ getTimers().clearInterval(this.healthCheckTimeoutRef);
12075
+ }
12076
+ if (this.connectionCheckTimeoutRef) {
12077
+ clearInterval(this.connectionCheckTimeoutRef);
12078
+ }
11924
12079
  removeConnectionEventListeners(this.onlineStatusChanged);
11925
12080
  this.isHealthy = false;
11926
12081
  let isClosedPromise;
@@ -12620,7 +12775,7 @@ class StreamClient {
12620
12775
  return await this.wsConnection.connect(this.defaultWSTimeout);
12621
12776
  };
12622
12777
  this.getUserAgent = () => {
12623
- const version = "1.11.11";
12778
+ const version = "1.11.13";
12624
12779
  return (this.userAgent ||
12625
12780
  `stream-video-javascript-client-${this.node ? 'node' : 'browser'}-${version}`);
12626
12781
  };
@@ -13031,10 +13186,14 @@ class StreamVideoClient {
13031
13186
  if (typeof apiKeyOrArgs === 'string') {
13032
13187
  logLevel = opts?.logLevel || logLevel;
13033
13188
  logger = opts?.logger || logger;
13189
+ if (opts?.expertimental_enableTimerWorker)
13190
+ enableTimerWorker();
13034
13191
  }
13035
13192
  else {
13036
13193
  logLevel = apiKeyOrArgs.options?.logLevel || logLevel;
13037
13194
  logger = apiKeyOrArgs.options?.logger || logger;
13195
+ if (apiKeyOrArgs.options?.expertimental_enableTimerWorker)
13196
+ enableTimerWorker();
13038
13197
  }
13039
13198
  setLogger(logger, logLevel);
13040
13199
  this.logger = getLogger(['client']);