stream-chat
Version:
JS SDK for the Stream Chat API
695 lines (608 loc) • 22.2 kB
text/typescript
import WebSocket from 'isomorphic-ws';
import {
addConnectionEventListeners,
chatCodes,
convertErrorToJson,
randomId,
removeConnectionEventListeners,
retryInterval,
sleep,
} from './utils';
import {
buildWsFatalInsight,
buildWsSuccessAfterFailureInsight,
postInsights,
} from './insights';
import type { ConnectAPIResponse, ConnectionOpen, LogLevel, UR } from './types';
import type { StreamChat } from './client';
import type { APIError } from './errors';
// Type guards to check WebSocket error type
const isCloseEvent = (
res: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent,
): res is WebSocket.CloseEvent => (res as WebSocket.CloseEvent).code !== undefined;
const isErrorEvent = (
res: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent,
): res is WebSocket.ErrorEvent => (res as WebSocket.ErrorEvent).error !== undefined;
/**
* StableWSConnection - A WS connection that reconnects upon failure.
* - the browser will sometimes report that you're online or offline
* - the WS connection can break and fail (there is a 30s health check)
* - sometimes your WS connection will seem to work while the user is in fact offline
* - to speed up online/offline detection you can use the window.addEventListener('offline');
*
* There are 4 ways in which a connection can become unhealthy:
* - websocket.onerror is called
* - websocket.onclose is called
* - the health check fails and no event is received for ~40 seconds
* - the browser indicates the connection is now offline
*
* There are 2 assumptions we make about the server:
* - state can be recovered by querying the channel again
* - if the servers fails to publish a message to the client, the WS connection is destroyed
*/
export class StableWSConnection {
// global from constructor
client: StreamChat;
// local vars
connectionID?: string;
connectionOpen?: ConnectAPIResponse;
consecutiveFailures: number;
pingInterval: number;
healthCheckTimeoutRef?: NodeJS.Timeout;
isConnecting: boolean;
isDisconnected: boolean;
isHealthy: boolean;
isResolved?: boolean;
lastEvent: Date | null;
connectionCheckTimeout: number;
connectionCheckTimeoutRef?: NodeJS.Timeout;
rejectPromise?: (
reason?: Error & {
code?: string | number;
isWSFailure?: boolean;
StatusCode?: string | number;
},
) => void;
requestID: string | undefined;
resolvePromise?: (value: ConnectionOpen) => void;
totalFailures: number;
ws?: WebSocket;
wsID: number;
constructor({ client }: { client: StreamChat }) {
/** StreamChat client */
this.client = client;
/** consecutive failures influence the duration of the timeout */
this.consecutiveFailures = 0;
/** keep track of the total number of failures */
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 */
this.isHealthy = false;
/** Incremented when a new WS connection is made */
this.wsID = 1;
/** Store the last event time for health checks */
this.lastEvent = null;
/** Send a health check message every 25 seconds */
this.pingInterval = 25 * 1000;
this.connectionCheckTimeout = this.pingInterval + 10 * 1000;
addConnectionEventListeners(this.onlineStatusChanged);
}
_log(msg: string, extra: UR = {}, level: LogLevel = 'info') {
this.client.logger(level, 'connection:' + msg, { tags: ['connection'], ...extra });
}
setClient(client: StreamChat) {
this.client = client;
}
/**
* connect - Connect to the WS URL
* the default 15s timeout allows between 2~3 tries
* @return {ConnectAPIResponse<ChannelType, CommandType, UserType>} Promise that completes once the first health check message is received
*/
async connect(timeout = 15000) {
if (this.isConnecting) {
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;
this._log(`connect() - Established ws connection with healthcheck: ${healthCheck}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
this.isHealthy = false;
this.consecutiveFailures += 1;
const e = error as APIError;
if (e.code === chatCodes.TOKEN_EXPIRED && !this.client.tokenManager.isStatic()) {
this._log(
'connect() - WS failure due to expired token, so going to try to reload token and reconnect',
);
this._reconnect({ refreshToken: true });
} else if (!e.isWSFailure) {
// API rejected the connection and we should not retry
throw new Error(
JSON.stringify({
code: e.code,
StatusCode: e.StatusCode,
message: e.message,
isWSFailure: e.isWSFailure,
}),
);
}
}
return await this._waitForHealthy(timeout);
}
/**
* _waitForHealthy polls the promise connection to see if its resolved until it times out
* the default 15s timeout allows between 2~3 tries
* @param timeout duration(ms)
*/
_waitForHealthy(timeout = 15000) {
return Promise.race([
(async () => {
const interval = 50; // ms
for (let i = 0; i <= timeout; i += interval) {
try {
return await this.connectionOpen;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
if (i === timeout) {
throw new Error(
JSON.stringify({
code: error.code,
StatusCode: error.StatusCode,
message: error.message,
isWSFailure: error.isWSFailure,
}),
);
}
await sleep(interval);
}
}
})(),
(async () => {
await sleep(timeout);
this.isConnecting = false;
throw new Error(
JSON.stringify({
code: '',
StatusCode: '',
message: 'initial WS connection could not be established',
isWSFailure: true,
}),
);
})(),
]);
}
/**
* Builds and returns the url for websocket.
* @private
* @returns url string
*/
_buildUrl = () => {
const qs = this.client._buildWSPayload(this.requestID);
const token = this.client.tokenManager.getToken();
const wsUrlParams = this.client.options.wsUrlParams;
const params = new URLSearchParams(wsUrlParams);
params.set('json', qs);
params.set('api_key', this.client.key);
// it is expected that the autorization parameter exists even if
// the token is undefined, so we interpolate it to be safe
params.set('authorization', `${token}`);
params.set('stream-auth-type', this.client.getAuthType());
params.set('X-Stream-Client', this.client.getUserAgent());
return `${this.client.wsBaseURL}/connect?${params.toString()}`;
};
/**
* disconnect - Disconnect the connection and doesn't recover...
*
*/
disconnect(timeout?: number) {
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
if (this.healthCheckTimeoutRef) {
clearInterval(this.healthCheckTimeoutRef);
}
if (this.connectionCheckTimeoutRef) {
clearInterval(this.connectionCheckTimeoutRef);
}
removeConnectionEventListeners(this.onlineStatusChanged);
this.isHealthy = false;
// remove ws handlers...
if (this.ws && this.ws.removeAllListeners) {
this.ws.removeAllListeners();
}
let isClosedPromise: Promise<void>;
// and finally close...
// Assigning to local here because we will remove it from this before the
// promise resolves.
const { ws } = this;
if (ws && ws.close && ws.readyState === ws.OPEN) {
isClosedPromise = new Promise((resolve) => {
const onclose = (event: WebSocket.CloseEvent) => {
this._log(
`disconnect() - resolving isClosedPromise ${event ? 'with' : 'without'} close frame`,
{ event },
);
resolve();
};
ws.onclose = onclose;
// In case we don't receive close frame websocket server in time,
// lets not wait for more than 1 seconds.
setTimeout(onclose, timeout != null ? timeout : 1000);
});
this._log(
`disconnect() - Manually closed connection by calling client.disconnect()`,
);
ws.close(
chatCodes.WS_CLOSED_SUCCESS,
'Manually closed connection by calling client.disconnect()',
);
} else {
this._log(`disconnect() - ws connection doesn't exist or it is already closed.`);
isClosedPromise = Promise.resolve();
}
delete this.ws;
return isClosedPromise;
}
/**
* _connect - Connect to the WS endpoint
*
* @return {ConnectAPIResponse<ChannelType, CommandType, UserType>} Promise that completes once the first health check message is received
*/
async _connect() {
if (
this.isConnecting ||
(this.isDisconnected && this.client.options.enableWSFallback)
)
return; // simply ignore _connect if it's currently trying to connect
this.isConnecting = true;
this.requestID = randomId();
this.client.insightMetrics.connectionStartTimestamp = new Date().getTime();
let isTokenReady = false;
try {
this._log(`_connect() - waiting for token`);
await this.client.tokenManager.tokenReady();
isTokenReady = true;
} catch (e) {
// token provider has failed before, so try again
}
try {
if (!isTokenReady) {
this._log(`_connect() - tokenProvider failed before, so going to retry`);
await this.client.tokenManager.loadToken();
}
this._setupConnectionPromise();
const wsURL = this._buildUrl();
this._log(`_connect() - Connecting to ${wsURL}`, {
wsURL,
requestID: this.requestID,
});
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;
if (response) {
this.connectionID = response.connection_id;
if (
this.client.insightMetrics.wsConsecutiveFailures > 0 &&
this.client.options.enableInsights
) {
postInsights(
'ws_success_after_failure',
buildWsSuccessAfterFailureInsight(this as unknown as StableWSConnection),
);
this.client.insightMetrics.wsConsecutiveFailures = 0;
}
return response;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
this.isConnecting = false;
this._log(`_connect() - Error - `, error);
if (this.client.options.enableInsights) {
this.client.insightMetrics.wsConsecutiveFailures++;
this.client.insightMetrics.wsTotalFailures++;
const insights = buildWsFatalInsight(
this as unknown as StableWSConnection,
convertErrorToJson(error as Error),
);
postInsights?.('ws_fatal', insights);
}
throw error;
}
}
/**
* _reconnect - Retry the connection to WS endpoint
*
* @param {{ interval?: number; refreshToken?: boolean }} options Following options are available
*
* - `interval` {int} number of ms that function should wait before reconnecting
* - `refreshToken` {boolean} reload/refresh user token be refreshed before attempting reconnection.
*/
async _reconnect(
options: { interval?: number; refreshToken?: boolean } = {},
): Promise<void> {
this._log('_reconnect() - Initiating the reconnect');
// only allow 1 connection at the time
if (this.isConnecting || this.isHealthy) {
this._log('_reconnect() - Abort (1) since already connecting or healthy');
return;
}
// reconnect in case of on error or on close
// also reconnect if the health check cycle fails
let interval = options.interval;
if (!interval) {
interval = retryInterval(this.consecutiveFailures);
}
// reconnect, or try again after a little while...
await sleep(interval);
// Check once again if by some other call to _reconnect is active or connection is
// already restored, then no need to proceed.
if (this.isConnecting || this.isHealthy) {
this._log('_reconnect() - Abort (2) since already connecting or healthy');
return;
}
if (this.isDisconnected && this.client.options.enableWSFallback) {
this._log('_reconnect() - Abort (3) since disconnect() is called');
return;
}
this._log('_reconnect() - Destroying current WS connection');
// cleanup the old connection
this._destroyCurrentWSConnection();
if (options.refreshToken) {
await this.client.tokenManager.loadToken();
}
try {
await this._connect();
this._log('_reconnect() - Waiting for recoverCallBack');
await this.client.recoverState();
this._log('_reconnect() - Finished recoverCallBack');
this.consecutiveFailures = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
this.isHealthy = false;
this.consecutiveFailures += 1;
if (
error.code === chatCodes.TOKEN_EXPIRED &&
!this.client.tokenManager.isStatic()
) {
this._log(
'_reconnect() - WS failure due to expired token, so going to try to reload token and reconnect',
);
return this._reconnect({ refreshToken: true });
}
// reconnect on WS failures, don't reconnect if there is a code bug
if (error.isWSFailure) {
this._log('_reconnect() - WS failure, so going to try to reconnect');
this._reconnect();
}
}
this._log('_reconnect() - == END ==');
}
/**
* onlineStatusChanged - this function is called when the browser connects or disconnects from the internet.
*
* @param {Event} event Event with type online or offline
*
*/
onlineStatusChanged = (event: Event) => {
if (event.type === 'offline') {
// mark the connection as down
this._log('onlineStatusChanged() - Status changing to offline');
this._setHealth(false);
} else if (event.type === 'online') {
// retry right now...
// We check this.isHealthy, not sure if it's always
// smart to create a new WS connection if the old one is still up and running.
// it's possible we didn't miss any messages, so this process is just expensive and not needed.
this._log(
`onlineStatusChanged() - Status changing to online. isHealthy: ${this.isHealthy}`,
);
if (!this.isHealthy) {
this._reconnect({ interval: 10 });
}
}
};
onopen = (wsID: number) => {
if (this.wsID !== wsID) return;
this._log('onopen() - onopen callback', { wsID });
};
onmessage = (wsID: number, event: WebSocket.MessageEvent) => {
if (this.wsID !== wsID) return;
this._log('onmessage() - onmessage callback', { event, wsID });
const data = typeof event.data === 'string' ? JSON.parse(event.data) : null;
// we wait till the first message before we consider the connection open..
// the reason for this is that auth errors and similar errors trigger a ws.onopen and immediately
// after that a ws.onclose..
if (!this.isResolved && data) {
this.isResolved = true;
if (data.error) {
this.rejectPromise?.(this._errorFromWSEvent(data, false));
return;
}
this.resolvePromise?.(data);
this._setHealth(true);
}
// trigger the event..
this.lastEvent = new Date();
if (data && data.type === 'health.check') {
this.scheduleNextPing();
}
this.client.handleEvent(event);
this.scheduleConnectionCheck();
};
onclose = (wsID: number, event: WebSocket.CloseEvent) => {
if (this.wsID !== wsID) return;
this._log('onclose() - onclose callback - ' + event.code, { event, wsID });
if (event.code === chatCodes.WS_CLOSED_SUCCESS) {
// this is a permanent error raised by stream..
// usually caused by invalid auth details
const error = new Error(
`WS connection reject with error ${event.reason}`,
) as Error & WebSocket.CloseEvent;
error.reason = event.reason;
error.code = event.code;
error.wasClean = event.wasClean;
error.target = event.target;
this.rejectPromise?.(error);
this._log(`onclose() - WS connection reject with error ${event.reason}`, { event });
} else {
this.consecutiveFailures += 1;
this.totalFailures += 1;
this._setHealth(false);
this.isConnecting = false;
this.rejectPromise?.(this._errorFromWSEvent(event));
this._log(`onclose() - WS connection closed. Calling reconnect ...`, { event });
// reconnect if its an abnormal failure
this._reconnect();
}
};
onerror = (wsID: number, event: WebSocket.ErrorEvent) => {
if (this.wsID !== wsID) return;
this.consecutiveFailures += 1;
this.totalFailures += 1;
this._setHealth(false);
this.isConnecting = false;
this.rejectPromise?.(this._errorFromWSEvent(event));
this._log(`onerror() - WS connection resulted into error`, { event });
this._reconnect();
};
/**
* _setHealth - Sets the connection to healthy or unhealthy.
* Broadcasts an event in case the connection status changed.
*
* @param {boolean} healthy boolean indicating if the connection is healthy or not
*
*/
_setHealth = (healthy: boolean) => {
if (healthy === this.isHealthy) return;
this.isHealthy = healthy;
if (this.isHealthy) {
this.client.dispatchEvent({ type: 'connection.changed', online: this.isHealthy });
return;
}
// we're offline, wait few seconds and fire and event if still offline
setTimeout(() => {
if (this.isHealthy) return;
this.client.dispatchEvent({ type: 'connection.changed', online: this.isHealthy });
}, 5000);
};
/**
* _errorFromWSEvent - Creates an error object for the WS event
*
*/
_errorFromWSEvent = (
event: WebSocket.CloseEvent | WebSocket.Data | WebSocket.ErrorEvent,
isWSFailure = true,
) => {
let code;
let statusCode;
let message;
if (isCloseEvent(event)) {
code = event.code;
statusCode = 'unknown';
message = event.reason;
}
if (isErrorEvent(event)) {
code = event.error.code;
statusCode = event.error.StatusCode;
message = event.error.message;
}
// Keeping this `warn` level log, to avoid cluttering of error logs from ws failures.
this._log(`_errorFromWSEvent() - WS failed with code ${code}`, { event }, 'warn');
const error = new Error(
`WS failed with code ${code} and reason - ${message}`,
) as Error & {
code?: string | number;
isWSFailure?: boolean;
StatusCode?: string | number;
};
error.code = code;
/**
* StatusCode does not exist on any event types but has been left
* as is to preserve JS functionality during the TS implementation
*/
error.StatusCode = statusCode;
error.isWSFailure = isWSFailure;
return error;
};
/**
* _destroyCurrentWSConnection - Removes the current WS connection
*
*/
_destroyCurrentWSConnection() {
// increment the ID, meaning we will ignore all messages from the old
// ws connection from now on.
this.wsID += 1;
try {
this?.ws?.removeAllListeners();
this?.ws?.close();
} catch (e) {
// we don't care
}
}
/**
* _setupPromise - sets up the this.connectOpen promise
*/
_setupConnectionPromise = () => {
this.isResolved = false;
/** a promise that is resolved once ws.open is called */
this.connectionOpen = new Promise<ConnectionOpen>((resolve, reject) => {
this.resolvePromise = resolve;
this.rejectPromise = reject;
});
};
/**
* Schedules a next health check ping for websocket.
*/
scheduleNextPing = () => {
if (this.healthCheckTimeoutRef) {
clearTimeout(this.healthCheckTimeoutRef);
}
// 30 seconds is the recommended interval (messenger uses this)
this.healthCheckTimeoutRef = setTimeout(() => {
// send the healthcheck.., server replies with a health check event
const data = [{ type: 'health.check', client_id: this.client.clientID }];
// try to send on the connection
try {
this.ws?.send(JSON.stringify(data));
} catch (e) {
// error will already be detected elsewhere
}
}, this.pingInterval);
};
/**
* scheduleConnectionCheck - schedules a check for time difference between last received event and now.
* If the difference is more than 35 seconds, it means our health check logic has failed and websocket needs
* to be reconnected.
*/
scheduleConnectionCheck = () => {
if (this.connectionCheckTimeoutRef) {
clearTimeout(this.connectionCheckTimeoutRef);
}
this.connectionCheckTimeoutRef = setTimeout(() => {
const now = new Date();
if (
this.lastEvent &&
now.getTime() - this.lastEvent.getTime() > this.connectionCheckTimeout
) {
this._log('scheduleConnectionCheck - going to reconnect');
this._setHealth(false);
this._reconnect();
}
}, this.connectionCheckTimeout);
};
}