From c9d4f52ace65c3cf779e959b4644da53c7524007 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 22 Nov 2021 15:10:16 +0100 Subject: [PATCH 01/49] set received_at in dispatchEvent --- src/client.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index a9bc471dc..81b90ad82 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1234,6 +1234,8 @@ export class StreamChat< UserType >, ) => { + event.received_at = new Date(); + // client event handlers const postListenerCallbacks = this._handleClientEvent(event); @@ -1265,7 +1267,6 @@ export class StreamChat< ReactionType, UserType >; - event.received_at = new Date(); this.dispatchEvent(event); }; From b54d3293ba40e675a67fd348d1ccf9630b1c6f16 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 22 Nov 2021 15:12:11 +0100 Subject: [PATCH 02/49] break _buildUrl --- src/connection.ts | 23 ++++++++++++++++------- src/insights.ts | 2 +- test/unit/connection.js | 4 ++-- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index db04e4607..c1dc65c2c 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -259,20 +259,29 @@ export class StableWSConnection< } /** - * Builds and returns the url for websocket. - * @param reqID Unique identifier generated on client side, to help tracking apis on backend. - * @returns url string + * encode ws url payload + * @private + * @returns json string */ - _buildUrl = (reqID?: string) => { + _buildUrlPayload = () => { const params = { user_id: this.user.id, user_details: this.user, user_token: this.tokenManager.getToken(), server_determines_connection_id: true, device: this.device, - client_request_id: reqID, + client_request_id: this.requestID, }; - const qs = encodeURIComponent(JSON.stringify(params)); + return encodeURIComponent(JSON.stringify(params)); + }; + + /** + * Builds and returns the url for websocket. + * @private + * @returns url string + */ + _buildUrl = () => { + const qs = this._buildUrlPayload(); const token = this.tokenManager.getToken(); return `${this.wsBaseURL}/connect?json=${qs}&api_key=${this.apiKey}&authorization=${token}&stream-auth-type=${this.authType}&X-Stream-Client=${this.userAgent}`; }; @@ -377,7 +386,7 @@ export class StableWSConnection< try { await this.tokenManager.tokenReady(); this._setupConnectionPromise(); - const wsURL = this._buildUrl(this.requestID); + const wsURL = this._buildUrl(); this.ws = new WebSocket(wsURL); this.ws.onopen = this.onopen.bind(this, this.wsID); this.ws.onclose = this.onclose.bind(this, this.wsID); diff --git a/src/insights.ts b/src/insights.ts index e56b8f864..60d30c7aa 100644 --- a/src/insights.ts +++ b/src/insights.ts @@ -56,7 +56,7 @@ export function buildWsFatalInsight( function buildWsBaseInsight(connection: StableWSConnection) { return { ready_state: connection.ws?.readyState, - url: connection._buildUrl(connection.requestID), + url: connection._buildUrl(), api_key: connection.apiKey, start_ts: connection.insightMetrics.connectionStartTimestamp, end_ts: new Date().getTime(), diff --git a/test/unit/connection.js b/test/unit/connection.js index bee4529e8..810a752db 100644 --- a/test/unit/connection.js +++ b/test/unit/connection.js @@ -52,7 +52,7 @@ describe('connection', function () { }); it('should create the correct url', function () { - const { host, pathname, query } = url.parse(ws._buildUrl('random'), true); + const { host, pathname, query } = url.parse(ws._buildUrl(), true); expect(host).to.be.eq('url.com'); expect(pathname).to.be.eq('/connect'); @@ -70,7 +70,7 @@ describe('connection', function () { it('should not include device if not there', function () { ws.device = undefined; - const { query } = url.parse(ws._buildUrl('random'), true); + const { query } = url.parse(ws._buildUrl(), true); const data = JSON.parse(query.json); expect(data.device).to.deep.undefined; }); From ba9d5ec7dd51bcc94b9a14c45448bcb8c5b4cf85 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 22 Nov 2021 17:26:09 +0100 Subject: [PATCH 03/49] first draft of ws poll --- src/client.ts | 3 ++ src/connection.ts | 40 ++++++++++++++---- src/connection_fallback.ts | 84 ++++++++++++++++++++++++++++++++++++++ src/types.ts | 1 + 4 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 src/connection_fallback.ts diff --git a/src/client.ts b/src/client.ts index 81b90ad82..60c6c696c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1627,8 +1627,11 @@ export class StreamChat< // The StableWSConnection handles all the reconnection logic. this.wsConnection = new StableWSConnection({ + // @ts-expect-error + client: this, // TODO: fix generics after refactor wsBaseURL: client.wsBaseURL, enableInsights: this.options.enableInsights, + enableWSFallback: this.options.enableWSFallback, clientID: client.clientID, userID: client.userID, tokenManager: client.tokenManager, diff --git a/src/connection.ts b/src/connection.ts index c1dc65c2c..01cd92737 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -17,6 +17,8 @@ import { UnknownType, UserResponse, } from './types'; +import { StreamChat } from './client'; +import { WSConnectionFallback } from './connection_fallback'; // Type guards to check WebSocket error type const isCloseEvent = ( @@ -34,6 +36,7 @@ type Constructor< > = { apiKey: string; authType: 'anonymous' | 'jwt'; + client: StreamChat; clientID: string; eventCallback: (event: ConnectionChangeEvent) => void; insightMetrics: InsightMetrics; @@ -49,6 +52,7 @@ type Constructor< wsBaseURL: string; device?: BaseDeviceFields; enableInsights?: boolean; + enableWSFallback?: boolean; }; /** @@ -112,9 +116,12 @@ export class StableWSConnection< wsID: number; enableInsights?: boolean; insightMetrics: InsightMetrics; + fallback?: WSConnectionFallback; + constructor({ apiKey, authType, + client, clientID, eventCallback, logger, @@ -127,6 +134,7 @@ export class StableWSConnection< wsBaseURL, device, enableInsights, + enableWSFallback, insightMetrics, }: Constructor) { this.wsBaseURL = wsBaseURL; @@ -163,6 +171,10 @@ export class StableWSConnection< this._listenForConnectionChanges(); this.enableInsights = enableInsights; this.insightMetrics = insightMetrics; + + if (enableWSFallback) { + this.fallback = new WSConnectionFallback({ client }); + } } /** @@ -272,7 +284,7 @@ export class StableWSConnection< device: this.device, client_request_id: this.requestID, }; - return encodeURIComponent(JSON.stringify(params)); + return JSON.stringify(params); }; /** @@ -281,7 +293,7 @@ export class StableWSConnection< * @returns url string */ _buildUrl = () => { - const qs = this._buildUrlPayload(); + const qs = encodeURIComponent(this._buildUrlPayload()); const token = this.tokenManager.getToken(); return `${this.wsBaseURL}/connect?json=${qs}&api_key=${this.apiKey}&authorization=${token}&stream-auth-type=${this.authType}&X-Stream-Client=${this.userAgent}`; }; @@ -368,6 +380,10 @@ export class StableWSConnection< isClosedPromise = Promise.resolve(); } + if (this.fallback) { + this.fallback.disconnect(); + } + delete this.ws; return isClosedPromise; @@ -385,13 +401,19 @@ export class StableWSConnection< this.insightMetrics.connectionStartTimestamp = new Date().getTime(); try { await this.tokenManager.tokenReady(); - this._setupConnectionPromise(); - const wsURL = this._buildUrl(); - this.ws = new WebSocket(wsURL); - this.ws.onopen = this.onopen.bind(this, this.wsID); - this.ws.onclose = this.onclose.bind(this, this.wsID); - this.ws.onerror = this.onerror.bind(this, this.wsID); - this.ws.onmessage = this.onmessage.bind(this, this.wsID); + if (this.fallback) { + // TODO: temporary for testing + this.connectionOpen = this.fallback.connect(this._buildUrlPayload()); + } else { + this._setupConnectionPromise(); + const wsURL = this._buildUrl(); + this.ws = new WebSocket(wsURL); + this.ws.onopen = this.onopen.bind(this, this.wsID); + this.ws.onclose = this.onclose.bind(this, this.wsID); + this.ws.onerror = this.onerror.bind(this, this.wsID); + this.ws.onmessage = this.onmessage.bind(this, this.wsID); + } + const response = await this.connectionOpen; this.isConnecting = false; diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts new file mode 100644 index 000000000..5a34b75f6 --- /dev/null +++ b/src/connection_fallback.ts @@ -0,0 +1,84 @@ +import axios, { AxiosRequestConfig, Canceler } from 'axios'; +import { StreamChat } from './client'; +import { ConnectionOpen, Event, UnknownType } from './types'; + +export class WSConnectionFallback { + client: StreamChat; + connecting: boolean; + connected: boolean; + cancel?: Canceler; + + constructor({ client }: { client: StreamChat }) { + this.client = client; + this.connecting = false; + this.connected = false; + } + + _newCancelToken = () => new axios.CancelToken((cancel) => (this.cancel = cancel)); + + _req(params: UnknownType, options: AxiosRequestConfig) { + return this.client.doAxiosRequest( + 'get', + this.client.baseURL + '/longpoll', + undefined, + { + config: options, + cancelToken: this._newCancelToken(), + params, + }, + ); + } + + _poll = async (json: string, connection_id: string) => { + while (this.connected) { + try { + const data = await this._req<{ events: Event[] }>( + { json, connection_id }, // TODO: remove json + { timeout: 30 * 1000 }, // 30s + ); + + if (data?.events?.length) { + for (let i = 0; i < data.events.length; i++) { + this.client.dispatchEvent(data.events[i]); + } + } + } catch (err) { + if (axios.isCancel(err)) { + return; + } + console.error(err); + // TODO: handle consequent failures + //TODO: check for error.code 46 and reset the client, for random failures fallback to loop + } + } + }; + + connect = async (json: string) => { + if (this.connecting) { + throw new Error('connection already in progress'); + } + + this.connecting = true; + + try { + const { event } = await this._req<{ event: ConnectionOpen }>( + { json }, + { timeout: 10 * 1000 }, // 10s + ); + this.connecting = false; + this.connected = true; + this._poll(json, event.connection_id).then(); + return event; + } catch (err) { + this.connecting = false; + return err; + } + }; + + disconnect = () => { + this.connected = false; + if (this.cancel) { + this.cancel('client.disconnect() is called'); + } + }; +} diff --git a/src/types.ts b/src/types.ts index cc96cdad0..3e628b1d2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -998,6 +998,7 @@ export type StreamChatOptions = AxiosRequestConfig & { browser?: boolean; device?: BaseDeviceFields; enableInsights?: boolean; + enableWSFallback?: boolean; logger?: Logger; /** * When network is recovered, we re-query the active channels on client. But in single query, you can recover From e935fb79fcbae1e99bd4b15c33c169a0255034a8 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 22 Nov 2021 17:37:35 +0100 Subject: [PATCH 04/49] fix tests --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index 60c6c696c..fad2f7dcc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1234,7 +1234,7 @@ export class StreamChat< UserType >, ) => { - event.received_at = new Date(); + if (!event.received_at) event.received_at = new Date(); // client event handlers const postListenerCallbacks = this._handleClientEvent(event); From 50a56d6b10da50677aba2cdfd0babce216c3a49e Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 24 Nov 2021 11:05:35 +0100 Subject: [PATCH 05/49] refactor connection state --- src/connection_fallback.ts | 51 ++++++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 5a34b75f6..03df598e3 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -2,39 +2,45 @@ import axios, { AxiosRequestConfig, Canceler } from 'axios'; import { StreamChat } from './client'; import { ConnectionOpen, Event, UnknownType } from './types'; +enum ConnectionState { + Closed = 'CLOSED', + Connected = 'CONNECTED', + Connecting = 'CONNECTING', + Disconnectted = 'DISCONNECTTED', + Init = 'INIT', +} + export class WSConnectionFallback { client: StreamChat; - connecting: boolean; - connected: boolean; + state: ConnectionState; cancel?: Canceler; constructor({ client }: { client: StreamChat }) { this.client = client; - this.connecting = false; - this.connected = false; + this.state = ConnectionState.Init; } _newCancelToken = () => new axios.CancelToken((cancel) => (this.cancel = cancel)); - _req(params: UnknownType, options: AxiosRequestConfig) { + _req(params: UnknownType, config: AxiosRequestConfig) { return this.client.doAxiosRequest( 'get', this.client.baseURL + '/longpoll', undefined, { - config: options, cancelToken: this._newCancelToken(), + config, params, }, ); } - _poll = async (json: string, connection_id: string) => { - while (this.connected) { + _poll = async (connection_id: string) => { + while (this.state === ConnectionState.Connected) { try { const data = await this._req<{ events: Event[] }>( - { json, connection_id }, // TODO: remove json - { timeout: 30 * 1000 }, // 30s + { connection_id }, + { timeout: 30000 }, // 30s ); if (data?.events?.length) { @@ -53,30 +59,33 @@ export class WSConnectionFallback { } }; - connect = async (json: string) => { - if (this.connecting) { - throw new Error('connection already in progress'); + connect = async (jsonPayload: string) => { + if (this.state === ConnectionState.Connecting) { + throw new Error('connecting already in progress'); + } + if (this.state === ConnectionState.Connected) { + throw new Error('already connected and polling'); } - this.connecting = true; + this.state = ConnectionState.Connecting; try { const { event } = await this._req<{ event: ConnectionOpen }>( - { json }, - { timeout: 10 * 1000 }, // 10s + { json: jsonPayload }, + { timeout: 10000 }, // 10s ); - this.connecting = false; - this.connected = true; - this._poll(json, event.connection_id).then(); + + this.state = ConnectionState.Connected; + this._poll(event.connection_id).then(); return event; } catch (err) { - this.connecting = false; + this.state = ConnectionState.Closed; return err; } }; disconnect = () => { - this.connected = false; + this.state = ConnectionState.Disconnectted; if (this.cancel) { this.cancel('client.disconnect() is called'); } From 4099febdf18b0095f57602ffc10fd614da01cffd Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 24 Nov 2021 11:05:55 +0100 Subject: [PATCH 06/49] APIErrorCodes --- src/errors.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/errors.ts diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 000000000..a67ff63db --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,28 @@ +export const APIErrorCodes = { + '-1': { name: 'InternalSystemError', retryable: true }, + '2': { name: 'AccessKeyError', retryable: false }, + '3': { name: 'AuthenticationFailedError', retryable: true }, + '4': { name: 'InputError', retryable: false }, + '6': { name: 'DuplicateUsernameError', retryable: false }, + '9': { name: 'RateLimitError', retryable: true }, + '16': { name: 'DoesNotExistError', retryable: false }, + '17': { name: 'NotAllowedError', retryable: false }, + '18': { name: 'EventNotSupportedError', retryable: false }, + '19': { name: 'ChannelFeatureNotSupportedError', retryable: false }, + '20': { name: 'MessageTooLongError', retryable: false }, + '21': { name: 'MultipleNestingLevelError', retryable: false }, + '22': { name: 'PayloadTooBigError', retryable: false }, + '23': { name: 'RequestTimeoutError', retryable: true }, + '24': { name: 'MaxHeaderSizeExceededError', retryable: false }, + '40': { name: 'AuthError', retryable: false }, + '41': { name: 'AuthErrorTokenNotValidYet', retryable: false }, + '42': { name: 'AuthErrorTokenExpired', retryable: false }, + '43': { name: 'AuthErrorTokenSignatureInvalid', retryable: false }, + '44': { name: 'CustomCommandEndpointMissingError', retryable: false }, + '45': { name: 'CustomCommandEndpointCallError', retryable: true }, + '60': { name: 'CoolDownError', retryable: true }, + '69': { name: 'ErrWrongRegion', retryable: false }, + '70': { name: 'ErrQueryChannelPermissions', retryable: false }, + '71': { name: 'ErrTooManyConnections', retryable: true }, + '99': { name: 'AppSuspendedError', retryable: false }, +}; From a32ac248d7ad948cbbfaeec6c45b185979dab309 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 24 Nov 2021 14:44:21 +0100 Subject: [PATCH 07/49] remove temp fallback --- src/connection.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index 01cd92737..b60d3a3e2 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -401,18 +401,14 @@ export class StableWSConnection< this.insightMetrics.connectionStartTimestamp = new Date().getTime(); try { await this.tokenManager.tokenReady(); - if (this.fallback) { - // TODO: temporary for testing - this.connectionOpen = this.fallback.connect(this._buildUrlPayload()); - } else { - this._setupConnectionPromise(); - const wsURL = this._buildUrl(); - this.ws = new WebSocket(wsURL); - this.ws.onopen = this.onopen.bind(this, this.wsID); - this.ws.onclose = this.onclose.bind(this, this.wsID); - this.ws.onerror = this.onerror.bind(this, this.wsID); - this.ws.onmessage = this.onmessage.bind(this, this.wsID); - } + + this._setupConnectionPromise(); + const wsURL = this._buildUrl(); + this.ws = new WebSocket(wsURL); + this.ws.onopen = this.onopen.bind(this, this.wsID); + this.ws.onclose = this.onclose.bind(this, this.wsID); + this.ws.onerror = this.onerror.bind(this, this.wsID); + this.ws.onmessage = this.onmessage.bind(this, this.wsID); const response = await this.connectionOpen; this.isConnecting = false; From 59449c996cbe132b21437ee4e2e113364e703a41 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Fri, 26 Nov 2021 17:49:38 +0100 Subject: [PATCH 08/49] fix wrong err code --- src/errors.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index a67ff63db..1a27906ea 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -14,9 +14,9 @@ export const APIErrorCodes = { '22': { name: 'PayloadTooBigError', retryable: false }, '23': { name: 'RequestTimeoutError', retryable: true }, '24': { name: 'MaxHeaderSizeExceededError', retryable: false }, - '40': { name: 'AuthError', retryable: false }, + '40': { name: 'AuthErrorTokenExpired', retryable: false }, '41': { name: 'AuthErrorTokenNotValidYet', retryable: false }, - '42': { name: 'AuthErrorTokenExpired', retryable: false }, + '42': { name: 'AuthErrorTokenUsedBeforeIssuedAt', retryable: false }, '43': { name: 'AuthErrorTokenSignatureInvalid', retryable: false }, '44': { name: 'CustomCommandEndpointMissingError', retryable: false }, '45': { name: 'CustomCommandEndpointCallError', retryable: true }, From 8b2b539eb9af6101013bf557c0a3cd37a337ab7e Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Fri, 26 Nov 2021 17:51:21 +0100 Subject: [PATCH 09/49] add ConnectionIDNotFoundError --- src/errors.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/errors.ts b/src/errors.ts index 1a27906ea..b63377f90 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -20,6 +20,7 @@ export const APIErrorCodes = { '43': { name: 'AuthErrorTokenSignatureInvalid', retryable: false }, '44': { name: 'CustomCommandEndpointMissingError', retryable: false }, '45': { name: 'CustomCommandEndpointCallError', retryable: true }, + '46': { name: 'ConnectionIDNotFoundError', retryable: false }, '60': { name: 'CoolDownError', retryable: true }, '69': { name: 'ErrWrongRegion', retryable: false }, '70': { name: 'ErrQueryChannelPermissions', retryable: false }, From 6a10060c5a622d06302ae58af7bac2142d31ad29 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Fri, 26 Nov 2021 18:14:51 +0100 Subject: [PATCH 10/49] _setConnectionID --- .eslintrc.json | 2 +- src/connection.ts | 5 ++--- src/connection_fallback.ts | 15 ++++++++++++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.eslintrc.json b/.eslintrc.json index 0a4ef3aae..ca1e893e3 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,7 +22,7 @@ "babel/no-invalid-this": 2, "array-callback-return": 2, "valid-typeof": 2, - "arrow-body-style": 2, + "react/prop-types": 0, "no-var": 2, "linebreak-style": [2, "unix"], diff --git a/src/connection.ts b/src/connection.ts index 6726b357d..ae59c9b21 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -185,15 +185,14 @@ export class StableWSConnection< * @returns json string */ _buildUrlPayload = () => { - const params = { + return JSON.stringify({ user_id: this.client.userID, user_details: this.client._user, user_token: this.client.tokenManager.getToken(), server_determines_connection_id: true, device: this.client.options.device, client_request_id: this.requestID, - }; - return JSON.stringify(params); + }); }; /** diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 250931f7e..2bacb9e10 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -20,8 +20,12 @@ export class WSConnectionFallback { this.state = ConnectionState.Init; } - _newCancelToken = () => new axios.CancelToken((cancel) => (this.cancel = cancel)); + /** @private */ + _newCancelToken = () => { + return new axios.CancelToken((cancel) => (this.cancel = cancel)); + }; + /** @private */ _req(params: UnknownType, config: AxiosRequestConfig) { return this.client.doAxiosRequest('get', this.client.baseURL + '/longpoll', undefined, { cancelToken: this._newCancelToken(), @@ -30,6 +34,14 @@ export class WSConnectionFallback { }); } + /** @private */ + _setConnectionID = (id: string) => { + if (this.client.wsConnection) { + this.client.wsConnection.connectionID = id; + } + }; + + /** @private */ _poll = async (connection_id: string) => { while (this.state === ConnectionState.Connected) { try { @@ -71,6 +83,7 @@ export class WSConnectionFallback { ); this.state = ConnectionState.Connected; + this._setConnectionID(event.connection_id); this._poll(event.connection_id).then(); return event; } catch (err) { From 7a5c799db144da0d474de32c414a3e5733aaa998 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Fri, 26 Nov 2021 18:29:31 +0100 Subject: [PATCH 11/49] reset longpoll on CONNECTION_ID_ERROR --- src/client.ts | 1 + src/connection_fallback.ts | 22 ++++++++++++++++------ src/utils.ts | 1 + 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index f008afdb8..1cf2fa2db 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1021,6 +1021,7 @@ export class StreamChat< this._logApiError(type, url, e); this.consecutiveFailures += 1; if (e.response) { + /** connection_fallback depends on this token expiration logic */ if (e.response.data.code === chatCodes.TOKEN_EXPIRED && !this.tokenManager.isStatic()) { if (this.consecutiveFailures > 1) { await sleep(retryInterval(this.consecutiveFailures)); diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 2bacb9e10..10e6fea7e 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -1,4 +1,6 @@ import axios, { AxiosRequestConfig, Canceler } from 'axios'; +import { StableWSConnection } from './connection'; +import { chatCodes } from './utils'; import { StreamChat } from './client'; import { ConnectionOpen, Event, UnknownType } from './types'; @@ -12,11 +14,15 @@ enum ConnectionState { export class WSConnectionFallback { client: StreamChat; + wsConnection: StableWSConnection; state: ConnectionState; cancel?: Canceler; constructor({ client }: { client: StreamChat }) { + if (!client.wsConnection) throw new Error('missing wsConnection, this class depends on client.wsConnection'); + this.client = client; + this.wsConnection = client.wsConnection; this.state = ConnectionState.Init; } @@ -59,14 +65,18 @@ export class WSConnectionFallback { if (axios.isCancel(err)) { return; } - console.error(err); - // TODO: handle consequent failures - //TODO: check for error.code 46 and reset the client, for random failures fallback to loop + + /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ + if (err.code === chatCodes.CONNECTION_ID_ERROR) { + this.state = ConnectionState.Disconnectted; + this.connect(); + return; + } } } }; - connect = async (jsonPayload: string) => { + connect = async () => { if (this.state === ConnectionState.Connecting) { throw new Error('connecting already in progress'); } @@ -75,10 +85,10 @@ export class WSConnectionFallback { } this.state = ConnectionState.Connecting; - + const payload = this.wsConnection._buildUrlPayload(); try { const { event } = await this._req<{ event: ConnectionOpen }>( - { json: jsonPayload }, + { json: payload }, { timeout: 10000 }, // 10s ); diff --git a/src/utils.ts b/src/utils.ts index 088a4530e..5eadc3539 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -28,6 +28,7 @@ export function isFunction(value: Function | T): value is Function { export const chatCodes = { TOKEN_EXPIRED: 40, + CONNECTION_ID_ERROR: 46, WS_CLOSED_SUCCESS: 1000, }; From eb1cb8694cd3be43fa5c73645fb810795169cd09 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 08:59:52 +0100 Subject: [PATCH 12/49] rm space --- .eslintrc.json | 1 - 1 file changed, 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index ca1e893e3..a0db29408 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -22,7 +22,6 @@ "babel/no-invalid-this": 2, "array-callback-return": 2, "valid-typeof": 2, - "react/prop-types": 0, "no-var": 2, "linebreak-style": [2, "unix"], From dc2b0a725f42ff9ff1ee92a409385ce67e4eef86 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 09:32:33 +0100 Subject: [PATCH 13/49] rate limit --- src/connection_fallback.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 10e6fea7e..94f085d02 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -1,6 +1,6 @@ import axios, { AxiosRequestConfig, Canceler } from 'axios'; import { StableWSConnection } from './connection'; -import { chatCodes } from './utils'; +import { chatCodes, retryInterval, sleep } from './utils'; import { StreamChat } from './client'; import { ConnectionOpen, Event, UnknownType } from './types'; @@ -14,16 +14,14 @@ enum ConnectionState { export class WSConnectionFallback { client: StreamChat; - wsConnection: StableWSConnection; state: ConnectionState; + consecutiveFailures: number; cancel?: Canceler; constructor({ client }: { client: StreamChat }) { - if (!client.wsConnection) throw new Error('missing wsConnection, this class depends on client.wsConnection'); - this.client = client; - this.wsConnection = client.wsConnection; this.state = ConnectionState.Init; + this.consecutiveFailures = 0; } /** @private */ @@ -32,13 +30,13 @@ export class WSConnectionFallback { }; /** @private */ - _req(params: UnknownType, config: AxiosRequestConfig) { + _req = (params: UnknownType, config: AxiosRequestConfig) => { return this.client.doAxiosRequest('get', this.client.baseURL + '/longpoll', undefined, { cancelToken: this._newCancelToken(), config, params, }); - } + }; /** @private */ _setConnectionID = (id: string) => { @@ -49,6 +47,8 @@ export class WSConnectionFallback { /** @private */ _poll = async (connection_id: string) => { + this.consecutiveFailures = 0; + while (this.state === ConnectionState.Connected) { try { const data = await this._req<{ events: Event[] }>( @@ -72,6 +72,9 @@ export class WSConnectionFallback { this.connect(); return; } + + this.consecutiveFailures += 1; + await sleep(retryInterval(this.consecutiveFailures)); } } }; @@ -85,7 +88,7 @@ export class WSConnectionFallback { } this.state = ConnectionState.Connecting; - const payload = this.wsConnection._buildUrlPayload(); + const payload = (this.client.wsConnection as StableWSConnection)._buildUrlPayload(); try { const { event } = await this._req<{ event: ConnectionOpen }>( { json: payload }, From 7df789f2202857afabd5598eead11e9f8ddea2c3 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 10:47:08 +0100 Subject: [PATCH 14/49] refactor _buildWSPayload --- src/client.ts | 16 ++++++++++++++++ src/connection.ts | 18 +----------------- src/connection_fallback.ts | 4 +--- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/client.ts b/src/client.ts index 1cf2fa2db..61625f44c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2581,6 +2581,22 @@ export class StreamChat< }, 500); } + /** + * encode ws url payload + * @private + * @returns json string + */ + _buildWSPayload = (client_request_id?: string) => { + return JSON.stringify({ + user_id: this.userID, + user_details: this._user, + user_token: this.tokenManager.getToken(), + server_determines_connection_id: true, + device: this.options.device, + client_request_id, + }); + }; + verifyWebhook(requestBody: string, xSignature: string) { return !!this.secret && CheckSignature(requestBody, this.secret, xSignature); } diff --git a/src/connection.ts b/src/connection.ts index ae59c9b21..5f06720e6 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -179,29 +179,13 @@ export class StableWSConnection< ]); } - /** - * encode ws url payload - * @private - * @returns json string - */ - _buildUrlPayload = () => { - return JSON.stringify({ - user_id: this.client.userID, - user_details: this.client._user, - user_token: this.client.tokenManager.getToken(), - server_determines_connection_id: true, - device: this.client.options.device, - client_request_id: this.requestID, - }); - }; - /** * Builds and returns the url for websocket. * @private * @returns url string */ _buildUrl = () => { - const qs = encodeURIComponent(this._buildUrlPayload()); + const qs = encodeURIComponent(this.client._buildWSPayload(this.requestID)); const token = this.client.tokenManager.getToken(); return `${this.client.wsBaseURL}/connect?json=${qs}&api_key=${ diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 94f085d02..3630eeb59 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -1,5 +1,4 @@ import axios, { AxiosRequestConfig, Canceler } from 'axios'; -import { StableWSConnection } from './connection'; import { chatCodes, retryInterval, sleep } from './utils'; import { StreamChat } from './client'; import { ConnectionOpen, Event, UnknownType } from './types'; @@ -88,10 +87,9 @@ export class WSConnectionFallback { } this.state = ConnectionState.Connecting; - const payload = (this.client.wsConnection as StableWSConnection)._buildUrlPayload(); try { const { event } = await this._req<{ event: ConnectionOpen }>( - { json: payload }, + { json: this.client._buildWSPayload() }, { timeout: 10000 }, // 10s ); From 39260569a51fd0f6740b00ce1d2d84c7f8ceffe1 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 10:56:21 +0100 Subject: [PATCH 15/49] _getConnectionID --- src/client.ts | 19 +++++++++---------- src/connection_fallback.ts | 16 +++++----------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/client.ts b/src/client.ts index 61625f44c..da7d655dd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -118,6 +118,7 @@ import { TaskResponse, } from './types'; import { InsightMetrics, postInsights } from './insights'; +import { WSConnectionFallback } from 'connection_fallback'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; @@ -145,7 +146,6 @@ export class StreamChat< cleaningIntervalRef?: NodeJS.Timeout; clientID?: string; configs: Configs; - connectionID?: string; key: string; listeners: { [key: string]: Array< @@ -184,6 +184,7 @@ export class StreamChat< MessageType, ReactionType > | null; + wsFallback?: WSConnectionFallback; wsPromise: ConnectAPIResponse | null; consecutiveFailures: number; insightMetrics: InsightMetrics; @@ -432,7 +433,9 @@ export class StreamChat< this.wsBaseURL = this.baseURL.replace('http', 'ws').replace(':3030', ':8800'); } - _hasConnectionID = () => Boolean(this.wsConnection?.connectionID); + _getConnectionID = () => this.wsConnection?.connectionID || this.wsFallback?.connectionID; + + _hasConnectionID = () => Boolean(this._getConnectionID()); /** * connectUser - Set the current user and open a WebSocket connection @@ -1370,13 +1373,9 @@ export class StreamChat< }; recoverState = async () => { - this.logger( - 'info', - `client:recoverState() - Start of recoverState with connectionID ${this.wsConnection?.connectionID}`, - { - tags: ['connection'], - }, - ); + this.logger('info', `client:recoverState() - Start of recoverState with connectionID ${this._getConnectionID()}`, { + tags: ['connection'], + }); const cids = Object.keys(this.activeChannels); if (cids.length && this.recoverStateOnReconnect) { @@ -2550,7 +2549,7 @@ export class StreamChat< user_id: this.userID, ...options.params, api_key: this.key, - connection_id: this.wsConnection?.connectionID, + connection_id: this._getConnectionID(), }, headers: { Authorization: token, diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 3630eeb59..ecae202d7 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -15,6 +15,7 @@ export class WSConnectionFallback { client: StreamChat; state: ConnectionState; consecutiveFailures: number; + connectionID?: string; cancel?: Canceler; constructor({ client }: { client: StreamChat }) { @@ -38,20 +39,13 @@ export class WSConnectionFallback { }; /** @private */ - _setConnectionID = (id: string) => { - if (this.client.wsConnection) { - this.client.wsConnection.connectionID = id; - } - }; - - /** @private */ - _poll = async (connection_id: string) => { + _poll = async () => { this.consecutiveFailures = 0; while (this.state === ConnectionState.Connected) { try { const data = await this._req<{ events: Event[] }>( - { connection_id }, + { connection_id: this.connectionID }, { timeout: 30000 }, // 30s ); @@ -94,8 +88,8 @@ export class WSConnectionFallback { ); this.state = ConnectionState.Connected; - this._setConnectionID(event.connection_id); - this._poll(event.connection_id).then(); + this.connectionID = event.connection_id; + this._poll(); return event; } catch (err) { this.state = ConnectionState.Closed; From 7818ee182bf1e4b9d2994db0bd8cc3c4514db445 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 11:04:01 +0100 Subject: [PATCH 16/49] waitForHealthy timeout from connect --- src/connection.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index 5f06720e6..d9dbcddbb 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -102,10 +102,10 @@ export class StableWSConnection< /** * connect - Connect to the WS URL - * + * the default 15s timeout allows between 2~3 tries * @return {ConnectAPIResponse} Promise that completes once the first health check message is received */ - async connect() { + async connect(timeout = 15000) { if (this.isConnecting) { throw Error(`You've called connect twice, can only attempt 1 connection at the time`); } @@ -135,7 +135,7 @@ export class StableWSConnection< } } - return await this._waitForHealthy(); + return await this._waitForHealthy(timeout); } /** From 3c2f056f7c6df37cad6a16b0b8ef482648cebbc1 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 11:04:18 +0100 Subject: [PATCH 17/49] clean fallback from WS class --- src/connection.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index d9dbcddbb..276b6f1a5 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -3,7 +3,6 @@ import { chatCodes, convertErrorToJson, sleep, retryInterval, randomId } from '. import { buildWsFatalInsight, buildWsSuccessAfterFailureInsight, postInsights } from './insights'; import { ConnectAPIResponse, ConnectionOpen, LiteralStringForUnion, UR, LogLevel } from './types'; import { StreamChat } from './client'; -import { WSConnectionFallback } from './connection_fallback'; // Type guards to check WebSocket error type const isCloseEvent = (res: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent): res is WebSocket.CloseEvent => @@ -63,8 +62,6 @@ export class StableWSConnection< ws?: WebSocket; wsID: number; - fallback?: WSConnectionFallback; - constructor({ client, }: { @@ -90,10 +87,6 @@ export class StableWSConnection< this.pingInterval = 25 * 1000; this.connectionCheckTimeout = this.pingInterval + 10 * 1000; this._listenForConnectionChanges(); - - if (this.client.options.enableWSFallback) { - this.fallback = new WSConnectionFallback({ client: (client as unknown) as StreamChat }); - } } _log(msg: string, extra: UR = {}, level: LogLevel = 'info') { @@ -247,10 +240,6 @@ export class StableWSConnection< delete this.ws; - if (this.fallback) { - this.fallback.disconnect(); - } - return isClosedPromise; } From 564aefbf4fd6f2f4252266181c54395f5ae74441 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 11:17:59 +0100 Subject: [PATCH 18/49] fallback isHealthy --- src/client.ts | 2 +- src/connection_fallback.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index da7d655dd..6f0300ae7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -557,7 +557,7 @@ export class StreamChat< throw Error('User is not set on client, use client.connectUser or client.connectAnonymousUser instead'); } - if (this.wsConnection?.isHealthy && this._hasConnectionID()) { + if ((this.wsConnection?.isHealthy || this.wsFallback?.isHealthy()) && this._hasConnectionID()) { this.logger('info', 'client:openConnection() - openConnection called twice, healthy connection already exists', { tags: ['connection', 'client'], }); diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index ecae202d7..ae3ecbf36 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -97,6 +97,10 @@ export class WSConnectionFallback { } }; + isHealthy = () => { + return this.connectionID && this.state === ConnectionState.Connected; + }; + disconnect = () => { this.state = ConnectionState.Disconnectted; if (this.cancel) { From de7e38473de3816527d98907f29470973d026642 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 11:26:23 +0100 Subject: [PATCH 19/49] client run fallback ws --- src/client.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6f0300ae7..320a40e53 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,6 +11,7 @@ import { StableWSConnection } from './connection'; import { isValidEventType } from './events'; import { JWTUserToken, DevToken, CheckSignature } from './signing'; import { TokenManager } from './token_manager'; +import { WSConnectionFallback } from './connection_fallback'; import { isFunction, isOwnUserBaseProperty, @@ -118,7 +119,6 @@ import { TaskResponse, } from './types'; import { InsightMetrics, postInsights } from './insights'; -import { WSConnectionFallback } from 'connection_fallback'; function isString(x: unknown): x is string { return typeof x === 'string' || x instanceof String; @@ -542,6 +542,8 @@ export class StreamChat< this.cleaningIntervalRef = undefined; } + this.wsFallback?.disconnect(); + if (!this.wsConnection) { return Promise.resolve(); } @@ -1435,7 +1437,17 @@ export class StreamChat< ReactionType >({ client: this }); - return await this.wsConnection.connect(); + try { + // if WSFallback is enabled, ws connect should timeout faster so fallback can try + return await this.wsConnection.connect(this.options.enableWSFallback ? 7000 : 15000); // 7s vs 15s + } catch (err) { + if (this.options.enableWSFallback) { + this.wsFallback = new WSConnectionFallback({ client: (this as unknown) as StreamChat }); + return await this.wsFallback.connect(); + } + + throw err; + } } /** From 8adcad950ca445a0083e825373af996088dee6c1 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 14:21:41 +0100 Subject: [PATCH 20/49] close longpoll properly --- src/client.ts | 10 ++-------- src/connection_fallback.ts | 38 ++++++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/client.ts b/src/client.ts index 320a40e53..858d7a9f7 100644 --- a/src/client.ts +++ b/src/client.ts @@ -542,13 +542,7 @@ export class StreamChat< this.cleaningIntervalRef = undefined; } - this.wsFallback?.disconnect(); - - if (!this.wsConnection) { - return Promise.resolve(); - } - - return this.wsConnection.disconnect(timeout); + return Promise.all([this.wsConnection?.disconnect(timeout), this.wsFallback?.disconnect(timeout)]); }; /** @@ -740,7 +734,7 @@ export class StreamChat< // reset client state this.state = new ClientState(); // reset token manager - this.tokenManager.reset(); + setTimeout(this.tokenManager.reset); // delay reseting to use token for disconnect calls // close the WS connection return closePromise; diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index ae3ecbf36..417eea534 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -1,7 +1,7 @@ -import axios, { AxiosRequestConfig, Canceler } from 'axios'; +import axios, { AxiosRequestConfig, CancelTokenSource } from 'axios'; import { chatCodes, retryInterval, sleep } from './utils'; import { StreamChat } from './client'; -import { ConnectionOpen, Event, UnknownType } from './types'; +import { ConnectionOpen, Event, UnknownType, UR } from './types'; enum ConnectionState { Closed = 'CLOSED', @@ -16,7 +16,7 @@ export class WSConnectionFallback { state: ConnectionState; consecutiveFailures: number; connectionID?: string; - cancel?: Canceler; + cancelToken?: CancelTokenSource; constructor({ client }: { client: StreamChat }) { this.client = client; @@ -25,16 +25,17 @@ export class WSConnectionFallback { } /** @private */ - _newCancelToken = () => { - return new axios.CancelToken((cancel) => (this.cancel = cancel)); - }; + _req = (params: UnknownType, config: AxiosRequestConfig) => { + if (!this.cancelToken && !params.close) { + this.cancelToken = axios.CancelToken.source(); + } - /** @private */ - _req = (params: UnknownType, config: AxiosRequestConfig) => { return this.client.doAxiosRequest('get', this.client.baseURL + '/longpoll', undefined, { - cancelToken: this._newCancelToken(), - config, params, + config: { + ...config, + cancelToken: this.cancelToken?.token, + }, }); }; @@ -45,7 +46,7 @@ export class WSConnectionFallback { while (this.state === ConnectionState.Connected) { try { const data = await this._req<{ events: Event[] }>( - { connection_id: this.connectionID }, + {}, { timeout: 30000 }, // 30s ); @@ -66,6 +67,8 @@ export class WSConnectionFallback { return; } + //TODO: check for non-retryable errors + this.consecutiveFailures += 1; await sleep(retryInterval(this.consecutiveFailures)); } @@ -101,10 +104,17 @@ export class WSConnectionFallback { return this.connectionID && this.state === ConnectionState.Connected; }; - disconnect = () => { + disconnect = async (timeout = 2000) => { this.state = ConnectionState.Disconnectted; - if (this.cancel) { - this.cancel('client.disconnect() is called'); + + this.cancelToken?.cancel('disconnect() is called'); + this.cancelToken = undefined; + + try { + await this._req({ close: true }, { timeout }); + this.connectionID = undefined; + } catch (err) { + console.error(err); //TODO: fire in logger } }; } From ac4fe430cb8d0e6538c634103eef160b49657ef2 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 14:24:41 +0100 Subject: [PATCH 21/49] delete errors.ts --- src/errors.ts | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 src/errors.ts diff --git a/src/errors.ts b/src/errors.ts deleted file mode 100644 index b63377f90..000000000 --- a/src/errors.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const APIErrorCodes = { - '-1': { name: 'InternalSystemError', retryable: true }, - '2': { name: 'AccessKeyError', retryable: false }, - '3': { name: 'AuthenticationFailedError', retryable: true }, - '4': { name: 'InputError', retryable: false }, - '6': { name: 'DuplicateUsernameError', retryable: false }, - '9': { name: 'RateLimitError', retryable: true }, - '16': { name: 'DoesNotExistError', retryable: false }, - '17': { name: 'NotAllowedError', retryable: false }, - '18': { name: 'EventNotSupportedError', retryable: false }, - '19': { name: 'ChannelFeatureNotSupportedError', retryable: false }, - '20': { name: 'MessageTooLongError', retryable: false }, - '21': { name: 'MultipleNestingLevelError', retryable: false }, - '22': { name: 'PayloadTooBigError', retryable: false }, - '23': { name: 'RequestTimeoutError', retryable: true }, - '24': { name: 'MaxHeaderSizeExceededError', retryable: false }, - '40': { name: 'AuthErrorTokenExpired', retryable: false }, - '41': { name: 'AuthErrorTokenNotValidYet', retryable: false }, - '42': { name: 'AuthErrorTokenUsedBeforeIssuedAt', retryable: false }, - '43': { name: 'AuthErrorTokenSignatureInvalid', retryable: false }, - '44': { name: 'CustomCommandEndpointMissingError', retryable: false }, - '45': { name: 'CustomCommandEndpointCallError', retryable: true }, - '46': { name: 'ConnectionIDNotFoundError', retryable: false }, - '60': { name: 'CoolDownError', retryable: true }, - '69': { name: 'ErrWrongRegion', retryable: false }, - '70': { name: 'ErrQueryChannelPermissions', retryable: false }, - '71': { name: 'ErrTooManyConnections', retryable: true }, - '99': { name: 'AppSuspendedError', retryable: false }, -}; From cb72f7e0f0ba8fe356ca21b7a7e4ee3269f46251 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 14:47:53 +0100 Subject: [PATCH 22/49] client use fallback if WS error --- src/client.ts | 7 +++++-- src/utils.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 858d7a9f7..bc31cb213 100644 --- a/src/client.ts +++ b/src/client.ts @@ -21,6 +21,7 @@ import { randomId, sleep, retryInterval, + isWSFailure, } from './utils'; import { @@ -1433,9 +1434,11 @@ export class StreamChat< try { // if WSFallback is enabled, ws connect should timeout faster so fallback can try - return await this.wsConnection.connect(this.options.enableWSFallback ? 7000 : 15000); // 7s vs 15s + return await this.wsConnection.connect(this.options.enableWSFallback ? 6000 : 15000); // 6s vs 15s } catch (err) { - if (this.options.enableWSFallback) { + // run fallback only if it's WS/Network error and not a normal API error + if (this.options.enableWSFallback && isWSFailure(err)) { + this.wsConnection.disconnect().then(); // close WS so no retry this.wsFallback = new WSConnectionFallback({ client: (this as unknown) as StreamChat }); return await this.wsFallback.connect(); } diff --git a/src/utils.ts b/src/utils.ts index 5eadc3539..37253762e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -204,3 +204,15 @@ export function convertErrorToJson(err: Error) { return jsonObj; } + +export function isWSFailure(err: Error & { isWSFailure?: boolean }): boolean { + if (typeof err.isWSFailure === 'boolean') { + return err.isWSFailure; + } + + try { + return JSON.parse(err.message).isWSFailure; + } catch (_) { + return false; + } +} From e8ef9e3b4fd2a86e3106085eb1fd2410a8a0f15b Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 15:05:17 +0100 Subject: [PATCH 23/49] setLocalDevice check for wsfallback --- src/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index bc31cb213..e05aa4e7c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1680,7 +1680,7 @@ export class StreamChat< * */ setLocalDevice(device: BaseDeviceFields) { - if (this.wsConnection) { + if (this.wsConnection || this.wsFallback) { throw new Error('you can only set device before opening a websocket connection'); } From 7035fd73d2491d92fd6d4e77314f89437bf63cf9 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 15:36:54 +0100 Subject: [PATCH 24/49] connection isDisconnected flag --- src/client.ts | 1 + src/connection.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index e05aa4e7c..42f85adcc 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1438,6 +1438,7 @@ export class StreamChat< } catch (err) { // run fallback only if it's WS/Network error and not a normal API error if (this.options.enableWSFallback && isWSFailure(err)) { + this.wsConnection._destroyCurrentWSConnection(); this.wsConnection.disconnect().then(); // close WS so no retry this.wsFallback = new WSConnectionFallback({ client: (this as unknown) as StreamChat }); return await this.wsFallback.connect(); diff --git a/src/connection.ts b/src/connection.ts index 276b6f1a5..75f41260f 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -48,6 +48,7 @@ export class StableWSConnection< pingInterval: number; healthCheckTimeoutRef?: NodeJS.Timeout; isConnecting: boolean; + isDisconnected: boolean; isHealthy: boolean; isResolved?: boolean; lastEvent: Date | null; @@ -75,6 +76,8 @@ export class StableWSConnection< this.totalFailures = 0; /** We only make 1 attempt to reconnect at the same time.. */ this.isConnecting = false; + /** To avoid reconnect if client is disconnected */ + this.isDisconnected = false; /** Boolean that indicates if the connection promise is resolved */ this.isResolved = false; /** Boolean that indicates if we have a working connection to the server */ @@ -103,6 +106,8 @@ export class StableWSConnection< throw Error(`You've called connect twice, can only attempt 1 connection at the time`); } + this.isDisconnected = false; + try { const healthCheck = await this._connect(); this.consecutiveFailures = 0; @@ -194,6 +199,7 @@ export class StableWSConnection< this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`); this.wsID += 1; + this.isDisconnected = true; // start by removing all the listeners if (this.healthCheckTimeoutRef) { @@ -249,7 +255,7 @@ export class StableWSConnection< * @return {ConnectAPIResponse} Promise that completes once the first health check message is received */ async _connect() { - if (this.isConnecting) return; // simply ignore _connect if it's currently trying to connect + if (this.isConnecting || this.isDisconnected) return; // simply ignore _connect if it's currently trying to connect this.isConnecting = true; this.requestID = randomId(); this.client.insightMetrics.connectionStartTimestamp = new Date().getTime(); @@ -300,6 +306,12 @@ export class StableWSConnection< */ async _reconnect(options: { interval?: number; refreshToken?: boolean } = {}): Promise { this._log('_reconnect() - Initiating the reconnect'); + + if (this.isDisconnected) { + this._log('_reconnect() - Abort (0) since disconnect() is called'); + return; + } + // only allow 1 connection at the time if (this.isConnecting || this.isHealthy) { this._log('_reconnect() - Abort (1) since already connecting or healthy'); From 2dd8dfa61fe0a42b1a0943bc625d91e315526596 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 16:40:23 +0100 Subject: [PATCH 25/49] closeConnection promise --- src/client.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/client.ts b/src/client.ts index 42f85adcc..1e4b1a5f2 100644 --- a/src/client.ts +++ b/src/client.ts @@ -537,13 +537,14 @@ export class StreamChat< * @param timeout Max number of ms, to wait for close event of websocket, before forcefully assuming succesful disconnection. * https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent */ - closeConnection = (timeout?: number) => { + closeConnection = async (timeout?: number) => { if (this.cleaningIntervalRef != null) { clearInterval(this.cleaningIntervalRef); this.cleaningIntervalRef = undefined; } - return Promise.all([this.wsConnection?.disconnect(timeout), this.wsFallback?.disconnect(timeout)]); + await Promise.all([this.wsConnection?.disconnect(timeout), this.wsFallback?.disconnect(timeout)]); + return Promise.resolve(); }; /** From b878e70912c0b4535a9da5f67fd41c047648154a Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 16:58:45 +0100 Subject: [PATCH 26/49] fix tests --- src/client.ts | 9 ++++++++- test/unit/connection.js | 22 ++++++++++++++-------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/client.ts b/src/client.ts index 1e4b1a5f2..0d58cb8c1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -189,6 +189,8 @@ export class StreamChat< wsPromise: ConnectAPIResponse | null; consecutiveFailures: number; insightMetrics: InsightMetrics; + defaultWSTimeoutWithFallback: number; + defaultWSTimeout: number; /** * Initialize a client @@ -274,6 +276,9 @@ export class StreamChat< this.consecutiveFailures = 0; this.insightMetrics = new InsightMetrics(); + this.defaultWSTimeoutWithFallback = 6000; + this.defaultWSTimeout = 15000; + /** * logger function should accept 3 parameters: * @param logLevel string @@ -1435,7 +1440,9 @@ export class StreamChat< try { // if WSFallback is enabled, ws connect should timeout faster so fallback can try - return await this.wsConnection.connect(this.options.enableWSFallback ? 6000 : 15000); // 6s vs 15s + return await this.wsConnection.connect( + this.options.enableWSFallback ? this.defaultWSTimeoutWithFallback : this.defaultWSTimeout, + ); } catch (err) { // run fallback only if it's WS/Network error and not a normal API error if (this.options.enableWSFallback && isWSFailure(err)) { diff --git a/test/unit/connection.js b/test/unit/connection.js index cb3434fcf..7de36495f 100644 --- a/test/unit/connection.js +++ b/test/unit/connection.js @@ -140,7 +140,7 @@ describe('connection', function () { const c = new StableWSConnection({ client }); expect(c.isConnecting).to.be.false; - const connection = c.connect(); + const connection = c.connect(1000); expect(c.isConnecting).to.be.true; try { await connection; @@ -163,22 +163,28 @@ describe('connection', function () { }); describe('Connection connect timeout', function () { - const client = new StreamChat('apiKey', { - allowServerSideConnect: true, - baseURL: 'http://localhost:1111', // invalid base url - enableInsights: true, - }); - const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW1pbiJ9.dN0CCAW5CayCq0dsTXxLZvjxhQuZvlaeIfrJmxk9NkU'; it('should fail with invalid URL', async function () { + const client = new StreamChat('apiKey', { + allowServerSideConnect: true, + baseURL: 'http://localhost:1111', // invalid base url + }); + client.defaultWSTimeout = 2000; + await expect(client.connectUser({ id: 'amin' }, token)).to.be.rejectedWith( /initial WS connection could not be established/, ); }); - it('should retry until connection is establsihed', async function () { + it('should retry until connection is established', async function () { + const client = new StreamChat('apiKey', { + allowServerSideConnect: true, + baseURL: 'http://localhost:1111', // invalid base url + }); + client.defaultWSTimeout = 5000; + await Promise.all([ client.connectUser({ id: 'amin' }, token).then((health) => { expect(health.type).to.be.equal('health.check'); From 8dd41698b9533309dc3c2d4d259139ffe71bea6c Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Mon, 29 Nov 2021 17:21:18 +0100 Subject: [PATCH 27/49] set this.isConnecting to false on err --- src/connection.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/connection.ts b/src/connection.ts index 75f41260f..ce8eb620f 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -165,6 +165,7 @@ export class StableWSConnection< })(), (async () => { await sleep(timeout); + this.isConnecting = false; throw new Error( JSON.stringify({ code: '', @@ -199,6 +200,7 @@ export class StableWSConnection< this._log(`disconnect() - Closing the websocket connection for wsID ${this.wsID}`); this.wsID += 1; + this.isConnecting = false; this.isDisconnected = true; // start by removing all the listeners From afaa2c4651346c091537bf39bef1c0720fdf7595 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 12:09:23 +0100 Subject: [PATCH 28/49] reset connection id on connect --- src/connection_fallback.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 417eea534..bed7ce5f4 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -84,6 +84,7 @@ export class WSConnectionFallback { } this.state = ConnectionState.Connecting; + this.connectionID = undefined; // connect should be sent with empty connection_id so API gives us one try { const { event } = await this._req<{ event: ConnectionOpen }>( { json: this.client._buildWSPayload() }, From ac909ab89fff1e28014ac94ca3e3e1f46e3f13ea Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 12:10:37 +0100 Subject: [PATCH 29/49] refactor errors --- src/client.ts | 2 +- src/errors.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 13 ------------- 3 files changed, 55 insertions(+), 14 deletions(-) create mode 100644 src/errors.ts diff --git a/src/client.ts b/src/client.ts index dc4993a99..317e5b090 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,6 +12,7 @@ import { isValidEventType } from './events'; import { JWTUserToken, DevToken, CheckSignature } from './signing'; import { TokenManager } from './token_manager'; import { WSConnectionFallback } from './connection_fallback'; +import { isWSFailure } from './errors'; import { isFunction, isOwnUserBaseProperty, @@ -21,7 +22,6 @@ import { randomId, sleep, retryInterval, - isWSFailure, } from './utils'; import { diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 000000000..afe26e96b --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,54 @@ +export const APIErrorCodes: Record = { + '-1': { name: 'InternalSystemError', retryable: true }, + '2': { name: 'AccessKeyError', retryable: false }, + '3': { name: 'AuthenticationFailedError', retryable: true }, + '4': { name: 'InputError', retryable: false }, + '6': { name: 'DuplicateUsernameError', retryable: false }, + '9': { name: 'RateLimitError', retryable: true }, + '16': { name: 'DoesNotExistError', retryable: false }, + '17': { name: 'NotAllowedError', retryable: false }, + '18': { name: 'EventNotSupportedError', retryable: false }, + '19': { name: 'ChannelFeatureNotSupportedError', retryable: false }, + '20': { name: 'MessageTooLongError', retryable: false }, + '21': { name: 'MultipleNestingLevelError', retryable: false }, + '22': { name: 'PayloadTooBigError', retryable: false }, + '23': { name: 'RequestTimeoutError', retryable: true }, + '24': { name: 'MaxHeaderSizeExceededError', retryable: false }, + '40': { name: 'AuthErrorTokenExpired', retryable: false }, + '41': { name: 'AuthErrorTokenNotValidYet', retryable: false }, + '42': { name: 'AuthErrorTokenUsedBeforeIssuedAt', retryable: false }, + '43': { name: 'AuthErrorTokenSignatureInvalid', retryable: false }, + '44': { name: 'CustomCommandEndpointMissingError', retryable: false }, + '45': { name: 'CustomCommandEndpointCallError', retryable: true }, + '46': { name: 'ConnectionIDNotFoundError', retryable: false }, + '60': { name: 'CoolDownError', retryable: true }, + '69': { name: 'ErrWrongRegion', retryable: false }, + '70': { name: 'ErrQueryChannelPermissions', retryable: false }, + '71': { name: 'ErrTooManyConnections', retryable: true }, + '99': { name: 'AppSuspendedError', retryable: false }, +}; + +type APIError = Error & { code?: number; isWSFailure?: boolean }; + +export function isErrorRetryable(error: APIError) { + if (!error.code) return false; + const err = APIErrorCodes[`${error.code}`]; + if (!err) return false; + return err.retryable; +} + +export function isConnectionIDError(error: APIError) { + return error.code === 46; // ConnectionIDNotFoundError +} + +export function isWSFailure(err: APIError): boolean { + if (typeof err.isWSFailure === 'boolean') { + return err.isWSFailure; + } + + try { + return JSON.parse(err.message).isWSFailure; + } catch (_) { + return false; + } +} diff --git a/src/utils.ts b/src/utils.ts index 37253762e..088a4530e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -28,7 +28,6 @@ export function isFunction(value: Function | T): value is Function { export const chatCodes = { TOKEN_EXPIRED: 40, - CONNECTION_ID_ERROR: 46, WS_CLOSED_SUCCESS: 1000, }; @@ -204,15 +203,3 @@ export function convertErrorToJson(err: Error) { return jsonObj; } - -export function isWSFailure(err: Error & { isWSFailure?: boolean }): boolean { - if (typeof err.isWSFailure === 'boolean') { - return err.isWSFailure; - } - - try { - return JSON.parse(err.message).isWSFailure; - } catch (_) { - return false; - } -} From 4268d460ddcd98ec1014b58207b03aaee72aa721 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 12:36:23 +0100 Subject: [PATCH 30/49] retry logic --- src/client.ts | 20 ++++++++- src/connection_fallback.ts | 84 ++++++++++++++++++++++++++------------ 2 files changed, 75 insertions(+), 29 deletions(-) diff --git a/src/client.ts b/src/client.ts index 317e5b090..4bc5cb5eb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -186,7 +186,15 @@ export class StreamChat< MessageType, ReactionType > | null; - wsFallback?: WSConnectionFallback; + wsFallback?: WSConnectionFallback< + AttachmentType, + ChannelType, + CommandType, + EventType, + MessageType, + ReactionType, + UserType + >; wsPromise: ConnectAPIResponse | null; consecutiveFailures: number; insightMetrics: InsightMetrics; @@ -1449,7 +1457,15 @@ export class StreamChat< if (this.options.enableWSFallback && isWSFailure(err)) { this.wsConnection._destroyCurrentWSConnection(); this.wsConnection.disconnect().then(); // close WS so no retry - this.wsFallback = new WSConnectionFallback({ client: (this as unknown) as StreamChat }); + this.wsFallback = new WSConnectionFallback< + AttachmentType, + ChannelType, + CommandType, + EventType, + MessageType, + ReactionType, + UserType + >({ client: this }); return await this.wsFallback.connect(); } diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index bed7ce5f4..1ead2820e 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -1,7 +1,8 @@ import axios, { AxiosRequestConfig, CancelTokenSource } from 'axios'; -import { chatCodes, retryInterval, sleep } from './utils'; import { StreamChat } from './client'; -import { ConnectionOpen, Event, UnknownType, UR } from './types'; +import { retryInterval, sleep } from './utils'; +import { isConnectionIDError, isErrorRetryable } from './errors'; +import { ConnectionOpen, Event, UnknownType, UR, LiteralStringForUnion } from './types'; enum ConnectionState { Closed = 'CLOSED', @@ -11,43 +12,67 @@ enum ConnectionState { Init = 'INIT', } -export class WSConnectionFallback { - client: StreamChat; +export class WSConnectionFallback< + AttachmentType extends UR = UR, + ChannelType extends UR = UR, + CommandType extends string = LiteralStringForUnion, + EventType extends UR = UR, + MessageType extends UR = UR, + ReactionType extends UR = UR, + UserType extends UR = UR +> { + client: StreamChat; state: ConnectionState; consecutiveFailures: number; connectionID?: string; cancelToken?: CancelTokenSource; - constructor({ client }: { client: StreamChat }) { + constructor({ + client, + }: { + client: StreamChat; + }) { this.client = client; this.state = ConnectionState.Init; this.consecutiveFailures = 0; } /** @private */ - _req = (params: UnknownType, config: AxiosRequestConfig) => { + _req = async (params: UnknownType, config: AxiosRequestConfig, retry: boolean): Promise => { if (!this.cancelToken && !params.close) { this.cancelToken = axios.CancelToken.source(); } - return this.client.doAxiosRequest('get', this.client.baseURL + '/longpoll', undefined, { - params, - config: { - ...config, - cancelToken: this.cancelToken?.token, - }, - }); + try { + const res = await this.client.doAxiosRequest('get', this.client.baseURL + '/longpoll', undefined, { + config: { ...config, cancelToken: this.cancelToken?.token }, + params, + }); + + this.consecutiveFailures = 0; // always reset in case of no error + return res; + } catch (err) { + this.consecutiveFailures += 1; + + if (retry && isErrorRetryable(err)) { + await sleep(retryInterval(this.consecutiveFailures)); + return this._req(params, config, retry); + } + + throw err; + } }; /** @private */ _poll = async () => { - this.consecutiveFailures = 0; - while (this.state === ConnectionState.Connected) { try { - const data = await this._req<{ events: Event[] }>( + const data = await this._req<{ + events: Event[]; + }>( {}, { timeout: 30000 }, // 30s + true, ); if (data?.events?.length) { @@ -61,21 +86,22 @@ export class WSConnectionFallback { } /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ - if (err.code === chatCodes.CONNECTION_ID_ERROR) { + if (isConnectionIDError(err)) { this.state = ConnectionState.Disconnectted; - this.connect(); + this.connect(true); return; } //TODO: check for non-retryable errors - - this.consecutiveFailures += 1; - await sleep(retryInterval(this.consecutiveFailures)); } } }; - connect = async () => { + /** + * connect try to open a longpoll request + * @param retry keep trying to connect until it succeed + */ + connect = async (retry = false) => { if (this.state === ConnectionState.Connecting) { throw new Error('connecting already in progress'); } @@ -84,11 +110,12 @@ export class WSConnectionFallback { } this.state = ConnectionState.Connecting; - this.connectionID = undefined; // connect should be sent with empty connection_id so API gives us one + this.connectionID = undefined; // connect should be sent with empty connection_id so API creates one try { - const { event } = await this._req<{ event: ConnectionOpen }>( + const { event } = await this._req<{ event: ConnectionOpen }>( { json: this.client._buildWSPayload() }, - { timeout: 10000 }, // 10s + { timeout: 8000 }, // 8s + retry, ); this.state = ConnectionState.Connected; @@ -97,10 +124,13 @@ export class WSConnectionFallback { return event; } catch (err) { this.state = ConnectionState.Closed; - return err; + throw err; } }; + /** + * isHealthy checks if there is a connectionID and connection is in Connected state + */ isHealthy = () => { return this.connectionID && this.state === ConnectionState.Connected; }; @@ -112,7 +142,7 @@ export class WSConnectionFallback { this.cancelToken = undefined; try { - await this._req({ close: true }, { timeout }); + await this._req({ close: true }, { timeout }, false); this.connectionID = undefined; } catch (err) { console.error(err); //TODO: fire in logger From e8462dadd1dd1a22871d3fae1dc080e1c55205c4 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 12:54:35 +0100 Subject: [PATCH 31/49] replace local port --- src/connection_fallback.ts | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 1ead2820e..b351885ac 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -44,10 +44,15 @@ export class WSConnectionFallback< } try { - const res = await this.client.doAxiosRequest('get', this.client.baseURL + '/longpoll', undefined, { - config: { ...config, cancelToken: this.cancelToken?.token }, - params, - }); + const res = await this.client.doAxiosRequest( + 'get', + (this.client.baseURL as string).replace(':3030', ':8900') + '/longpoll', // replace port if present for testing with local API + undefined, + { + config: { ...config, cancelToken: this.cancelToken?.token }, + params, + }, + ); this.consecutiveFailures = 0; // always reset in case of no error return res; @@ -71,11 +76,11 @@ export class WSConnectionFallback< events: Event[]; }>( {}, - { timeout: 30000 }, // 30s + { timeout: 30000 }, // 30s => API responds in 20s if there is no event true, ); - if (data?.events?.length) { + if (data.events?.length) { for (let i = 0; i < data.events.length; i++) { this.client.dispatchEvent(data.events[i]); } @@ -86,6 +91,7 @@ export class WSConnectionFallback< } /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ + if (isConnectionIDError(err)) { this.state = ConnectionState.Disconnectted; this.connect(true); From 8d663970a437ea28999136b41b739baa3a7da9d3 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 12:56:53 +0100 Subject: [PATCH 32/49] typo --- src/connection_fallback.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index b351885ac..9a9b54353 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -8,7 +8,7 @@ enum ConnectionState { Closed = 'CLOSED', Connected = 'CONNECTED', Connecting = 'CONNECTING', - Disconnectted = 'DISCONNECTTED', + Disconnected = 'DISCONNECTED', Init = 'INIT', } @@ -93,7 +93,7 @@ export class WSConnectionFallback< /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ if (isConnectionIDError(err)) { - this.state = ConnectionState.Disconnectted; + this.state = ConnectionState.Disconnected; this.connect(true); return; } @@ -142,7 +142,7 @@ export class WSConnectionFallback< }; disconnect = async (timeout = 2000) => { - this.state = ConnectionState.Disconnectted; + this.state = ConnectionState.Disconnected; this.cancelToken?.cancel('disconnect() is called'); this.cancelToken = undefined; From 6d9ff795174b2063c331994693606bf15b7d0fb3 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 13:12:28 +0100 Subject: [PATCH 33/49] poll sleep on err --- src/connection_fallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 9a9b54353..4961ded32 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -98,7 +98,7 @@ export class WSConnectionFallback< return; } - //TODO: check for non-retryable errors + await sleep(retryInterval(this.consecutiveFailures)); } } }; From 88c54b91b7e2465928cd14d05a05f8b9dc96c10c Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 13:32:48 +0100 Subject: [PATCH 34/49] fix multiple recover call --- src/connection.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index ce8eb620f..d638864fa 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -309,11 +309,6 @@ export class StableWSConnection< async _reconnect(options: { interval?: number; refreshToken?: boolean } = {}): Promise { this._log('_reconnect() - Initiating the reconnect'); - if (this.isDisconnected) { - this._log('_reconnect() - Abort (0) since disconnect() is called'); - return; - } - // only allow 1 connection at the time if (this.isConnecting || this.isHealthy) { this._log('_reconnect() - Abort (1) since already connecting or healthy'); @@ -336,6 +331,11 @@ export class StableWSConnection< return; } + if (this.isDisconnected) { + this._log('_reconnect() - Abort (3) since disconnect() is called'); + return; + } + this._log('_reconnect() - Destroying current WS connection'); // cleanup the old connection From 6e586b0f90120e3c738c918f4337c81cff7cdc5e Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 14:03:12 +0100 Subject: [PATCH 35/49] disconnect in case of non retryable errors --- src/connection_fallback.ts | 21 +++++++++++++++------ src/errors.ts | 4 ++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 4961ded32..f7f2257c1 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -1,7 +1,7 @@ import axios, { AxiosRequestConfig, CancelTokenSource } from 'axios'; import { StreamChat } from './client'; import { retryInterval, sleep } from './utils'; -import { isConnectionIDError, isErrorRetryable } from './errors'; +import { isAPIError, isConnectionIDError, isErrorRetryable } from './errors'; import { ConnectionOpen, Event, UnknownType, UR, LiteralStringForUnion } from './types'; enum ConnectionState { @@ -37,6 +37,10 @@ export class WSConnectionFallback< this.consecutiveFailures = 0; } + _setState(state: ConnectionState) { + this.state = state; + } + /** @private */ _req = async (params: UnknownType, config: AxiosRequestConfig, retry: boolean): Promise => { if (!this.cancelToken && !params.close) { @@ -93,11 +97,16 @@ export class WSConnectionFallback< /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ if (isConnectionIDError(err)) { - this.state = ConnectionState.Disconnected; + this._setState(ConnectionState.Disconnected); this.connect(true); return; } + if (isAPIError(err) && !isErrorRetryable(err)) { + this._setState(ConnectionState.Closed); + return; + } + await sleep(retryInterval(this.consecutiveFailures)); } } @@ -115,7 +124,7 @@ export class WSConnectionFallback< throw new Error('already connected and polling'); } - this.state = ConnectionState.Connecting; + this._setState(ConnectionState.Connecting); this.connectionID = undefined; // connect should be sent with empty connection_id so API creates one try { const { event } = await this._req<{ event: ConnectionOpen }>( @@ -124,12 +133,12 @@ export class WSConnectionFallback< retry, ); - this.state = ConnectionState.Connected; + this._setState(ConnectionState.Connected); this.connectionID = event.connection_id; this._poll(); return event; } catch (err) { - this.state = ConnectionState.Closed; + this._setState(ConnectionState.Closed); throw err; } }; @@ -142,7 +151,7 @@ export class WSConnectionFallback< }; disconnect = async (timeout = 2000) => { - this.state = ConnectionState.Disconnected; + this._setState(ConnectionState.Disconnected); this.cancelToken?.cancel('disconnect() is called'); this.cancelToken = undefined; diff --git a/src/errors.ts b/src/errors.ts index afe26e96b..2e0bb9cc7 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -30,6 +30,10 @@ export const APIErrorCodes: Record type APIError = Error & { code?: number; isWSFailure?: boolean }; +export function isAPIError(error: Error): error is APIError { + return (error as APIError).code !== undefined; +} + export function isErrorRetryable(error: APIError) { if (!error.code) return false; const err = APIErrorCodes[`${error.code}`]; From 08e8429387b93a246b7d5bfff7e085a8eee379f8 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 14:13:26 +0100 Subject: [PATCH 36/49] fallback recover state --- src/connection_fallback.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index f7f2257c1..b34c428ce 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -114,9 +114,9 @@ export class WSConnectionFallback< /** * connect try to open a longpoll request - * @param retry keep trying to connect until it succeed + * @param reconnect should be false for first call and true for subsequent calls to keep the connection alive and call recoverState */ - connect = async (retry = false) => { + connect = async (reconnect = false) => { if (this.state === ConnectionState.Connecting) { throw new Error('connecting already in progress'); } @@ -130,12 +130,15 @@ export class WSConnectionFallback< const { event } = await this._req<{ event: ConnectionOpen }>( { json: this.client._buildWSPayload() }, { timeout: 8000 }, // 8s - retry, + reconnect, ); this._setState(ConnectionState.Connected); this.connectionID = event.connection_id; this._poll(); + if (reconnect) { + this.client.recoverState(); + } return event; } catch (err) { this._setState(ConnectionState.Closed); From fd558ab9e2a9f4f1b76dca5b9e6ee89aa90898d4 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 14:43:52 +0100 Subject: [PATCH 37/49] enableWSFallback flag warning --- src/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types.ts b/src/types.ts index 495e97f9a..82384e2e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -871,6 +871,7 @@ export type StreamChatOptions = AxiosRequestConfig & { browser?: boolean; device?: BaseDeviceFields; enableInsights?: boolean; + /** experimental feature, please contact support if you want this feature enabled for you */ enableWSFallback?: boolean; logger?: Logger; /** From f5a943c79cbefe0ad8bae8cc5302a736177398d8 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 15:45:40 +0100 Subject: [PATCH 38/49] check browser is online before longpoll --- src/client.ts | 4 +++- src/utils.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/client.ts b/src/client.ts index bdc3d51ef..6f3f105f1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,6 +22,7 @@ import { randomId, sleep, retryInterval, + isOnline, } from './utils'; import { @@ -1455,7 +1456,8 @@ export class StreamChat< ); } catch (err) { // run fallback only if it's WS/Network error and not a normal API error - if (this.options.enableWSFallback && isWSFailure(err)) { + // make sure browser is online before even trying the longpoll + if (this.options.enableWSFallback && isWSFailure(err) && isOnline()) { this.wsConnection._destroyCurrentWSConnection(); this.wsConnection.disconnect().then(); // close WS so no retry this.wsFallback = new WSConnectionFallback< diff --git a/src/utils.ts b/src/utils.ts index 088a4530e..296b2aa97 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -203,3 +203,19 @@ export function convertErrorToJson(err: Error) { return jsonObj; } + +/** + * isOnline safely return the navigator.online value + * if navigator is not in global object, it always return true + */ +export function isOnline() { + const nav = + typeof navigator !== 'undefined' + ? navigator + : typeof window !== 'undefined' && window.navigator + ? window.navigator + : undefined; + + if (!nav) return true; + return nav.onLine; +} From 90211397b7b56448ad22f2ce2f3e2ba5cea09e01 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Tue, 30 Nov 2021 15:54:07 +0100 Subject: [PATCH 39/49] refactor addConnectionEventListeners --- src/connection.ts | 36 ++++++++++++------------------------ src/utils.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/src/connection.ts b/src/connection.ts index d638864fa..2ecfb704e 100644 --- a/src/connection.ts +++ b/src/connection.ts @@ -1,5 +1,13 @@ import WebSocket from 'isomorphic-ws'; -import { chatCodes, convertErrorToJson, sleep, retryInterval, randomId } from './utils'; +import { + chatCodes, + convertErrorToJson, + sleep, + retryInterval, + randomId, + removeConnectionEventListeners, + addConnectionEventListeners, +} from './utils'; import { buildWsFatalInsight, buildWsSuccessAfterFailureInsight, postInsights } from './insights'; import { ConnectAPIResponse, ConnectionOpen, LiteralStringForUnion, UR, LogLevel } from './types'; import { StreamChat } from './client'; @@ -89,7 +97,8 @@ export class StableWSConnection< /** Send a health check message every 25 seconds */ this.pingInterval = 25 * 1000; this.connectionCheckTimeout = this.pingInterval + 10 * 1000; - this._listenForConnectionChanges(); + + addConnectionEventListeners(this.onlineStatusChanged); } _log(msg: string, extra: UR = {}, level: LogLevel = 'info') { @@ -211,7 +220,7 @@ export class StableWSConnection< clearInterval(this.connectionCheckTimeoutRef); } - this._removeConnectionListeners(); + removeConnectionEventListeners(this.onlineStatusChanged); this.isHealthy = false; @@ -541,27 +550,6 @@ export class StableWSConnection< return error; }; - /** - * _listenForConnectionChanges - Adds an event listener for the browser going online or offline - */ - _listenForConnectionChanges = () => { - // (typeof window !== 'undefined') check is for environments where window is not defined, such as nextjs environment, - // and thus (window === undefined) will result in ReferenceError. - if (typeof window !== 'undefined' && window?.addEventListener) { - window.addEventListener('offline', this.onlineStatusChanged); - window.addEventListener('online', this.onlineStatusChanged); - } - }; - - _removeConnectionListeners = () => { - // (typeof window !== 'undefined') check is for environments where window is not defined, such as nextjs environment, - // and thus (window === undefined) will result in ReferenceError. - if (typeof window !== 'undefined' && window?.removeEventListener) { - window.removeEventListener('offline', this.onlineStatusChanged); - window.removeEventListener('online', this.onlineStatusChanged); - } - }; - /** * _destroyCurrentWSConnection - Removes the current WS connection * diff --git a/src/utils.ts b/src/utils.ts index 296b2aa97..8052b697f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -219,3 +219,20 @@ export function isOnline() { if (!nav) return true; return nav.onLine; } + +/** + * listenForConnectionChanges - Adds an event listener fired on browser going online or offline + */ +export function addConnectionEventListeners(cb: (e: Event) => void) { + if (typeof window !== 'undefined' && window.addEventListener) { + window.addEventListener('offline', cb); + window.addEventListener('online', cb); + } +} + +export function removeConnectionEventListeners(cb: (e: Event) => void) { + if (typeof window !== 'undefined' && window.removeEventListener) { + window.removeEventListener('offline', cb); + window.removeEventListener('online', cb); + } +} From 020a09050d26e3d38d7ffc9b7a789c7e4a103086 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 1 Dec 2021 10:27:25 +0100 Subject: [PATCH 40/49] connection.changed events --- src/connection_fallback.ts | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index b34c428ce..2c446843a 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -1,6 +1,6 @@ import axios, { AxiosRequestConfig, CancelTokenSource } from 'axios'; import { StreamChat } from './client'; -import { retryInterval, sleep } from './utils'; +import { addConnectionEventListeners, removeConnectionEventListeners, retryInterval, sleep } from './utils'; import { isAPIError, isConnectionIDError, isErrorRetryable } from './errors'; import { ConnectionOpen, Event, UnknownType, UR, LiteralStringForUnion } from './types'; @@ -35,12 +35,38 @@ export class WSConnectionFallback< this.client = client; this.state = ConnectionState.Init; this.consecutiveFailures = 0; + + addConnectionEventListeners(this._onlineStatusChanged); } _setState(state: ConnectionState) { + if (state === ConnectionState.Connected || this.state === ConnectionState.Connecting) { + //@ts-expect-error + this.client.dispatchEvent({ type: 'connection.changed', online: true }); + } + + if (state === ConnectionState.Closed || state === ConnectionState.Disconnected) { + //@ts-expect-error + this.client.dispatchEvent({ type: 'connection.changed', online: false }); + } + this.state = state; } + /** @private */ + _onlineStatusChanged = (event: { type: string }) => { + if (event.type === 'offline') { + this._setState(ConnectionState.Closed); + this.cancelToken?.cancel('disconnect() is called'); + this.cancelToken = undefined; + return; + } + + if (event.type === 'online' && this.state === ConnectionState.Closed) { + this.connect(true); + } + }; + /** @private */ _req = async (params: UnknownType, config: AxiosRequestConfig, retry: boolean): Promise => { if (!this.cancelToken && !params.close) { @@ -154,8 +180,9 @@ export class WSConnectionFallback< }; disconnect = async (timeout = 2000) => { - this._setState(ConnectionState.Disconnected); + removeConnectionEventListeners(this._onlineStatusChanged); + this._setState(ConnectionState.Disconnected); this.cancelToken?.cancel('disconnect() is called'); this.cancelToken = undefined; From 9cc7b2d141c985105c2d0df8239b820aafa903ec Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 1 Dec 2021 10:36:54 +0100 Subject: [PATCH 41/49] fallback logger --- src/client.ts | 2 ++ src/connection_fallback.ts | 20 ++++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/client.ts b/src/client.ts index 6f3f105f1..37a5d2a02 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1458,6 +1458,8 @@ export class StreamChat< // run fallback only if it's WS/Network error and not a normal API error // make sure browser is online before even trying the longpoll if (this.options.enableWSFallback && isWSFailure(err) && isOnline()) { + this.logger('info', 'client:connect() - WS failed, fallback to longpoll', { tags: ['connection', 'client'] }); + this.wsConnection._destroyCurrentWSConnection(); this.wsConnection.disconnect().then(); // close WS so no retry this.wsFallback = new WSConnectionFallback< diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 2c446843a..b3736a138 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -2,7 +2,7 @@ import axios, { AxiosRequestConfig, CancelTokenSource } from 'axios'; import { StreamChat } from './client'; import { addConnectionEventListeners, removeConnectionEventListeners, retryInterval, sleep } from './utils'; import { isAPIError, isConnectionIDError, isErrorRetryable } from './errors'; -import { ConnectionOpen, Event, UnknownType, UR, LiteralStringForUnion } from './types'; +import { ConnectionOpen, Event, UnknownType, UR, LiteralStringForUnion, LogLevel } from './types'; enum ConnectionState { Closed = 'CLOSED', @@ -39,7 +39,13 @@ export class WSConnectionFallback< addConnectionEventListeners(this._onlineStatusChanged); } + _log(msg: string, extra: UR = {}, level: LogLevel = 'info') { + this.client.logger(level, 'WSConnectionFallback:' + msg, { tags: ['connection_fallback', 'connection'], ...extra }); + } + _setState(state: ConnectionState) { + this._log(`_setState() - ${state}`); + if (state === ConnectionState.Connected || this.state === ConnectionState.Connecting) { //@ts-expect-error this.client.dispatchEvent({ type: 'connection.changed', online: true }); @@ -55,6 +61,8 @@ export class WSConnectionFallback< /** @private */ _onlineStatusChanged = (event: { type: string }) => { + this._log(`_onlineStatusChanged() - ${event.type}`); + if (event.type === 'offline') { this._setState(ConnectionState.Closed); this.cancelToken?.cancel('disconnect() is called'); @@ -90,6 +98,7 @@ export class WSConnectionFallback< this.consecutiveFailures += 1; if (retry && isErrorRetryable(err)) { + this._log(`_req() - Retryable error, retrying request`); await sleep(retryInterval(this.consecutiveFailures)); return this._req(params, config, retry); } @@ -104,11 +113,7 @@ export class WSConnectionFallback< try { const data = await this._req<{ events: Event[]; - }>( - {}, - { timeout: 30000 }, // 30s => API responds in 20s if there is no event - true, - ); + }>({}, { timeout: 30000 }, true); // 30s => API responds in 20s if there is no event if (data.events?.length) { for (let i = 0; i < data.events.length; i++) { @@ -117,12 +122,14 @@ export class WSConnectionFallback< } } catch (err) { if (axios.isCancel(err)) { + this._log(`_poll() - axios canceled request`); return; } /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ if (isConnectionIDError(err)) { + this._log(`_poll() - ConnectionID error, connecting without ID...`); this._setState(ConnectionState.Disconnected); this.connect(true); return; @@ -189,6 +196,7 @@ export class WSConnectionFallback< try { await this._req({ close: true }, { timeout }, false); this.connectionID = undefined; + this._log(`disconnect() - Closed connectionID`); } catch (err) { console.error(err); //TODO: fire in logger } From 13c95b91eb97ab75e409f37aadca5e146228bf12 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 1 Dec 2021 10:38:59 +0100 Subject: [PATCH 42/49] removed todo --- src/connection_fallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index b3736a138..de4128d44 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -198,7 +198,7 @@ export class WSConnectionFallback< this.connectionID = undefined; this._log(`disconnect() - Closed connectionID`); } catch (err) { - console.error(err); //TODO: fire in logger + this._log(`disconnect() - Failed`, { err }); } }; } From 840cfbd3b6f22572566328b0db59b4c8bf68d766 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 1 Dec 2021 10:52:30 +0100 Subject: [PATCH 43/49] fix event condition --- src/connection_fallback.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index de4128d44..634b130b2 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -46,7 +46,8 @@ export class WSConnectionFallback< _setState(state: ConnectionState) { this._log(`_setState() - ${state}`); - if (state === ConnectionState.Connected || this.state === ConnectionState.Connecting) { + // transition from connecting => connected + if (this.state === ConnectionState.Connecting && state === ConnectionState.Connected) { //@ts-expect-error this.client.dispatchEvent({ type: 'connection.changed', online: true }); } From 7c61d87a6ef2a2e67bd1a01cc2767175f5f831ca Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 1 Dec 2021 13:34:30 +0100 Subject: [PATCH 44/49] 4.5.0-beta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1517d21f8..7ad731ca4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stream-chat", - "version": "4.4.3", + "version": "4.5.0-beta.0", "description": "JS SDK for the Stream Chat API", "author": "GetStream", "homepage": "https://getstream.io/chat/", From 7b4ac0648492d537a86835b477925229cbb9b7e4 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 1 Dec 2021 16:54:10 +0100 Subject: [PATCH 45/49] warn for missing navigation --- src/connection_fallback.ts | 2 +- src/utils.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 634b130b2..806eb4b69 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -199,7 +199,7 @@ export class WSConnectionFallback< this.connectionID = undefined; this._log(`disconnect() - Closed connectionID`); } catch (err) { - this._log(`disconnect() - Failed`, { err }); + this._log(`disconnect() - Failed`, { err }, 'error'); } }; } diff --git a/src/utils.ts b/src/utils.ts index 8052b697f..b1b60b451 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -205,7 +205,7 @@ export function convertErrorToJson(err: Error) { } /** - * isOnline safely return the navigator.online value + * isOnline safely return the navigator.online value for browser env * if navigator is not in global object, it always return true */ export function isOnline() { @@ -216,7 +216,11 @@ export function isOnline() { ? window.navigator : undefined; - if (!nav) return true; + if (!nav) { + console.warn('isOnline failed to access window.navigator and assume browser is online'); + return true; + } + return nav.onLine; } From 8a989b58983d79a4e202c0ca58bda44e07cf33fc Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Wed, 1 Dec 2021 16:56:00 +0100 Subject: [PATCH 46/49] fix APIError type --- src/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index 2e0bb9cc7..3d7b382fc 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -28,7 +28,7 @@ export const APIErrorCodes: Record '99': { name: 'AppSuspendedError', retryable: false }, }; -type APIError = Error & { code?: number; isWSFailure?: boolean }; +type APIError = Error & { code: number; isWSFailure?: boolean }; export function isAPIError(error: Error): error is APIError { return (error as APIError).code !== undefined; From 9dbafe38778878affcdd50443661885986bfd227 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Thu, 2 Dec 2021 09:40:56 +0100 Subject: [PATCH 47/49] fix isOnline --- src/utils.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/utils.ts b/src/utils.ts index b1b60b451..c25c4673f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -221,6 +221,11 @@ export function isOnline() { return true; } + // RN navigator has undefined for onLine + if (typeof nav.onLine !== 'boolean') { + return true; + } + return nav.onLine; } From 8229b7d12aaa0b29c83774ab576e8a6279a48e9e Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Thu, 2 Dec 2021 12:16:06 +0100 Subject: [PATCH 48/49] export ConnectionState --- src/connection_fallback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/connection_fallback.ts b/src/connection_fallback.ts index 806eb4b69..2dca91d59 100644 --- a/src/connection_fallback.ts +++ b/src/connection_fallback.ts @@ -4,7 +4,7 @@ import { addConnectionEventListeners, removeConnectionEventListeners, retryInter import { isAPIError, isConnectionIDError, isErrorRetryable } from './errors'; import { ConnectionOpen, Event, UnknownType, UR, LiteralStringForUnion, LogLevel } from './types'; -enum ConnectionState { +export enum ConnectionState { Closed = 'CLOSED', Connected = 'CONNECTED', Connecting = 'CONNECTING', From a7ee9aeceab7d79215a9eaddbe1482855b1e9345 Mon Sep 17 00:00:00 2001 From: Amin Mahboubi Date: Thu, 2 Dec 2021 12:16:34 +0100 Subject: [PATCH 49/49] test client --- test/unit/client.js | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/test/unit/client.js b/test/unit/client.js index 8866a79f4..1814128db 100644 --- a/test/unit/client.js +++ b/test/unit/client.js @@ -1,9 +1,12 @@ import chai from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; import { generateMsg } from './test-utils/generateMessage'; import { getClientWithUser } from './test-utils/getClient'; +import * as utils from '../../src/utils'; import { StreamChat } from '../../src/client'; +import { ConnectionState } from '../../src/connection_fallback'; const expect = chai.expect; chai.use(chaiAsPromised); @@ -169,6 +172,14 @@ describe('Client connectUser', () => { const connection = await client.connectUser({ id: 'amin' }, 'token'); expect(connection).to.equal('openConnection'); }); + + it('_getConnectionID, _hasConnectionID', () => { + expect(client._hasConnectionID()).to.be.false; + expect(client._getConnectionID()).to.equal(undefined); + client.wsConnection = { connectionID: 'ID' }; + expect(client._getConnectionID()).to.equal('ID'); + expect(client._hasConnectionID()).to.be.true; + }); }); describe('Detect node environment', () => { @@ -320,3 +331,68 @@ describe('Client setLocalDevice', async () => { expect(() => client.setLocalDevice({ id: 'id3', push_provider: 'firebase' })).to.throw(); }); }); + +describe('Client WSFallback', () => { + let client; + const userToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiYW1pbiJ9.1R88K_f1CC2yrR6j1_OzMEbasfS_dxRSNbundEDBlJI'; + beforeEach(() => { + sinon.restore(); + client = new StreamChat('', { allowServerSideConnect: true, enableWSFallback: true }); + client.defaultWSTimeout = 500; + client.defaultWSTimeoutWithFallback = 500; + }); + + it('_getConnectionID, _hasConnectionID', () => { + expect(client._hasConnectionID()).to.be.false; + expect(client._getConnectionID()).to.equal(undefined); + client.wsFallback = { connectionID: 'ID' }; + expect(client._getConnectionID()).to.equal('ID'); + expect(client._hasConnectionID()).to.be.true; + }); + + it('should try wsFallback if WebSocket fails', async () => { + const stub = sinon + .stub() + .onCall(0) + .resolves({ event: { connection_id: 'new_id' } }) + .resolves({}); + client.doAxiosRequest = stub; + client.wsBaseURL = 'ws://invalidWS.xyz'; + const health = await client.connectUser({ id: 'amin' }, userToken); + expect(health).to.be.eql({ connection_id: 'new_id' }); + expect(client.wsFallback.state).to.be.eql(ConnectionState.Connected); + expect(client.wsFallback.connectionID).to.be.eql('new_id'); + expect(client.wsFallback.consecutiveFailures).to.be.eql(0); + + expect(client.wsConnection.isHealthy).to.be.false; + expect(client.wsConnection.isDisconnected).to.be.true; + expect(client.wsConnection.connectionID).to.be.undefined; + expect(client.wsConnection.totalFailures).to.be.greaterThan(1); + await client.disconnectUser(); + expect(client.wsFallback.state).to.be.eql(ConnectionState.Disconnected); + }); + + it('should ignore fallback if flag is false', async () => { + client.wsBaseURL = 'ws://invalidWS.xyz'; + client.options.enableWSFallback = false; + + await expect(client.connectUser({ id: 'amin' }, userToken)).to.be.rejectedWith( + /"initial WS connection could not be established","isWSFailure":true/, + ); + + expect(client.wsFallback).to.be.undefined; + }); + + it('should ignore fallback if browser is offline', async () => { + client.wsBaseURL = 'ws://invalidWS.xyz'; + client.options.enableWSFallback = true; + sinon.stub(utils, 'isOnline').returns(false); + + await expect(client.connectUser({ id: 'amin' }, userToken)).to.be.rejectedWith( + /"initial WS connection could not be established","isWSFailure":true/, + ); + + expect(client.wsFallback).to.be.undefined; + }); +});