@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.
@@ -81,6 +81,7 @@ export declare class Call {
81
81
  private reconnectAttempts;
82
82
  private reconnectStrategy;
83
83
  private fastReconnectDeadlineSeconds;
84
+ private disconnectionTimeoutSeconds;
84
85
  private lastOfflineTimestamp;
85
86
  private networkAvailableTask;
86
87
  private trackPublishOrder;
@@ -652,4 +653,10 @@ export declare class Call {
652
653
  * and removes any preference for preferred resolution.
653
654
  */
654
655
  setIncomingVideoEnabled: (enabled: boolean) => void;
656
+ /**
657
+ * Sets the maximum amount of time a user can remain waiting for a reconnect
658
+ * after a network disruption
659
+ * @param timeoutSeconds Timeout in seconds, or 0 to keep reconnecting indefinetely
660
+ */
661
+ setDisconnectionTimeout: (timeoutSeconds: number) => void;
655
662
  }
@@ -72,7 +72,6 @@ export declare class StreamSfuClient {
72
72
  private pingIntervalInMs;
73
73
  private unhealthyTimeoutInMs;
74
74
  private lastMessageTimestamp?;
75
- private readonly restoreWebSocketConcurrencyTag;
76
75
  private readonly unsubscribeIceTrickle;
77
76
  private readonly unsubscribeNetworkChanged;
78
77
  private readonly onSignalClose;
@@ -80,7 +79,7 @@ export declare class StreamSfuClient {
80
79
  private readonly logTag;
81
80
  private readonly credentials;
82
81
  private readonly dispatcher;
83
- private readonly joinResponseTimeout?;
82
+ private readonly joinResponseTimeout;
84
83
  private networkAvailableTask;
85
84
  /**
86
85
  * Promise that resolves when the JoinResponse is received.
@@ -119,7 +118,6 @@ export declare class StreamSfuClient {
119
118
  constructor({ dispatcher, credentials, sessionId, logTag, joinResponseTimeout, onSignalClose, streamClient, }: StreamSfuClientConstructor);
120
119
  private createWebSocket;
121
120
  private cleanUpWebSocket;
122
- private restoreWebSocket;
123
121
  get isHealthy(): boolean;
124
122
  get joinTask(): Promise<JoinResponse>;
125
123
  private handleWebSocketClose;
@@ -23,7 +23,7 @@ export declare class StableWSConnection {
23
23
  private connectionOpenSafe?;
24
24
  consecutiveFailures: number;
25
25
  pingInterval: number;
26
- healthCheckTimeoutRef?: NodeJS.Timeout;
26
+ healthCheckTimeoutRef?: number;
27
27
  isConnecting: boolean;
28
28
  isDisconnected: boolean;
29
29
  isHealthy: boolean;
@@ -119,6 +119,11 @@ export type StreamClientOptions = Partial<AxiosRequestConfig> & {
119
119
  * In Node.js environment, you can use the `ws` package.
120
120
  */
121
121
  WebSocketImpl?: typeof WebSocket;
122
+ /**
123
+ * Create Web Worker to initiate timer events like health checks. Can possibly prevent
124
+ * timer throttling issues in inactive browser tabs.
125
+ */
126
+ expertimental_enableTimerWorker?: boolean;
122
127
  };
123
128
  export type TokenProvider = () => Promise<string>;
124
129
  export type TokenOrProvider = null | string | TokenProvider | undefined;
@@ -16,3 +16,17 @@ export interface SafePromise<T> {
16
16
  * with the original promise
17
17
  */
18
18
  export declare function makeSafePromise<T>(promise: Promise<T>): SafePromise<T>;
19
+ export type PromiseWithResolvers<T> = {
20
+ promise: Promise<T>;
21
+ resolve: (value: T | PromiseLike<T>) => void;
22
+ reject: (reason: any) => void;
23
+ isResolved: boolean;
24
+ isRejected: boolean;
25
+ };
26
+ /**
27
+ * Creates a new promise with resolvers.
28
+ *
29
+ * Based on:
30
+ * - https://github.com/tc39/proposal-promise-with-resolvers/blob/main/polyfills.js
31
+ */
32
+ export declare const promiseWithResolvers: <T = void>() => PromiseWithResolvers<T>;
@@ -0,0 +1,22 @@
1
+ declare class TimerWorker {
2
+ private currentTimerId;
3
+ private callbacks;
4
+ private worker;
5
+ private fallback;
6
+ setup({ useTimerWorker }?: {
7
+ useTimerWorker?: boolean;
8
+ }): void;
9
+ destroy(): void;
10
+ get ready(): boolean;
11
+ setInterval(callback: () => void, timeout: number): number;
12
+ clearInterval(id?: number): void;
13
+ setTimeout(callback: () => void, timeout: number): number;
14
+ clearTimeout(id?: number): void;
15
+ private setTimer;
16
+ private clearTimer;
17
+ private getTimerId;
18
+ private sendMessage;
19
+ }
20
+ export declare const enableTimerWorker: () => void;
21
+ export declare const getTimers: () => TimerWorker;
22
+ export {};
@@ -0,0 +1,12 @@
1
+ export type TimerWorkerRequest = {
2
+ type: 'setInterval' | 'setTimeout';
3
+ id: number;
4
+ timeout: number;
5
+ } | {
6
+ type: 'clearInterval' | 'clearTimeout';
7
+ id: number;
8
+ };
9
+ export type TimerWorkerEvent = {
10
+ type: 'tick';
11
+ id: number;
12
+ };
@@ -0,0 +1,3 @@
1
+ export declare const timerWorker: {
2
+ src: string;
3
+ };
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stream-io/video-client",
3
- "version": "1.11.11",
3
+ "version": "1.11.13",
4
4
  "packageManager": "yarn@3.2.4",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.es.js",
@@ -11,12 +11,13 @@
11
11
  "scripts": {
12
12
  "clean": "rimraf dist",
13
13
  "start": "rollup -w -c",
14
- "build": "yarn clean && rollup -c",
14
+ "build": "yarn clean && ./generate-timer-worker.sh && rollup -c",
15
15
  "test": "vitest",
16
16
  "clean:docs": "rimraf generated-docs",
17
17
  "test-ci": "vitest --coverage",
18
18
  "generate:open-api": "./generate-openapi.sh protocol",
19
- "generate:open-api:dev": "./generate-openapi.sh chat"
19
+ "generate:open-api:dev": "./generate-openapi.sh chat",
20
+ "generate:timer-worker": "./generate-timer-worker.sh"
20
21
  },
21
22
  "files": [
22
23
  "dist",
package/src/Call.ts CHANGED
@@ -120,9 +120,10 @@ import { getSdkSignature } from './stats/utils';
120
120
  import { withoutConcurrency } from './helpers/concurrency';
121
121
  import { ensureExhausted } from './helpers/ensureExhausted';
122
122
  import {
123
+ makeSafePromise,
123
124
  PromiseWithResolvers,
124
125
  promiseWithResolvers,
125
- } from './helpers/withResolvers';
126
+ } from './helpers/promise';
126
127
 
127
128
  /**
128
129
  * An object representation of a `Call`.
@@ -213,6 +214,7 @@ export class Call {
213
214
  private reconnectAttempts = 0;
214
215
  private reconnectStrategy = WebsocketReconnectStrategy.UNSPECIFIED;
215
216
  private fastReconnectDeadlineSeconds: number = 0;
217
+ private disconnectionTimeoutSeconds: number = 0;
216
218
  private lastOfflineTimestamp: number = 0;
217
219
  private networkAvailableTask: PromiseWithResolvers<void> | undefined;
218
220
  // maintain the order of publishing tracks to restore them after a reconnection
@@ -1051,6 +1053,9 @@ export class Call {
1051
1053
  */
1052
1054
  private handleSfuSignalClose = (sfuClient: StreamSfuClient) => {
1053
1055
  this.logger('debug', '[Reconnect] SFU signal connection closed');
1056
+ // SFU WS closed before we finished current join, no need to schedule reconnect
1057
+ // because join operation will fail
1058
+ if (this.state.callingState === CallingState.JOINING) return;
1054
1059
  // normal close, no need to reconnect
1055
1060
  if (sfuClient.isLeaving) return;
1056
1061
  this.reconnect(WebsocketReconnectStrategy.REJOIN).catch((err) => {
@@ -1068,14 +1073,35 @@ export class Call {
1068
1073
  private reconnect = async (
1069
1074
  strategy: WebsocketReconnectStrategy,
1070
1075
  ): Promise<void> => {
1076
+ if (
1077
+ this.state.callingState === CallingState.RECONNECTING ||
1078
+ this.state.callingState === CallingState.RECONNECTING_FAILED
1079
+ )
1080
+ return;
1081
+
1071
1082
  return withoutConcurrency(this.reconnectConcurrencyTag, async () => {
1072
1083
  this.logger(
1073
1084
  'info',
1074
1085
  `[Reconnect] Reconnecting with strategy ${WebsocketReconnectStrategy[strategy]}`,
1075
1086
  );
1076
1087
 
1088
+ let reconnectStartTime = Date.now();
1077
1089
  this.reconnectStrategy = strategy;
1090
+
1078
1091
  do {
1092
+ if (
1093
+ this.disconnectionTimeoutSeconds > 0 &&
1094
+ (Date.now() - reconnectStartTime) / 1000 >
1095
+ this.disconnectionTimeoutSeconds
1096
+ ) {
1097
+ this.logger(
1098
+ 'warn',
1099
+ '[Reconnect] Stopping reconnection attempts after reaching disconnection timeout',
1100
+ );
1101
+ this.state.setCallingState(CallingState.RECONNECTING_FAILED);
1102
+ return;
1103
+ }
1104
+
1079
1105
  // we don't increment reconnect attempts for the FAST strategy.
1080
1106
  if (this.reconnectStrategy !== WebsocketReconnectStrategy.FAST) {
1081
1107
  this.reconnectAttempts++;
@@ -1193,7 +1219,7 @@ export class Call {
1193
1219
  currentSubscriber?.detachEventHandlers();
1194
1220
  currentPublisher?.detachEventHandlers();
1195
1221
 
1196
- const migrationTask = currentSfuClient.enterMigration();
1222
+ const migrationTask = makeSafePromise(currentSfuClient.enterMigration());
1197
1223
 
1198
1224
  try {
1199
1225
  const currentSfu = currentSfuClient.edgeName;
@@ -1211,7 +1237,7 @@ export class Call {
1211
1237
  // Wait for the migration to complete, then close the previous SFU client
1212
1238
  // and the peer connection instances. In case of failure, the migration
1213
1239
  // task would throw an error and REJOIN would be attempted.
1214
- await migrationTask;
1240
+ await migrationTask();
1215
1241
 
1216
1242
  // in MIGRATE, we can consider the call as joined only after
1217
1243
  // `participantMigrationComplete` event is received, signaled by
@@ -2337,4 +2363,13 @@ export class Call {
2337
2363
  );
2338
2364
  this.dynascaleManager.applyTrackSubscriptions();
2339
2365
  };
2366
+
2367
+ /**
2368
+ * Sets the maximum amount of time a user can remain waiting for a reconnect
2369
+ * after a network disruption
2370
+ * @param timeoutSeconds Timeout in seconds, or 0 to keep reconnecting indefinetely
2371
+ */
2372
+ setDisconnectionTimeout = (timeoutSeconds: number) => {
2373
+ this.disconnectionTimeoutSeconds = timeoutSeconds;
2374
+ };
2340
2375
  }
@@ -25,15 +25,17 @@ import {
25
25
  } from './gen/video/sfu/signal_rpc/signal';
26
26
  import { ICETrickle, TrackType } from './gen/video/sfu/models/models';
27
27
  import { StreamClient } from './coordinator/connection/client';
28
- import { generateUUIDv4, sleep } from './coordinator/connection/utils';
28
+ import { generateUUIDv4 } from './coordinator/connection/utils';
29
29
  import { Credentials } from './gen/coordinator';
30
30
  import { Logger } from './coordinator/connection/types';
31
31
  import { getLogger, getLogLevel } from './logger';
32
- import { withoutConcurrency } from './helpers/concurrency';
33
32
  import {
34
33
  promiseWithResolvers,
35
34
  PromiseWithResolvers,
36
- } from './helpers/withResolvers';
35
+ makeSafePromise,
36
+ SafePromise,
37
+ } from './helpers/promise';
38
+ import { getTimers } from './timers';
37
39
 
38
40
  export type StreamSfuClientConstructor = {
39
41
  /**
@@ -101,7 +103,7 @@ export class StreamSfuClient {
101
103
  /**
102
104
  * Promise that resolves when the WebSocket connection is ready (open).
103
105
  */
104
- private signalReady!: Promise<WebSocket>;
106
+ private signalReady!: SafePromise<WebSocket>;
105
107
 
106
108
  /**
107
109
  * Flag to indicate if the client is in the process of leaving the call.
@@ -110,13 +112,12 @@ export class StreamSfuClient {
110
112
  isLeaving = false;
111
113
 
112
114
  private readonly rpc: SignalServerClient;
113
- private keepAliveInterval?: NodeJS.Timeout;
115
+ private keepAliveInterval?: number;
114
116
  private connectionCheckTimeout?: NodeJS.Timeout;
115
117
  private migrateAwayTimeout?: NodeJS.Timeout;
116
118
  private pingIntervalInMs = 10 * 1000;
117
119
  private unhealthyTimeoutInMs = this.pingIntervalInMs + 5 * 1000;
118
120
  private lastMessageTimestamp?: Date;
119
- private readonly restoreWebSocketConcurrencyTag = Symbol('recoverWebSocket');
120
121
  private readonly unsubscribeIceTrickle: () => void;
121
122
  private readonly unsubscribeNetworkChanged: () => void;
122
123
  private readonly onSignalClose: (() => void) | undefined;
@@ -124,7 +125,7 @@ export class StreamSfuClient {
124
125
  private readonly logTag: string;
125
126
  private readonly credentials: Credentials;
126
127
  private readonly dispatcher: Dispatcher;
127
- private readonly joinResponseTimeout?: number;
128
+ private readonly joinResponseTimeout: number;
128
129
  private networkAvailableTask: PromiseWithResolvers<void> | undefined;
129
130
  /**
130
131
  * Promise that resolves when the JoinResponse is received.
@@ -227,32 +228,31 @@ export class StreamSfuClient {
227
228
  });
228
229
 
229
230
  this.signalWs.addEventListener('close', this.handleWebSocketClose);
230
- this.signalWs.addEventListener('error', this.restoreWebSocket);
231
-
232
- this.signalReady = new Promise((resolve) => {
233
- const onOpen = () => {
234
- this.signalWs.removeEventListener('open', onOpen);
235
- resolve(this.signalWs);
236
- };
237
- this.signalWs.addEventListener('open', onOpen);
238
- });
231
+
232
+ this.signalReady = makeSafePromise(
233
+ Promise.race<WebSocket>([
234
+ new Promise((resolve) => {
235
+ const onOpen = () => {
236
+ this.signalWs.removeEventListener('open', onOpen);
237
+ resolve(this.signalWs);
238
+ };
239
+ this.signalWs.addEventListener('open', onOpen);
240
+ }),
241
+
242
+ new Promise((resolve, reject) => {
243
+ setTimeout(
244
+ () => reject(new Error('SFU WS connection timed out')),
245
+ this.joinResponseTimeout,
246
+ );
247
+ }),
248
+ ]),
249
+ );
239
250
  };
240
251
 
241
252
  private cleanUpWebSocket = () => {
242
- this.signalWs.removeEventListener('error', this.restoreWebSocket);
243
253
  this.signalWs.removeEventListener('close', this.handleWebSocketClose);
244
254
  };
245
255
 
246
- private restoreWebSocket = () => {
247
- withoutConcurrency(this.restoreWebSocketConcurrencyTag, async () => {
248
- await this.networkAvailableTask?.promise;
249
- this.logger('debug', 'Restoring SFU WS connection');
250
- this.cleanUpWebSocket();
251
- await sleep(500);
252
- this.createWebSocket();
253
- }).catch((err) => this.logger('debug', `Can't restore WS connection`, err));
254
- };
255
-
256
256
  get isHealthy() {
257
257
  return this.signalWs.readyState === WebSocket.OPEN;
258
258
  }
@@ -263,7 +263,7 @@ export class StreamSfuClient {
263
263
 
264
264
  private handleWebSocketClose = () => {
265
265
  this.signalWs.removeEventListener('close', this.handleWebSocketClose);
266
- clearInterval(this.keepAliveInterval);
266
+ getTimers().clearInterval(this.keepAliveInterval);
267
267
  clearTimeout(this.connectionCheckTimeout);
268
268
  this.onSignalClose?.();
269
269
  };
@@ -409,7 +409,7 @@ export class StreamSfuClient {
409
409
  data: Omit<JoinRequest, 'sessionId' | 'token'>,
410
410
  ): Promise<JoinResponse> => {
411
411
  // wait for the signal web socket to be ready before sending "joinRequest"
412
- await this.signalReady;
412
+ await this.signalReady();
413
413
  if (this.joinResponseTask.isResolved || this.joinResponseTask.isRejected) {
414
414
  // we need to lock the RPC requests until we receive a JoinResponse.
415
415
  // that's why we have this primitive lock mechanism.
@@ -478,7 +478,7 @@ export class StreamSfuClient {
478
478
  };
479
479
 
480
480
  private send = async (message: SfuRequest) => {
481
- await this.signalReady; // wait for the signal ws to be open
481
+ await this.signalReady(); // wait for the signal ws to be open
482
482
  const msgJson = SfuRequest.toJson(message);
483
483
  if (this.signalWs.readyState !== WebSocket.OPEN) {
484
484
  this.logger('debug', 'Signal WS is not open. Skipping message', msgJson);
@@ -489,8 +489,9 @@ export class StreamSfuClient {
489
489
  };
490
490
 
491
491
  private keepAlive = () => {
492
- clearInterval(this.keepAliveInterval);
493
- this.keepAliveInterval = setInterval(() => {
492
+ const timers = getTimers();
493
+ timers.clearInterval(this.keepAliveInterval);
494
+ this.keepAliveInterval = timers.setInterval(() => {
494
495
  this.ping().catch((e) => {
495
496
  this.logger('error', 'Error sending healthCheckRequest to SFU', e);
496
497
  });
@@ -31,6 +31,7 @@ import { getLogger, logToConsole, setLogger } from './logger';
31
31
  import { getSdkInfo } from './client-details';
32
32
  import { SdkType } from './gen/video/sfu/models/models';
33
33
  import { withoutConcurrency } from './helpers/concurrency';
34
+ import { enableTimerWorker } from './timers';
34
35
 
35
36
  /**
36
37
  * A `StreamVideoClient` instance lets you communicate with our API, and authenticate users.
@@ -86,9 +87,12 @@ export class StreamVideoClient {
86
87
  if (typeof apiKeyOrArgs === 'string') {
87
88
  logLevel = opts?.logLevel || logLevel;
88
89
  logger = opts?.logger || logger;
90
+ if (opts?.expertimental_enableTimerWorker) enableTimerWorker();
89
91
  } else {
90
92
  logLevel = apiKeyOrArgs.options?.logLevel || logLevel;
91
93
  logger = apiKeyOrArgs.options?.logger || logger;
94
+ if (apiKeyOrArgs.options?.expertimental_enableTimerWorker)
95
+ enableTimerWorker();
92
96
  }
93
97
 
94
98
  setLogger(logger, logLevel);
@@ -15,6 +15,7 @@ import type {
15
15
  WSAuthMessage,
16
16
  } from '../../gen/coordinator';
17
17
  import { makeSafePromise, type SafePromise } from '../../helpers/promise';
18
+ import { getTimers } from '../../timers';
18
19
 
19
20
  /**
20
21
  * StableWSConnection - A WS connection that reconnects upon failure.
@@ -39,7 +40,7 @@ export class StableWSConnection {
39
40
  private connectionOpenSafe?: SafePromise<ConnectedEvent>;
40
41
  consecutiveFailures: number;
41
42
  pingInterval: number;
42
- healthCheckTimeoutRef?: NodeJS.Timeout;
43
+ healthCheckTimeoutRef?: number;
43
44
  isConnecting: boolean;
44
45
  isDisconnected: boolean;
45
46
  isHealthy: boolean;
@@ -224,8 +225,12 @@ export class StableWSConnection {
224
225
  this.isDisconnected = true;
225
226
 
226
227
  // start by removing all the listeners
227
- clearInterval(this.healthCheckTimeoutRef);
228
- clearInterval(this.connectionCheckTimeoutRef);
228
+ if (this.healthCheckTimeoutRef) {
229
+ getTimers().clearInterval(this.healthCheckTimeoutRef);
230
+ }
231
+ if (this.connectionCheckTimeoutRef) {
232
+ clearInterval(this.connectionCheckTimeoutRef);
233
+ }
229
234
 
230
235
  removeConnectionEventListeners(this.onlineStatusChanged);
231
236
 
@@ -689,9 +694,13 @@ export class StableWSConnection {
689
694
  * Schedules a next health check ping for websocket.
690
695
  */
691
696
  scheduleNextPing = () => {
697
+ const timers = getTimers();
698
+ if (this.healthCheckTimeoutRef) {
699
+ timers.clearTimeout(this.healthCheckTimeoutRef);
700
+ }
701
+
692
702
  // 30 seconds is the recommended interval (messenger uses this)
693
- clearTimeout(this.healthCheckTimeoutRef);
694
- this.healthCheckTimeoutRef = setTimeout(() => {
703
+ this.healthCheckTimeoutRef = timers.setTimeout(() => {
695
704
  // send the healthcheck..., server replies with a health check event
696
705
  const data = [{ type: 'health.check', client_id: this.client.clientID }];
697
706
  // try to send on the connection
@@ -146,6 +146,12 @@ export type StreamClientOptions = Partial<AxiosRequestConfig> & {
146
146
  * In Node.js environment, you can use the `ws` package.
147
147
  */
148
148
  WebSocketImpl?: typeof WebSocket;
149
+
150
+ /**
151
+ * Create Web Worker to initiate timer events like health checks. Can possibly prevent
152
+ * timer throttling issues in inactive browser tabs.
153
+ */
154
+ expertimental_enableTimerWorker?: boolean;
149
155
  };
150
156
 
151
157
  export type TokenProvider = () => Promise<string>;
@@ -45,3 +45,47 @@ export function makeSafePromise<T>(promise: Promise<T>): SafePromise<T> {
45
45
  unwrapPromise.checkPending = () => isPending;
46
46
  return unwrapPromise;
47
47
  }
48
+
49
+ export type PromiseWithResolvers<T> = {
50
+ promise: Promise<T>;
51
+ resolve: (value: T | PromiseLike<T>) => void;
52
+ reject: (reason: any) => void;
53
+ isResolved: boolean;
54
+ isRejected: boolean;
55
+ };
56
+
57
+ /**
58
+ * Creates a new promise with resolvers.
59
+ *
60
+ * Based on:
61
+ * - https://github.com/tc39/proposal-promise-with-resolvers/blob/main/polyfills.js
62
+ */
63
+ export const promiseWithResolvers = <T = void>(): PromiseWithResolvers<T> => {
64
+ let resolve: (value: T | PromiseLike<T>) => void;
65
+ let reject: (reason: any) => void;
66
+ const promise = new Promise<T>((_resolve, _reject) => {
67
+ resolve = _resolve;
68
+ reject = _reject;
69
+ });
70
+
71
+ let isResolved = false;
72
+ let isRejected = false;
73
+
74
+ const resolver = (value: T | PromiseLike<T>) => {
75
+ isResolved = true;
76
+ resolve(value);
77
+ };
78
+
79
+ const rejecter = (reason: any) => {
80
+ isRejected = true;
81
+ reject(reason);
82
+ };
83
+
84
+ return {
85
+ promise,
86
+ resolve: resolver,
87
+ reject: rejecter,
88
+ isResolved,
89
+ isRejected,
90
+ };
91
+ };
@@ -0,0 +1,137 @@
1
+ import { lazy } from '../helpers/lazy';
2
+ import { getLogger } from '../logger';
3
+ import { TimerWorkerEvent, TimerWorkerRequest } from './types';
4
+ import { timerWorker } from './worker.build';
5
+
6
+ class TimerWorker {
7
+ private currentTimerId = 1;
8
+ private callbacks = new Map<number, () => void>();
9
+ private worker: Worker | undefined;
10
+ private fallback = false;
11
+
12
+ setup({ useTimerWorker = true }: { useTimerWorker?: boolean } = {}): void {
13
+ if (!useTimerWorker) {
14
+ this.fallback = true;
15
+ return;
16
+ }
17
+
18
+ try {
19
+ const source = timerWorker.src;
20
+ const blob = new Blob([source], {
21
+ type: 'application/javascript; charset=utf-8',
22
+ });
23
+ const script = URL.createObjectURL(blob);
24
+ this.worker = new Worker(script, { name: 'str-timer-worker' });
25
+ this.worker.addEventListener('message', (event) => {
26
+ const { type, id } = event.data as TimerWorkerEvent;
27
+ if (type === 'tick') {
28
+ this.callbacks.get(id)?.();
29
+ }
30
+ });
31
+ } catch (err: any) {
32
+ getLogger(['timer-worker'])('error', err);
33
+ this.fallback = true;
34
+ }
35
+ }
36
+
37
+ destroy(): void {
38
+ this.callbacks.clear();
39
+ this.worker?.terminate();
40
+ this.worker = undefined;
41
+ this.fallback = false;
42
+ }
43
+
44
+ get ready() {
45
+ return this.fallback || Boolean(this.worker);
46
+ }
47
+
48
+ setInterval(callback: () => void, timeout: number): number {
49
+ return this.setTimer('setInterval', callback, timeout);
50
+ }
51
+
52
+ clearInterval(id?: number): void {
53
+ this.clearTimer('clearInterval', id);
54
+ }
55
+
56
+ setTimeout(callback: () => void, timeout: number): number {
57
+ return this.setTimer('setTimeout', callback, timeout);
58
+ }
59
+
60
+ clearTimeout(id?: number): void {
61
+ this.clearTimer('clearTimeout', id);
62
+ }
63
+
64
+ private setTimer(
65
+ type: 'setTimeout' | 'setInterval',
66
+ callback: () => void,
67
+ timeout: number,
68
+ ) {
69
+ if (!this.ready) {
70
+ this.setup();
71
+ }
72
+
73
+ if (this.fallback) {
74
+ return (type === 'setTimeout' ? setTimeout : setInterval)(
75
+ callback,
76
+ timeout,
77
+ ) as unknown as number;
78
+ }
79
+
80
+ const id = this.getTimerId();
81
+
82
+ this.callbacks.set(id, () => {
83
+ callback();
84
+
85
+ // Timeouts are one-off operations, so no need to keep callback reference
86
+ // after timer has fired
87
+ if (type === 'setTimeout') {
88
+ this.callbacks.delete(id);
89
+ }
90
+ });
91
+
92
+ this.sendMessage({ type, id, timeout });
93
+ return id;
94
+ }
95
+
96
+ private clearTimer(type: 'clearTimeout' | 'clearInterval', id?: number) {
97
+ if (!id) {
98
+ return;
99
+ }
100
+
101
+ if (!this.ready) {
102
+ this.setup();
103
+ }
104
+
105
+ if (this.fallback) {
106
+ (type === 'clearTimeout' ? clearTimeout : clearInterval)(id);
107
+ return;
108
+ }
109
+
110
+ this.callbacks.delete(id);
111
+ this.sendMessage({ type, id });
112
+ }
113
+
114
+ private getTimerId() {
115
+ return this.currentTimerId++;
116
+ }
117
+
118
+ private sendMessage(message: TimerWorkerRequest) {
119
+ if (!this.worker) {
120
+ throw new Error("Cannot use timer worker before it's set up");
121
+ }
122
+
123
+ this.worker.postMessage(message);
124
+ }
125
+ }
126
+
127
+ let timerWorkerEnabled = false;
128
+
129
+ export const enableTimerWorker = () => {
130
+ timerWorkerEnabled = true;
131
+ };
132
+
133
+ export const getTimers = lazy(() => {
134
+ const instance = new TimerWorker();
135
+ instance.setup({ useTimerWorker: timerWorkerEnabled });
136
+ return instance;
137
+ });
@@ -0,0 +1,15 @@
1
+ export type TimerWorkerRequest =
2
+ | {
3
+ type: 'setInterval' | 'setTimeout';
4
+ id: number;
5
+ timeout: number;
6
+ }
7
+ | {
8
+ type: 'clearInterval' | 'clearTimeout';
9
+ id: number;
10
+ };
11
+
12
+ export type TimerWorkerEvent = {
13
+ type: 'tick';
14
+ id: number;
15
+ };