@stream-io/feeds-client 0.1.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 (96) hide show
  1. package/@react-bindings/index.ts +2 -0
  2. package/CHANGELOG.md +44 -0
  3. package/LICENSE +219 -0
  4. package/README.md +9 -0
  5. package/dist/@react-bindings/hooks/useComments.d.ts +12 -0
  6. package/dist/@react-bindings/hooks/useStateStore.d.ts +3 -0
  7. package/dist/@react-bindings/index.d.ts +2 -0
  8. package/dist/index-react-bindings.browser.cjs +56 -0
  9. package/dist/index-react-bindings.browser.cjs.map +1 -0
  10. package/dist/index-react-bindings.browser.js +53 -0
  11. package/dist/index-react-bindings.browser.js.map +1 -0
  12. package/dist/index-react-bindings.node.cjs +56 -0
  13. package/dist/index-react-bindings.node.cjs.map +1 -0
  14. package/dist/index-react-bindings.node.js +53 -0
  15. package/dist/index-react-bindings.node.js.map +1 -0
  16. package/dist/index.browser.cjs +5799 -0
  17. package/dist/index.browser.cjs.map +1 -0
  18. package/dist/index.browser.js +5782 -0
  19. package/dist/index.browser.js.map +1 -0
  20. package/dist/index.d.ts +13 -0
  21. package/dist/index.node.cjs +5799 -0
  22. package/dist/index.node.cjs.map +1 -0
  23. package/dist/index.node.js +5782 -0
  24. package/dist/index.node.js.map +1 -0
  25. package/dist/src/Feed.d.ts +109 -0
  26. package/dist/src/FeedsClient.d.ts +63 -0
  27. package/dist/src/ModerationClient.d.ts +3 -0
  28. package/dist/src/common/ActivitySearchSource.d.ts +17 -0
  29. package/dist/src/common/ApiClient.d.ts +20 -0
  30. package/dist/src/common/BaseSearchSource.d.ts +87 -0
  31. package/dist/src/common/ConnectionIdManager.d.ts +11 -0
  32. package/dist/src/common/EventDispatcher.d.ts +11 -0
  33. package/dist/src/common/FeedSearchSource.d.ts +17 -0
  34. package/dist/src/common/Poll.d.ts +34 -0
  35. package/dist/src/common/SearchController.d.ts +41 -0
  36. package/dist/src/common/StateStore.d.ts +124 -0
  37. package/dist/src/common/TokenManager.d.ts +29 -0
  38. package/dist/src/common/UserSearchSource.d.ts +17 -0
  39. package/dist/src/common/gen-imports.d.ts +2 -0
  40. package/dist/src/common/rate-limit.d.ts +2 -0
  41. package/dist/src/common/real-time/StableWSConnection.d.ts +144 -0
  42. package/dist/src/common/real-time/event-models.d.ts +36 -0
  43. package/dist/src/common/types.d.ts +29 -0
  44. package/dist/src/common/utils.d.ts +54 -0
  45. package/dist/src/gen/feeds/FeedApi.d.ts +26 -0
  46. package/dist/src/gen/feeds/FeedsApi.d.ts +237 -0
  47. package/dist/src/gen/model-decoders/decoders.d.ts +3 -0
  48. package/dist/src/gen/model-decoders/event-decoder-mapping.d.ts +6 -0
  49. package/dist/src/gen/models/index.d.ts +3437 -0
  50. package/dist/src/gen/moderation/ModerationApi.d.ts +21 -0
  51. package/dist/src/gen-imports.d.ts +3 -0
  52. package/dist/src/state-updates/activity-reaction-utils.d.ts +10 -0
  53. package/dist/src/state-updates/activity-utils.d.ts +13 -0
  54. package/dist/src/state-updates/bookmark-utils.d.ts +14 -0
  55. package/dist/src/types-internal.d.ts +4 -0
  56. package/dist/src/types.d.ts +13 -0
  57. package/dist/src/utils.d.ts +1 -0
  58. package/dist/tsconfig.tsbuildinfo +1 -0
  59. package/index.ts +13 -0
  60. package/package.json +85 -0
  61. package/src/Feed.ts +1070 -0
  62. package/src/FeedsClient.ts +352 -0
  63. package/src/ModerationClient.ts +3 -0
  64. package/src/common/ActivitySearchSource.ts +46 -0
  65. package/src/common/ApiClient.ts +197 -0
  66. package/src/common/BaseSearchSource.ts +238 -0
  67. package/src/common/ConnectionIdManager.ts +51 -0
  68. package/src/common/EventDispatcher.ts +52 -0
  69. package/src/common/FeedSearchSource.ts +94 -0
  70. package/src/common/Poll.ts +313 -0
  71. package/src/common/SearchController.ts +152 -0
  72. package/src/common/StateStore.ts +314 -0
  73. package/src/common/TokenManager.ts +112 -0
  74. package/src/common/UserSearchSource.ts +93 -0
  75. package/src/common/gen-imports.ts +2 -0
  76. package/src/common/rate-limit.ts +23 -0
  77. package/src/common/real-time/StableWSConnection.ts +761 -0
  78. package/src/common/real-time/event-models.ts +38 -0
  79. package/src/common/types.ts +40 -0
  80. package/src/common/utils.ts +194 -0
  81. package/src/gen/feeds/FeedApi.ts +129 -0
  82. package/src/gen/feeds/FeedsApi.ts +2192 -0
  83. package/src/gen/model-decoders/decoders.ts +1877 -0
  84. package/src/gen/model-decoders/event-decoder-mapping.ts +150 -0
  85. package/src/gen/models/index.ts +5882 -0
  86. package/src/gen/moderation/ModerationApi.ts +270 -0
  87. package/src/gen-imports.ts +3 -0
  88. package/src/state-updates/activity-reaction-utils.test.ts +348 -0
  89. package/src/state-updates/activity-reaction-utils.ts +107 -0
  90. package/src/state-updates/activity-utils.test.ts +257 -0
  91. package/src/state-updates/activity-utils.ts +80 -0
  92. package/src/state-updates/bookmark-utils.test.ts +383 -0
  93. package/src/state-updates/bookmark-utils.ts +157 -0
  94. package/src/types-internal.ts +5 -0
  95. package/src/types.ts +20 -0
  96. package/src/utils.ts +4 -0
@@ -0,0 +1,761 @@
1
+ import {
2
+ addConnectionEventListeners,
3
+ KnownCodes,
4
+ randomId,
5
+ removeConnectionEventListeners,
6
+ retryInterval,
7
+ sleep,
8
+ } from '../utils';
9
+ import type { LogLevel } from '../types';
10
+ import type { UserRequest } from '../../gen/models';
11
+ import { TokenManager } from '../TokenManager';
12
+ import { EventDispatcher } from '../EventDispatcher';
13
+ import { ConnectionIdManager } from '../ConnectionIdManager';
14
+ import { ConnectedEvent } from './event-models';
15
+
16
+ // Type guards to check WebSocket error type
17
+ const isCloseEvent = (
18
+ res: CloseEvent | Event | ErrorEvent,
19
+ ): res is CloseEvent => (res as CloseEvent).code !== undefined;
20
+
21
+ const isErrorEvent = (
22
+ res: CloseEvent | Event | ErrorEvent,
23
+ ): res is ErrorEvent => (res as ErrorEvent).error !== undefined;
24
+
25
+ const isWSError = (error: WSError | any): error is WSError =>
26
+ typeof error.isWSFailure !== 'undefined' ||
27
+ typeof error.code !== 'undefined' ||
28
+ typeof error.StatusCode !== 'undefined';
29
+
30
+ export type WSConfig = {
31
+ baseUrl: string;
32
+ user: UserRequest;
33
+ };
34
+
35
+ type WSError = Error & {
36
+ code?: string | number;
37
+ isWSFailure?: boolean;
38
+ StatusCode?: string | number;
39
+ };
40
+
41
+ /**
42
+ * StableWSConnection - A WS connection that reconnects upon failure.
43
+ * - the browser will sometimes report that you're online or offline
44
+ * - the WS connection can break and fail (there is a 30s health check)
45
+ * - sometimes your WS connection will seem to work while the user is in fact offline
46
+ * - to speed up online/offline detection you can use the window.addEventListener('offline');
47
+ *
48
+ * There are 4 ways in which a connection can become unhealthy:
49
+ * - websocket.onerror is called
50
+ * - websocket.onclose is called
51
+ * - the health check fails and no event is received for ~40 seconds
52
+ * - the browser indicates the connection is now offline
53
+ *
54
+ * There are 2 assumptions we make about the server:
55
+ * - state can be recovered by querying the channel again
56
+ * - if the servers fails to publish a message to the client, the WS connection is destroyed
57
+ */
58
+ export class StableWSConnection {
59
+ // local vars
60
+ connectionID?: string;
61
+ connectionOpen?: Promise<ConnectedEvent>;
62
+ authenticationSent: boolean;
63
+ consecutiveFailures: number;
64
+ pingInterval: number;
65
+ healthCheckTimeoutRef?: NodeJS.Timeout;
66
+ isConnecting: boolean;
67
+ isDisconnected: boolean;
68
+ isHealthy: boolean;
69
+ isResolved?: boolean;
70
+ lastEvent: Date | null;
71
+ connectionCheckTimeout: number;
72
+ connectionCheckTimeoutRef?: NodeJS.Timeout;
73
+ rejectPromise?: (reason?: WSError) => void;
74
+ requestID: string | undefined;
75
+ resolvePromise?: (value: ConnectedEvent) => void;
76
+ totalFailures: number;
77
+ ws?: WebSocket;
78
+ wsID: number;
79
+ private readonly dispatcher = new EventDispatcher();
80
+ private readonly clientId: string;
81
+
82
+ constructor(
83
+ private readonly config: WSConfig,
84
+ private readonly tokenManager: TokenManager,
85
+ private readonly connectionIdManager: ConnectionIdManager,
86
+ private readonly decoders: Array<(event: any) => any> = [],
87
+ ) {
88
+ /** consecutive failures influence the duration of the timeout */
89
+ this.consecutiveFailures = 0;
90
+ /** keep track of the total number of failures */
91
+ this.totalFailures = 0;
92
+ /** We only make 1 attempt to reconnect at the same time.. */
93
+ this.isConnecting = false;
94
+ /** True after the auth payload is sent to the server */
95
+ this.authenticationSent = false;
96
+ /** To avoid reconnect if client is disconnected */
97
+ this.isDisconnected = false;
98
+ /** Boolean that indicates if the connection promise is resolved */
99
+ this.isResolved = false;
100
+ /** Boolean that indicates if we have a working connection to the server */
101
+ this.isHealthy = false;
102
+ /** Incremented when a new WS connection is made */
103
+ this.wsID = 1;
104
+ /** Store the last event time for health checks */
105
+ this.lastEvent = null;
106
+ /** Send a health check message every 25 seconds */
107
+ this.pingInterval = 25 * 1000;
108
+ this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
109
+ this.clientId = `${config.user.id}-${randomId()}`;
110
+
111
+ addConnectionEventListeners(this.onlineStatusChanged);
112
+ }
113
+
114
+ _log = (msg: string, extra: any = {}, level: LogLevel = 'info') => {
115
+ // TODO: fix logging
116
+ console.log(msg, extra, level);
117
+ };
118
+
119
+ on = this.dispatcher.on;
120
+ off = this.dispatcher.off;
121
+ offAll = this.dispatcher.offAll;
122
+
123
+ /**
124
+ * connect - Connect to the WS URL
125
+ * the default 15s timeout allows between 2~3 tries
126
+ * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
127
+ */
128
+ async connect(timeout = 15000) {
129
+ if (this.isConnecting) {
130
+ throw Error(
131
+ `You've called connect twice, can only attempt 1 connection at the time`,
132
+ );
133
+ }
134
+
135
+ this.isDisconnected = false;
136
+
137
+ if (!this.connectionIdManager.loadConnectionIdPromise) {
138
+ this.connectionIdManager.resetConnectionIdPromise();
139
+ }
140
+
141
+ try {
142
+ const healthCheck = await this._connect();
143
+ this.consecutiveFailures = 0;
144
+
145
+ this._log(
146
+ `connect() - Established ws connection with healthcheck`,
147
+ healthCheck,
148
+ );
149
+ } catch (error) {
150
+ this.isHealthy = false;
151
+ this.consecutiveFailures += 1;
152
+
153
+ if (isWSError(error)) {
154
+ if (
155
+ error.code === KnownCodes.TOKEN_EXPIRED &&
156
+ !this.tokenManager.isStatic()
157
+ ) {
158
+ this._log(
159
+ 'connect() - WS failure due to expired token, so going to try to reload token and reconnect',
160
+ );
161
+ void this._reconnect({ refreshToken: true });
162
+ } else {
163
+ if (!error.isWSFailure) {
164
+ // API rejected the connection and we should not retry
165
+ throw new Error(
166
+ JSON.stringify({
167
+ code: error.code,
168
+ StatusCode: error.StatusCode,
169
+ message: error.message,
170
+ isWSFailure: error.isWSFailure,
171
+ }),
172
+ );
173
+ }
174
+ }
175
+ } else {
176
+ throw error;
177
+ }
178
+ }
179
+
180
+ return await this._waitForHealthy(timeout);
181
+ }
182
+
183
+ /**
184
+ * _waitForHealthy polls the promise connection to see if its resolved until it times out
185
+ * the default 15s timeout allows between 2~3 tries
186
+ * @param timeout duration(ms)
187
+ */
188
+ async _waitForHealthy(timeout = 15000) {
189
+ return await Promise.race([
190
+ (async () => {
191
+ const interval = 50; // ms
192
+ for (let i = 0; i <= timeout; i += interval) {
193
+ try {
194
+ return await this.connectionOpen;
195
+ } catch (error: any) {
196
+ if (i === timeout) {
197
+ throw new Error(
198
+ JSON.stringify({
199
+ code: error.code,
200
+ StatusCode: error.StatusCode,
201
+ message: error.message,
202
+ isWSFailure: error.isWSFailure,
203
+ }),
204
+ );
205
+ }
206
+ await sleep(interval);
207
+ }
208
+ }
209
+ })(),
210
+ (async () => {
211
+ await sleep(timeout);
212
+ this.isConnecting = false;
213
+ throw new Error(
214
+ JSON.stringify({
215
+ code: '',
216
+ StatusCode: '',
217
+ message: 'initial WS connection could not be established',
218
+ isWSFailure: true,
219
+ }),
220
+ );
221
+ })(),
222
+ ]);
223
+ }
224
+
225
+ /**
226
+ * disconnect - Disconnect the connection and doesn't recover...
227
+ *
228
+ */
229
+ disconnect(timeout?: number) {
230
+ this._log(
231
+ `disconnect() - Closing the websocket connection for wsID ${this.wsID}`,
232
+ );
233
+
234
+ this.wsID += 1;
235
+ this.isConnecting = false;
236
+ this.isDisconnected = true;
237
+
238
+ // start by removing all the listeners
239
+ if (this.healthCheckTimeoutRef) {
240
+ clearInterval(this.healthCheckTimeoutRef);
241
+ }
242
+ if (this.connectionCheckTimeoutRef) {
243
+ clearInterval(this.connectionCheckTimeoutRef);
244
+ }
245
+
246
+ removeConnectionEventListeners(this.onlineStatusChanged);
247
+
248
+ this.isHealthy = false;
249
+
250
+ // remove ws handlers...
251
+ if (this.ws) {
252
+ this.ws.onclose = () => {};
253
+ this.ws.onerror = () => {};
254
+ this.ws.onmessage = () => {};
255
+ }
256
+
257
+ let isClosedPromise: Promise<void>;
258
+ // and finally close...
259
+ // Assigning to local here because we will remove it from this before the
260
+ // promise resolves.
261
+ const { ws } = this;
262
+ if (ws?.close && ws.readyState === ws.OPEN) {
263
+ isClosedPromise = new Promise((resolve) => {
264
+ const onclose = (event: CloseEvent) => {
265
+ this._log(
266
+ `disconnect() - resolving isClosedPromise ${
267
+ event ? 'with' : 'without'
268
+ } close frame`,
269
+ { event },
270
+ );
271
+ resolve();
272
+ };
273
+
274
+ ws.onclose = onclose;
275
+ // In case we don't receive close frame websocket server in time,
276
+ // lets not wait for more than 1 second.
277
+ setTimeout(onclose, timeout ?? 1000);
278
+ });
279
+
280
+ this._log(
281
+ `disconnect() - Manually closed connection by calling client.disconnect()`,
282
+ );
283
+
284
+ ws.close(
285
+ KnownCodes.WS_CLOSED_SUCCESS,
286
+ 'Manually closed connection by calling client.disconnect()',
287
+ );
288
+ } else {
289
+ this._log(
290
+ `disconnect() - ws connection doesn't exist or it is already closed.`,
291
+ );
292
+ isClosedPromise = Promise.resolve();
293
+ }
294
+
295
+ delete this.ws;
296
+
297
+ return isClosedPromise;
298
+ }
299
+
300
+ /**
301
+ * _connect - Connect to the WS endpoint
302
+ *
303
+ * @return {ConnectAPIResponse<ConnectedEvent>} Promise that completes once the first health check message is received
304
+ */
305
+ async _connect() {
306
+ if (this.isConnecting) return; // simply ignore _connect if it's currently trying to connect
307
+ this.isConnecting = true;
308
+ this.requestID = randomId();
309
+ let isTokenReady = false;
310
+ try {
311
+ this._log(`_connect() - waiting for token`);
312
+ await this.tokenManager.getToken();
313
+ isTokenReady = true;
314
+ } catch (_) {
315
+ // token provider has failed before, so try again
316
+ }
317
+
318
+ try {
319
+ if (!isTokenReady) {
320
+ this._log(
321
+ `_connect() - tokenProvider failed before, so going to retry`,
322
+ );
323
+ await this.tokenManager.loadToken();
324
+ }
325
+
326
+ this._setupConnectionPromise();
327
+ const wsURL = this.config.baseUrl;
328
+ this._log(`_connect() - Connecting to ${wsURL}`, {
329
+ wsURL,
330
+ requestID: this.requestID,
331
+ });
332
+ this.ws = new WebSocket(wsURL);
333
+ this.ws.onopen = this.onopen.bind(this, this.wsID);
334
+ this.ws.onclose = this.onclose.bind(this, this.wsID);
335
+ this.ws.onerror = this.onerror.bind(this, this.wsID);
336
+ this.ws.onmessage = this.onmessage.bind(this, this.wsID);
337
+ const response = await this.connectionOpen;
338
+ this.isConnecting = false;
339
+
340
+ if (response) {
341
+ return response;
342
+ }
343
+ } catch (err) {
344
+ this.isConnecting = false;
345
+ this._log(`_connect() - Error - `, err);
346
+ throw err;
347
+ }
348
+ }
349
+
350
+ /**
351
+ * _reconnect - Retry the connection to WS endpoint
352
+ *
353
+ * @param {{ interval?: number; refreshToken?: boolean }} options Following options are available
354
+ *
355
+ * - `interval` {int} number of ms that function should wait before reconnecting
356
+ * - `refreshToken` {boolean} reload/refresh user token be refreshed before attempting reconnection.
357
+ */
358
+ async _reconnect(
359
+ options: { interval?: number; refreshToken?: boolean } = {},
360
+ ): Promise<void> {
361
+ this._log('_reconnect() - Initiating the reconnect');
362
+
363
+ // only allow 1 connection at the time
364
+ if (this.isConnecting || this.isHealthy) {
365
+ this._log('_reconnect() - Abort (1) since already connecting or healthy');
366
+ return;
367
+ }
368
+
369
+ // reconnect in case of on error or on close
370
+ // also reconnect if the health check cycle fails
371
+ let interval = options.interval;
372
+ if (!interval) {
373
+ interval = retryInterval(this.consecutiveFailures);
374
+ }
375
+ // reconnect, or try again after a little while...
376
+ await sleep(interval);
377
+
378
+ // Check once again if by some other call to _reconnect is active or connection is
379
+ // already restored, then no need to proceed.
380
+ if (this.isConnecting || this.isHealthy) {
381
+ this._log('_reconnect() - Abort (2) since already connecting or healthy');
382
+ return;
383
+ }
384
+
385
+ if (this.isDisconnected) {
386
+ this._log('_reconnect() - Abort (3) since disconnect() is called');
387
+ return;
388
+ }
389
+
390
+ this._log('_reconnect() - Destroying current WS connection');
391
+
392
+ // cleanup the old connection
393
+ this._destroyCurrentWSConnection();
394
+
395
+ if (options.refreshToken) {
396
+ await this.tokenManager.loadToken();
397
+ }
398
+
399
+ try {
400
+ await this._connect();
401
+ this._log('_reconnect() - Waiting for recoverCallBack');
402
+ // await this.client.recoverState();
403
+ this._log('_reconnect() - Finished recoverCallBack');
404
+
405
+ this.consecutiveFailures = 0;
406
+ } catch (error: any) {
407
+ this.isHealthy = false;
408
+ this.consecutiveFailures += 1;
409
+ if (
410
+ error.code === KnownCodes.TOKEN_EXPIRED &&
411
+ !this.tokenManager.isStatic()
412
+ ) {
413
+ this._log(
414
+ '_reconnect() - WS failure due to expired token, so going to try to reload token and reconnect',
415
+ );
416
+
417
+ return await this._reconnect({ refreshToken: true });
418
+ }
419
+
420
+ // reconnect on WS failures, don't reconnect if there is a code bug
421
+ if (error.isWSFailure) {
422
+ this._log('_reconnect() - WS failure, so going to try to reconnect');
423
+
424
+ void this._reconnect();
425
+ }
426
+ }
427
+ this._log('_reconnect() - == END ==');
428
+ }
429
+
430
+ /**
431
+ * onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
432
+ *
433
+ * @param {Event} event Event with type online or offline
434
+ *
435
+ */
436
+ onlineStatusChanged = (event: Event) => {
437
+ if (event.type === 'offline') {
438
+ // mark the connection as down
439
+ this._log('onlineStatusChanged() - Status changing to offline');
440
+ // we know that the app is offline so dispatch the unhealthy connection event immediately
441
+ this._setHealth(false, true);
442
+ } else if (event.type === 'online') {
443
+ // retry right now...
444
+ // We check this.isHealthy, not sure if it's always
445
+ // smart to create a new WS connection if the old one is still up and running.
446
+ // it's possible we didn't miss any messages, so this process is just expensive and not needed.
447
+ this._log(
448
+ `onlineStatusChanged() - Status changing to online. isHealthy: ${this.isHealthy}`,
449
+ );
450
+ if (!this.isHealthy) {
451
+ void this._reconnect({ interval: 10 });
452
+ }
453
+ }
454
+ };
455
+
456
+ onopen = async (wsID: number) => {
457
+ if (this.wsID !== wsID) return;
458
+
459
+ const user = this.config.user;
460
+ if (!user) {
461
+ this._log('error', `User not set, can't connect to WS`);
462
+ return;
463
+ }
464
+
465
+ const token = await this.tokenManager.getToken();
466
+ if (!token) {
467
+ this._log('error', `Token not set, can't connect authenticate`);
468
+ return;
469
+ }
470
+
471
+ const authMessage = {
472
+ token,
473
+ user_details: {
474
+ id: user.id,
475
+ name: user.name,
476
+ image: user.image,
477
+ custom: user.custom,
478
+ },
479
+ products: ['feeds'],
480
+ };
481
+
482
+ this.authenticationSent = true;
483
+ this.ws?.send(JSON.stringify(authMessage));
484
+ this._log('onopen() - onopen callback', { wsID });
485
+ };
486
+
487
+ onmessage = (wsID: number, event: MessageEvent) => {
488
+ if (this.wsID !== wsID) return;
489
+
490
+ this._log('onmessage() - onmessage callback', { event, wsID });
491
+ let data = typeof event.data === 'string' ? JSON.parse(event.data) : null;
492
+ this.decoders.forEach((decode) => {
493
+ data = decode(data);
494
+ });
495
+
496
+ // we wait till the first message before we consider the connection open.
497
+ // the reason for this is that auth errors and similar errors trigger a ws.onopen and immediately
498
+ // after that a ws.onclose.
499
+ if (!this.isResolved && data && data.type === 'connection.error') {
500
+ this.isResolved = true;
501
+ if (data.error) {
502
+ this.rejectPromise?.(this._errorFromWSEvent(data, false));
503
+ return;
504
+ }
505
+ }
506
+
507
+ // trigger the event..
508
+ this.lastEvent = new Date();
509
+
510
+ if (
511
+ data &&
512
+ (data.type === 'health.check' || data.type === 'connection.ok')
513
+ ) {
514
+ // the initial health-check should come from the client
515
+ this.scheduleNextPing();
516
+ }
517
+
518
+ if (data && data.type === 'connection.ok') {
519
+ this.resolvePromise?.(data);
520
+ this.connectionID = (data as ConnectedEvent).connection_id;
521
+ this._setHealth(true);
522
+ }
523
+
524
+ if (data && data.type === 'connection.error' && data.error) {
525
+ const { code } = data.error;
526
+ this.isHealthy = false;
527
+ this.isConnecting = false;
528
+ this.consecutiveFailures += 1;
529
+ if (code === KnownCodes.TOKEN_EXPIRED && !this.tokenManager.isStatic()) {
530
+ clearTimeout(this.connectionCheckTimeoutRef);
531
+ this._log(
532
+ 'connect() - WS failure due to expired token, so going to try to reload token and reconnect',
533
+ );
534
+ void this._reconnect({ refreshToken: true });
535
+ }
536
+ }
537
+
538
+ if (data) {
539
+ data.received_at = new Date();
540
+ this.dispatcher.dispatch(data);
541
+ }
542
+ this.scheduleConnectionCheck();
543
+ };
544
+
545
+ onclose = (wsID: number, event: CloseEvent) => {
546
+ if (this.wsID !== wsID) return;
547
+
548
+ this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
549
+
550
+ if (event.code === KnownCodes.WS_CLOSED_SUCCESS) {
551
+ // this is a permanent error raised by stream..
552
+ // usually caused by invalid auth details
553
+ this.rejectPromise?.(this._errorFromWSEvent(event));
554
+ this._log(`onclose() - WS connection reject with error ${event.reason}`, {
555
+ event,
556
+ });
557
+ } else {
558
+ this.consecutiveFailures += 1;
559
+ this.totalFailures += 1;
560
+ this._setHealth(false);
561
+ this.isConnecting = false;
562
+
563
+ this.rejectPromise?.(this._errorFromWSEvent(event));
564
+
565
+ this._log(`onclose() - WS connection closed. Calling reconnect ...`, {
566
+ event,
567
+ });
568
+
569
+ // reconnect if its an abnormal failure
570
+ void this._reconnect();
571
+ }
572
+ };
573
+
574
+ onerror = (wsID: number, event: Event) => {
575
+ if (this.wsID !== wsID) return;
576
+
577
+ this.consecutiveFailures += 1;
578
+ this.totalFailures += 1;
579
+ this._setHealth(false);
580
+ this.isConnecting = false;
581
+ this.rejectPromise?.(this._errorFromWSEvent(event));
582
+ this._log(`onerror() - WS connection resulted into error`, { event });
583
+
584
+ void this._reconnect();
585
+ };
586
+
587
+ /**
588
+ * _setHealth - Sets the connection to healthy or unhealthy.
589
+ * Broadcasts an event in case the connection status changed.
590
+ *
591
+ * @param {boolean} healthy boolean indicating if the connection is healthy or not
592
+ * @param {boolean} dispatchImmediately boolean indicating to dispatch event immediately even if the connection is unhealthy
593
+ *
594
+ */
595
+ _setHealth = (healthy: boolean, dispatchImmediately = false) => {
596
+ if (healthy === this.isHealthy) return;
597
+
598
+ this.isHealthy = healthy;
599
+
600
+ if (this.isHealthy || dispatchImmediately) {
601
+ this.dispatchConnectionChanged();
602
+ return;
603
+ }
604
+
605
+ // we're offline, wait few seconds and fire and event if still offline
606
+ setTimeout(() => {
607
+ if (this.isHealthy) return;
608
+ this.dispatchConnectionChanged();
609
+ }, 5000);
610
+ };
611
+
612
+ dispatchConnectionChanged = () => {
613
+ if (this.isHealthy) {
614
+ if (this.connectionID) {
615
+ this.connectionIdManager?.resolveConnectionidPromise(this.connectionID);
616
+ } else {
617
+ throw new Error(
618
+ `Stream error: WebSocket connection is healthy, but connection id isn't set`,
619
+ );
620
+ }
621
+ } else {
622
+ if (this.connectionIdManager.loadConnectionIdPromise) {
623
+ this.connectionIdManager.rejectConnectionIdPromise(
624
+ new Error(
625
+ `Stream error: Failed to get WebSocket connection id because WebSocket connection failed`,
626
+ ),
627
+ );
628
+ }
629
+ this.connectionIdManager.reset();
630
+ }
631
+ this.dispatcher.dispatch({
632
+ type: 'connection.changed',
633
+ online: this.isHealthy,
634
+ });
635
+ };
636
+
637
+ /**
638
+ * _errorFromWSEvent - Creates an error object for the WS event
639
+ *
640
+ */
641
+ _errorFromWSEvent = (
642
+ event: CloseEvent | Event | ErrorEvent,
643
+ isWSFailure = true,
644
+ ): WSError => {
645
+ let code;
646
+ let statusCode;
647
+ let message;
648
+ if (isCloseEvent(event)) {
649
+ code = event.code;
650
+ statusCode = 'unknown';
651
+ message = event.reason;
652
+ }
653
+
654
+ if (isErrorEvent(event)) {
655
+ code = event.error.code;
656
+ statusCode = event.error.StatusCode;
657
+ message = event.error.message;
658
+ }
659
+
660
+ // Keeping this `warn` level log, to avoid cluttering of error logs from ws failures.
661
+ this._log(
662
+ `_errorFromWSEvent() - WS failed with code ${code}`,
663
+ { event },
664
+ 'warn',
665
+ );
666
+
667
+ const error = new Error(
668
+ `WS failed with code ${code} and reason - ${message}`,
669
+ ) as Error & {
670
+ code?: string | number;
671
+ isWSFailure?: boolean;
672
+ StatusCode?: string | number;
673
+ };
674
+ error.code = code;
675
+ /**
676
+ * StatusCode does not exist on any event types but has been left
677
+ * as is to preserve JS functionality during the TS implementation
678
+ */
679
+ error.StatusCode = statusCode;
680
+ error.isWSFailure = isWSFailure;
681
+ return error;
682
+ };
683
+
684
+ /**
685
+ * _destroyCurrentWSConnection - Removes the current WS connection
686
+ *
687
+ */
688
+ _destroyCurrentWSConnection() {
689
+ // increment the ID, meaning we will ignore all messages from the old
690
+ // ws connection from now on.
691
+ this.wsID += 1;
692
+
693
+ try {
694
+ if (this.ws) {
695
+ this.ws.onclose = () => {};
696
+ this.ws.onerror = () => {};
697
+ this.ws.onmessage = () => {};
698
+ this.ws.onopen = () => {};
699
+ this.ws.close();
700
+ }
701
+ } catch (_) {
702
+ // we don't care
703
+ }
704
+ }
705
+
706
+ /**
707
+ * _setupPromise - sets up the this.connectOpen promise
708
+ */
709
+ _setupConnectionPromise = () => {
710
+ this.isResolved = false;
711
+ /** a promise that is resolved once ws.open is called */
712
+ this.connectionOpen = new Promise<ConnectedEvent>((resolve, reject) => {
713
+ this.resolvePromise = resolve;
714
+ this.rejectPromise = reject;
715
+ });
716
+ };
717
+
718
+ /**
719
+ * Schedules a next health check ping for websocket.
720
+ */
721
+ scheduleNextPing = () => {
722
+ if (this.healthCheckTimeoutRef) {
723
+ clearTimeout(this.healthCheckTimeoutRef);
724
+ }
725
+
726
+ // 30 seconds is the recommended interval (messenger uses this)
727
+ this.healthCheckTimeoutRef = setTimeout(() => {
728
+ // send the healthcheck..., server replies with a health check event
729
+ const data = [{ type: 'health.check', client_id: this.clientId }];
730
+ // try to send on the connection
731
+ try {
732
+ this.ws?.send(JSON.stringify(data));
733
+ } catch (_) {
734
+ // error will already be detected elsewhere
735
+ }
736
+ }, this.pingInterval);
737
+ };
738
+
739
+ /**
740
+ * scheduleConnectionCheck - schedules a check for time difference between last received event and now.
741
+ * If the difference is more than 35 seconds, it means our health check logic has failed and websocket needs
742
+ * to be reconnected.
743
+ */
744
+ scheduleConnectionCheck = () => {
745
+ if (this.connectionCheckTimeoutRef) {
746
+ clearTimeout(this.connectionCheckTimeoutRef);
747
+ }
748
+
749
+ this.connectionCheckTimeoutRef = setTimeout(() => {
750
+ const now = new Date();
751
+ if (
752
+ this.lastEvent &&
753
+ now.getTime() - this.lastEvent.getTime() > this.connectionCheckTimeout
754
+ ) {
755
+ this._log('scheduleConnectionCheck - going to reconnect');
756
+ this._setHealth(false);
757
+ void this._reconnect();
758
+ }
759
+ }, this.connectionCheckTimeout);
760
+ };
761
+ }