UNPKG

stream-chat

Version:

JS SDK for the Stream Chat API

214 lines (182 loc) 6.42 kB
import type { AxiosRequestConfig, CancelTokenSource } from 'axios'; import axios from 'axios'; import type { StreamChat } from './client'; import { addConnectionEventListeners, removeConnectionEventListeners, retryInterval, sleep, } from './utils'; import { isAPIError, isConnectionIDError, isErrorRetryable } from './errors'; import type { ConnectionOpen, Event, LogLevel, UR } from './types'; export enum ConnectionState { Closed = 'CLOSED', Connected = 'CONNECTED', Connecting = 'CONNECTING', Disconnected = 'DISCONNECTED', Init = 'INIT', } export class WSConnectionFallback { client: StreamChat; state: ConnectionState; consecutiveFailures: number; connectionID?: string; cancelToken?: CancelTokenSource; constructor({ client }: { client: StreamChat }) { this.client = client; this.state = ConnectionState.Init; this.consecutiveFailures = 0; 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}`); // transition from connecting => connected if ( this.state === ConnectionState.Connecting && state === ConnectionState.Connected ) { this.client.dispatchEvent({ type: 'connection.changed', online: true }); } if (state === ConnectionState.Closed || state === ConnectionState.Disconnected) { this.client.dispatchEvent({ type: 'connection.changed', online: false }); } this.state = state; } /** @private */ _onlineStatusChanged = (event: { type: string }) => { this._log(`_onlineStatusChanged() - ${event.type}`); 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 <T = UR>( params: UR, config: AxiosRequestConfig, retry: boolean, ): Promise<T> => { if (!this.cancelToken && !params.close) { this.cancelToken = axios.CancelToken.source(); } try { const res = await this.client.doAxiosRequest<T>( '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; // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { this.consecutiveFailures += 1; if (retry && isErrorRetryable(error)) { this._log(`_req() - Retryable error, retrying request`); await sleep(retryInterval(this.consecutiveFailures)); return this._req<T>(params, config, retry); } throw error; } }; /** @private */ _poll = async () => { while (this.state === ConnectionState.Connected) { try { const data = await this._req<{ events: Event[]; }>({}, { 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++) { this.client.dispatchEvent(data.events[i]); } } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if (axios.isCancel(error)) { this._log(`_poll() - axios canceled request`); return; } /** client.doAxiosRequest will take care of TOKEN_EXPIRED error */ if (isConnectionIDError(error)) { this._log(`_poll() - ConnectionID error, connecting without ID...`); this._setState(ConnectionState.Disconnected); this.connect(true); return; } if (isAPIError(error) && !isErrorRetryable(error)) { this._setState(ConnectionState.Closed); return; } await sleep(retryInterval(this.consecutiveFailures)); } } }; /** * connect try to open a longpoll request * @param reconnect should be false for first call and true for subsequent calls to keep the connection alive and call recoverState */ connect = async (reconnect = false) => { if (this.state === ConnectionState.Connecting) { this._log('connect() - connecting already in progress', { reconnect }, 'warn'); return; } if (this.state === ConnectionState.Connected) { this._log('connect() - already connected and polling', { reconnect }, 'warn'); return; } 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 }>( { json: this.client._buildWSPayload() }, { timeout: 8000 }, // 8s reconnect, ); this._setState(ConnectionState.Connected); this.connectionID = event.connection_id; // @ts-expect-error type mismatch this.client.dispatchEvent(event); this._poll(); if (reconnect) { this.client.recoverState(); } return event; } catch (err) { this._setState(ConnectionState.Closed); throw err; } }; /** * isHealthy checks if there is a connectionID and connection is in Connected state */ isHealthy = () => !!this.connectionID && this.state === ConnectionState.Connected; disconnect = async (timeout = 2000) => { removeConnectionEventListeners(this._onlineStatusChanged); this._setState(ConnectionState.Disconnected); this.cancelToken?.cancel('disconnect() is called'); this.cancelToken = undefined; const connection_id = this.connectionID; this.connectionID = undefined; try { await this._req({ close: true, connection_id }, { timeout }, false); this._log(`disconnect() - Closed connectionID`); } catch (err) { this._log(`disconnect() - Failed`, { err }, 'error'); } }; }