stream-chat
Version:
JS SDK for the Stream Chat API
1,624 lines (1,468 loc) • 145 kB
text/typescript
/* eslint no-unused-vars: "off" */
/* global process */
import type { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
import axios from 'axios';
import https from 'https';
import type WebSocket from 'isomorphic-ws';
import { Channel } from './channel';
import { ClientState } from './client_state';
import { StableWSConnection } from './connection';
import { CheckSignature, DevToken, JWTUserToken } from './signing';
import { TokenManager } from './token_manager';
import { WSConnectionFallback } from './connection_fallback';
import { Campaign } from './campaign';
import { Segment } from './segment';
import { isErrorResponse, isWSFailure } from './errors';
import {
addFileToFormData,
axiosParamsSerializer,
chatCodes,
generateChannelTempCid,
isFunction,
isOnline,
isOwnUserBaseProperty,
messageSetPagination,
normalizeQuerySort,
randomId,
retryInterval,
sleep,
toUpdatedMessagePayload,
} from './utils';
import type {
ActiveLiveLocationsAPIResponse,
APIErrorResponse,
APIResponse,
AppSettings,
AppSettingsAPIResponse,
BannedUsersFilters,
BannedUsersPaginationOptions,
BannedUsersResponse,
BannedUsersSort,
BanUserOptions,
BaseDeviceFields,
BlockList,
BlockListResponse,
BlockUserAPIResponse,
CampaignData,
CampaignFilters,
CampaignQueryOptions,
CampaignResponse,
CampaignSort,
CastVoteAPIResponse,
ChannelAPIResponse,
ChannelData,
ChannelFilters,
ChannelMute,
ChannelOptions,
ChannelResponse,
ChannelSort,
ChannelStateOptions,
CheckPushResponse,
CheckSNSResponse,
CheckSQSResponse,
Configs,
ConnectAPIResponse,
CreateChannelOptions,
CreateChannelResponse,
CreateCommandOptions,
CreateCommandResponse,
CreateImportOptions,
CreateImportResponse,
CreateImportURLResponse,
CreatePollAPIResponse,
CreatePollData,
CreatePollOptionAPIResponse,
CreateReminderOptions,
CustomPermissionOptions,
DeactivateUsersOptions,
DeleteChannelsResponse,
DeleteCommandResponse,
DeleteUserOptions,
Device,
DeviceIdentifier,
DraftFilters,
DraftSort,
EndpointName,
Event,
EventHandler,
ExportChannelOptions,
ExportChannelRequest,
ExportChannelResponse,
ExportChannelStatusResponse,
ExportUsersRequest,
ExportUsersResponse,
FlagMessageResponse,
FlagReportsFilters,
FlagReportsPaginationOptions,
FlagReportsResponse,
FlagsFilters,
FlagsPaginationOptions,
FlagsResponse,
FlagUserResponse,
GetBlockedUsersAPIResponse,
GetCampaignOptions,
GetChannelTypeResponse,
GetCommandResponse,
GetHookEventsResponse,
GetImportResponse,
GetMessageAPIResponse,
GetMessageOptions,
GetPollAPIResponse,
GetPollOptionAPIResponse,
GetRateLimitsResponse,
GetThreadAPIResponse,
GetThreadOptions,
GetUnreadCountAPIResponse,
GetUnreadCountBatchAPIResponse,
ListChannelResponse,
ListCommandsResponse,
ListImportsPaginationOptions,
ListImportsResponse,
LocalMessage,
Logger,
MarkChannelsReadOptions,
MessageFilters,
MessageFlagsFilters,
MessageFlagsPaginationOptions,
MessageFlagsResponse,
MessageResponse,
Mute,
MuteUserOptions,
MuteUserResponse,
NewMemberPayload,
OGAttachment,
OwnUserResponse,
Pager,
PartialMessageUpdate,
PartialPollUpdate,
PartialThreadUpdate,
PartialUserUpdate,
PermissionAPIResponse,
PermissionsAPIResponse,
PollAnswersAPIResponse,
PollData,
PollOptionData,
PollSort,
PollVote,
PollVoteData,
PollVotesAPIResponse,
Product,
PushPreference,
PushProvider,
PushProviderConfig,
PushProviderID,
PushProviderListResponse,
PushProviderUpsertResponse,
QueryChannelsAPIResponse,
QueryDraftsResponse,
QueryMessageHistoryFilters,
QueryMessageHistoryOptions,
QueryMessageHistoryResponse,
QueryMessageHistorySort,
QueryPollsFilters,
QueryPollsOptions,
QueryPollsResponse,
QueryReactionsAPIResponse,
QueryReactionsOptions,
QueryRemindersOptions,
QueryRemindersResponse,
QuerySegmentsOptions,
QuerySegmentTargetsFilter,
QueryThreadsAPIResponse,
QueryThreadsOptions,
QueryVotesFilters,
QueryVotesOptions,
ReactionFilters,
ReactionResponse,
ReactionSort,
ReactivateUserOptions,
ReactivateUsersOptions,
ReminderAPIResponse,
ReviewFlagReportOptions,
ReviewFlagReportResponse,
SdkIdentifier,
SearchAPIResponse,
SearchMessageSortBase,
SearchOptions,
SearchPayload,
SegmentData,
SegmentResponse,
SegmentTargetsResponse,
SegmentType,
SendFileAPIResponse,
SharedLocationResponse,
SortParam,
StreamChatOptions,
SyncOptions,
SyncResponse,
TaskResponse,
TaskStatus,
TestPushDataInput,
TestSNSDataInput,
TestSQSDataInput,
TokenOrProvider,
TranslateResponse,
UnBanUserOptions,
UpdateChannelTypeRequest,
UpdateChannelTypeResponse,
UpdateCommandOptions,
UpdateCommandResponse,
UpdateLocationPayload,
UpdateMessageAPIResponse,
UpdateMessageOptions,
UpdatePollAPIResponse,
UpdatePollOptionAPIResponse,
UpdateReminderOptions,
UpdateSegmentData,
UpsertPushPreferencesResponse,
UserCustomEvent,
UserFilters,
UserOptions,
UserResponse,
UserSort,
VoteSort,
} from './types';
import { ErrorFromResponse } from './types';
import { InsightMetrics, postInsights } from './insights';
import { Thread } from './thread';
import { Moderation } from './moderation';
import { ThreadManager } from './thread_manager';
import { DEFAULT_QUERY_CHANNELS_MESSAGE_LIST_PAGE_SIZE } from './constants';
import { PollManager } from './poll_manager';
import type {
ChannelManagerEventHandlerOverrides,
ChannelManagerOptions,
QueryChannelsRequestType,
} from './channel_manager';
import { ChannelManager } from './channel_manager';
import { NotificationManager } from './notifications';
import { ReminderManager } from './reminders';
import { StateStore } from './store';
import type { MessageComposer } from './messageComposer';
import type { AbstractOfflineDB } from './offline-support';
function isString(x: unknown): x is string {
return typeof x === 'string' || x instanceof String;
}
type MessageComposerTearDownFunction = () => void;
type MessageComposerSetupFunction = ({
composer,
}: {
composer: MessageComposer;
}) => void | MessageComposerTearDownFunction;
export type MessageComposerSetupState = {
/**
* Each `MessageComposer` runs this function each time its signature changes or
* whenever you run `MessageComposer.registerSubscriptions`. Function returned
* from `applyModifications` will be used as a cleanup function - it will be stored
* and ran before new modification is applied. Cleaning up only the
* modified parts is the general way to go but if your setup gets a bit
* complicated, feel free to restore the whole composer with `MessageComposer.restore`.
*/
setupFunction: MessageComposerSetupFunction | null;
};
export class StreamChat {
private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics
_user?: OwnUserResponse | UserResponse;
appSettingsPromise?: Promise<AppSettingsAPIResponse>;
activeChannels: {
[key: string]: Channel;
};
threads: ThreadManager;
polls: PollManager;
offlineDb?: AbstractOfflineDB;
notifications: NotificationManager;
reminders: ReminderManager;
anonymous: boolean;
persistUserOnConnectionFailure?: boolean;
axiosInstance: AxiosInstance;
baseURL?: string;
browser: boolean;
cleaningIntervalRef?: NodeJS.Timeout;
clientID?: string;
configs: Configs;
key: string;
listeners: Record<string, Array<(event: Event) => void>>;
logger: Logger;
/**
* When network is recovered, we re-query the active channels on client. But in single query, you can recover
* only 30 channels. So its not guaranteed that all the channels in activeChannels object have updated state.
* Thus in UI sdks, state recovery is managed by components themselves, they don't rely on js client for this.
*
* `recoverStateOnReconnect` parameter can be used in such cases, to disable state recovery within js client.
* When false, user/consumer of this client will need to make sure all the channels present on UI by
* manually calling queryChannels endpoint.
*/
recoverStateOnReconnect?: boolean;
moderation: Moderation;
mutedChannels: ChannelMute[];
mutedUsers: Mute[];
node: boolean;
options: StreamChatOptions;
secret?: string;
setUserPromise: ConnectAPIResponse | null;
state: ClientState;
tokenManager: TokenManager;
user?: OwnUserResponse | UserResponse;
userAgent?: string;
userID?: string;
wsBaseURL?: string;
wsConnection: StableWSConnection | null;
wsFallback?: WSConnectionFallback;
wsPromise: ConnectAPIResponse | null;
consecutiveFailures: number;
insightMetrics: InsightMetrics;
defaultWSTimeoutWithFallback: number;
defaultWSTimeout: number;
sdkIdentifier?: SdkIdentifier;
deviceIdentifier?: DeviceIdentifier;
private nextRequestAbortController: AbortController | null = null;
/**
* @private
*/
_messageComposerSetupState = new StateStore<MessageComposerSetupState>({
setupFunction: null,
});
/**
* Initialize a client
*
* **Only use constructor for advanced usages. It is strongly advised to use `StreamChat.getInstance()` instead of `new StreamChat()` to reduce integration issues due to multiple WebSocket connections**
* @param {string} key - the api key
* @param {string} [secret] - the api secret
* @param {StreamChatOptions} [options] - additional options, here you can pass custom options to axios instance
* @param {boolean} [options.browser] - enforce the client to be in browser mode
* @param {boolean} [options.warmUp] - default to false, if true, client will open a connection as soon as possible to speed up following requests
* @param {Logger} [options.Logger] - custom logger
* @param {number} [options.timeout] - default to 3000
* @param {httpsAgent} [options.httpsAgent] - custom httpsAgent, in node it's default to https.agent()
* @example <caption>initialize the client in user mode</caption>
* new StreamChat('api_key')
* @example <caption>initialize the client in user mode with options</caption>
* new StreamChat('api_key', { warmUp:true, timeout:5000 })
* @example <caption>secret is optional and only used in server side mode</caption>
* new StreamChat('api_key', "secret", { httpsAgent: customAgent })
*/
constructor(key: string, options?: StreamChatOptions);
constructor(key: string, secret?: string, options?: StreamChatOptions);
constructor(
key: string,
secretOrOptions?: StreamChatOptions | string,
options?: StreamChatOptions,
) {
// set the key
this.key = key;
this.listeners = {};
this.state = new ClientState({ client: this });
// a list of channels to hide ws events from
this.mutedChannels = [];
this.mutedUsers = [];
this.moderation = new Moderation(this);
this.notifications = options?.notifications ?? new NotificationManager();
// set the secret
if (secretOrOptions && isString(secretOrOptions)) {
this.secret = secretOrOptions;
}
// set the options... and figure out defaults...
const inputOptions = options
? options
: secretOrOptions && !isString(secretOrOptions)
? secretOrOptions
: {};
this.browser =
typeof inputOptions.browser !== 'undefined'
? inputOptions.browser
: typeof window !== 'undefined';
this.node = !this.browser;
this.options = {
timeout: 3000,
withCredentials: false, // making sure cookies are not sent
warmUp: false,
recoverStateOnReconnect: true,
disableCache: false,
wsUrlParams: new URLSearchParams({}),
...inputOptions,
};
if (this.node && !this.options.httpsAgent) {
this.options.httpsAgent = new https.Agent({
keepAlive: true,
keepAliveMsecs: 3000,
});
}
this.axiosInstance = axios.create(this.options);
this.setBaseURL(this.options.baseURL || 'https://chat.stream-io-api.com');
if (
typeof process !== 'undefined' &&
'env' in process &&
process.env.STREAM_LOCAL_TEST_RUN
) {
this.setBaseURL('http://localhost:3030');
}
if (
typeof process !== 'undefined' &&
'env' in process &&
process.env.STREAM_LOCAL_TEST_HOST
) {
this.setBaseURL('http://' + process.env.STREAM_LOCAL_TEST_HOST);
}
// WS connection is initialized when setUser is called
this.wsConnection = null;
this.wsPromise = null;
this.setUserPromise = null;
// keeps a reference to all the channels that are in use
this.activeChannels = {};
// mapping between channel groups and configs
this.configs = {};
this.anonymous = false;
this.persistUserOnConnectionFailure = this.options?.persistUserOnConnectionFailure;
// If its a server-side client, then lets initialize the tokenManager, since token will be
// generated from secret.
this.tokenManager = new TokenManager(this.secret);
this.consecutiveFailures = 0;
this.insightMetrics = new InsightMetrics();
this.defaultWSTimeoutWithFallback = 6 * 1000;
this.defaultWSTimeout = 15 * 1000;
this.axiosInstance.defaults.paramsSerializer = axiosParamsSerializer;
/**
* logger function should accept 3 parameters:
* @param logLevel string
* @param message string
* @param extraData object
*
* e.g.,
* const client = new StreamChat('api_key', {}, {
* logger = (logLevel, message, extraData) => {
* console.log(message);
* }
* })
*
* extraData contains tags array attached to log message. Tags can have one/many of following values:
* 1. api
* 2. api_request
* 3. api_response
* 4. client
* 5. channel
* 6. connection
* 7. event
*
* It may also contains some extra data, some examples have been mentioned below:
* 1. {
* tags: ['api', 'api_request', 'client'],
* url: string,
* payload: object,
* config: object
* }
* 2. {
* tags: ['api', 'api_response', 'client'],
* url: string,
* response: object
* }
* 3. {
* tags: ['api', 'api_response', 'client'],
* url: string,
* error: object
* }
* 4. {
* tags: ['event', 'client'],
* event: object
* }
* 5. {
* tags: ['channel'],
* channel: object
* }
*/
this.logger = isFunction(inputOptions.logger) ? inputOptions.logger : () => null;
this.recoverStateOnReconnect = this.options.recoverStateOnReconnect;
this.threads = new ThreadManager({ client: this });
this.polls = new PollManager({ client: this });
this.reminders = new ReminderManager({ client: this });
}
/**
* Get a client instance
*
* This function always returns the same Client instance to avoid issues raised by multiple Client and WS connections
*
* **After the first call, the client configuration will not change if the key or options parameters change**
*
* @param {string} key - the api key
* @param {string} [secret] - the api secret
* @param {StreamChatOptions} [options] - additional options, here you can pass custom options to axios instance
* @param {boolean} [options.browser] - enforce the client to be in browser mode
* @param {boolean} [options.warmUp] - default to false, if true, client will open a connection as soon as possible to speed up following requests
* @param {Logger} [options.Logger] - custom logger
* @param {number} [options.timeout] - default to 3000
* @param {httpsAgent} [options.httpsAgent] - custom httpsAgent, in node it's default to https.agent()
* @example <caption>initialize the client in user mode</caption>
* StreamChat.getInstance('api_key')
* @example <caption>initialize the client in user mode with options</caption>
* StreamChat.getInstance('api_key', { timeout:5000 })
* @example <caption>secret is optional and only used in server side mode</caption>
* StreamChat.getInstance('api_key', "secret", { httpsAgent: customAgent })
*/
public static getInstance(key: string, options?: StreamChatOptions): StreamChat;
public static getInstance(
key: string,
secret?: string,
options?: StreamChatOptions,
): StreamChat;
public static getInstance(
key: string,
secretOrOptions?: StreamChatOptions | string,
options?: StreamChatOptions,
): StreamChat {
if (!StreamChat._instance) {
if (typeof secretOrOptions === 'string') {
StreamChat._instance = new StreamChat(key, secretOrOptions, options);
} else {
StreamChat._instance = new StreamChat(key, secretOrOptions);
}
}
return StreamChat._instance as StreamChat;
}
setOfflineDBApi(offlineDBInstance: AbstractOfflineDB) {
if (this.offlineDb) {
return;
}
this.offlineDb = offlineDBInstance;
}
devToken(userID: string) {
return DevToken(userID);
}
getAuthType() {
return this.anonymous ? 'anonymous' : 'jwt';
}
setBaseURL(baseURL: string) {
this.baseURL = baseURL;
this.wsBaseURL = this.baseURL.replace('http', 'ws').replace(':3030', ':8800');
}
_getConnectionID = () =>
this.wsConnection?.connectionID || this.wsFallback?.connectionID;
_hasConnectionID = () => Boolean(this._getConnectionID());
public setMessageComposerSetupFunction = (
setupFunction: MessageComposerSetupState['setupFunction'],
) => {
this._messageComposerSetupState.partialNext({ setupFunction });
};
/**
* connectUser - Set the current user and open a WebSocket connection
*
* @param {OwnUserResponse | UserResponse} user Data about this user. IE {name: "john"}
* @param {TokenOrProvider} userTokenOrProvider Token or provider
*
* @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup
*/
connectUser = async (
user: OwnUserResponse | UserResponse,
userTokenOrProvider: TokenOrProvider,
) => {
if (!user.id) {
throw new Error('The "id" field on the user is missing');
}
/**
* Calling connectUser multiple times is potentially the result of a bad integration, however,
* If the user id remains the same we don't throw error
*/
if (this.userID === user.id && this.setUserPromise) {
console.warn(
'Consecutive calls to connectUser is detected, ideally you should only call this function once in your app.',
);
return this.setUserPromise;
}
if (this.userID) {
throw new Error(
'Use client.disconnect() before trying to connect as a different user. connectUser was called twice.',
);
}
if (
(this._isUsingServerAuth() || this.node) &&
!this.options.allowServerSideConnect
) {
console.warn(
'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.',
);
}
// we generate the client id client side
this.userID = user.id;
this.anonymous = false;
const setTokenPromise = this._setToken(user, userTokenOrProvider);
this._setUser(user);
const wsPromise = this.openConnection();
this.setUserPromise = Promise.all([setTokenPromise, wsPromise]).then(
(result) => result[1], // We only return connection promise;
);
try {
return await this.setUserPromise;
} catch (err) {
if (this.persistUserOnConnectionFailure) {
// cleanup client to allow the user to retry connectUser again
this.closeConnection();
} else {
this.disconnectUser();
}
throw err;
}
};
/**
* @deprecated Please use connectUser() function instead. Its naming is more consistent with its functionality.
*
* setUser - Set the current user and open a WebSocket connection
*
* @param {OwnUserResponse | UserResponse} user Data about this user. IE {name: "john"}
* @param {TokenOrProvider} userTokenOrProvider Token or provider
*
* @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup
*/
setUser = this.connectUser;
_setToken = (user: UserResponse, userTokenOrProvider: TokenOrProvider) =>
this.tokenManager.setTokenOrProvider(userTokenOrProvider, user);
_setUser(user: OwnUserResponse | UserResponse) {
/**
* This one is used by the frontend. This is a copy of the current user object stored on backend.
* It contains reserved properties and own user properties which are not present in `this._user`.
*/
this.user = user;
this.userID = user.id;
// this one is actually used for requests. This is a copy of current user provided to `connectUser` function.
this._user = { ...user };
}
/**
* Disconnects the websocket connection, without removing the user set on client.
* client.closeConnection will not trigger default auto-retry mechanism for reconnection. You need
* to call client.openConnection to reconnect to websocket.
*
* This is mainly useful on mobile side. You can only receive push notifications
* if you don't have active websocket connection.
* So when your app goes to background, you can call `client.closeConnection`.
* And when app comes back to foreground, call `client.openConnection`.
*
* @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 = async (timeout?: number) => {
if (this.cleaningIntervalRef != null) {
clearInterval(this.cleaningIntervalRef);
this.cleaningIntervalRef = undefined;
}
await Promise.all([
this.wsConnection?.disconnect(timeout),
this.wsFallback?.disconnect(timeout),
]);
this.offlineDb?.executeQuerySafely(
async (db) => {
if (this.userID) {
await db.upsertUserSyncStatus({
userId: this.userID,
lastSyncedAt: new Date().toString(),
});
}
},
{ method: 'upsertUserSyncStatus' },
);
return Promise.resolve();
};
/**
* Creates an instance of ChannelManager.
*
* @internal
*
* @param eventHandlerOverrides - the overrides for event handlers to be used
* @param options - the options used for the channel manager
*/
createChannelManager = ({
eventHandlerOverrides = {},
options = {},
queryChannelsOverride,
}: {
eventHandlerOverrides?: ChannelManagerEventHandlerOverrides;
options?: ChannelManagerOptions;
queryChannelsOverride?: QueryChannelsRequestType;
}) =>
new ChannelManager({
client: this,
eventHandlerOverrides,
options,
queryChannelsOverride,
});
/**
* Creates a new WebSocket connection with the current user. Returns empty promise, if there is an active connection
*/
openConnection = () => {
if (!this.userID) {
throw Error(
'User is not set on client, use client.connectUser or client.connectAnonymousUser instead',
);
}
if (this.wsConnection?.isConnecting && this.wsPromise) {
this.logger('info', 'client:openConnection() - connection already in progress', {
tags: ['connection', 'client'],
});
return this.wsPromise;
}
if (
(this.wsConnection?.isHealthy || this.wsFallback?.isHealthy()) &&
this._hasConnectionID()
) {
this.logger(
'info',
'client:openConnection() - openConnection called twice, healthy connection already exists',
{
tags: ['connection', 'client'],
},
);
return;
}
this.clientID = `${this.userID}--${randomId()}`;
this.wsPromise = this.connect();
this._startCleaning();
return this.wsPromise;
};
/**
* @deprecated Please use client.openConnction instead.
* @private
*
* Creates a new websocket connection with current user.
*/
_setupConnection = this.openConnection;
/**
* updateAppSettings - updates application settings
*
* @param {AppSettings} options App settings.
* IE: {
'apn_config': {
'auth_type': 'token',
'auth_key": fs.readFileSync(
'./apn-push-auth-key.p8',
'utf-8',
),
'key_id': 'keyid',
'team_id': 'teamid',
'notification_template": 'notification handlebars template',
'bundle_id': 'com.apple.your.app',
'development': true
},
'firebase_config': {
'server_key': 'server key from fcm',
'notification_template': 'notification handlebars template',
'data_template': 'data handlebars template',
'apn_template': 'apn notification handlebars template under v2'
},
'webhook_url': 'https://acme.com/my/awesome/webhook/',
'event_hooks': [
{
'hook_type': 'webhook',
'enabled': true,
'event_types': ['message.new'],
'webhook_url': 'https://acme.com/my/awesome/webhook/'
},
{
'hook_type': 'sqs',
'enabled': true,
'event_types': ['message.new'],
'sqs_url': 'https://sqs.us-east-1.amazonaws.com/1234567890/my-queue',
'sqs_auth_type': 'key',
'sqs_key': 'my-access-key',
'sqs_secret': 'my-secret-key'
}
]
}
*/
async updateAppSettings(options: AppSettings) {
const apn_config = options.apn_config;
if (apn_config?.p12_cert) {
options = {
...options,
apn_config: {
...apn_config,
p12_cert: Buffer.from(apn_config.p12_cert).toString('base64'),
},
};
}
return await this.patch<APIResponse>(this.baseURL + '/app', options);
}
_normalizeDate = (before: Date | string | null): string | null => {
if (before instanceof Date) {
before = before.toISOString();
}
if (before === '') {
throw new Error(
"Don't pass blank string for since, use null instead if resetting the token revoke",
);
}
return before;
};
/**
* Revokes all tokens on application level issued before given time
*/
async revokeTokens(before: Date | string | null) {
return await this.updateAppSettings({
revoke_tokens_issued_before: this._normalizeDate(before),
});
}
/**
* Revokes token for a user issued before given time
*/
async revokeUserToken(userID: string, before?: Date | string | null) {
return await this.revokeUsersToken([userID], before);
}
/**
* Revokes tokens for a list of users issued before given time
*/
async revokeUsersToken(userIDs: string[], before?: Date | string | null) {
if (before === undefined) {
before = new Date().toISOString();
} else {
before = this._normalizeDate(before);
}
const users: PartialUserUpdate[] = [];
for (const userID of userIDs) {
users.push({
id: userID,
set: <Partial<UserResponse>>{
revoke_tokens_issued_before: before,
},
});
}
return await this.partialUpdateUsers(users);
}
/**
* getAppSettings - retrieves application settings
*/
async getAppSettings() {
this.appSettingsPromise = this.get<AppSettingsAPIResponse>(this.baseURL + '/app');
return await this.appSettingsPromise;
}
/**
* testPushSettings - Tests the push settings for a user with a random chat message and the configured push templates
*
* @param {string} userID User ID. If user has no devices, it will error
* @param {TestPushDataInput} [data] Overrides for push templates/message used
* IE: {
messageID: 'id-of-message', // will error if message does not exist
apnTemplate: '{}', // if app doesn't have apn configured it will error
firebaseTemplate: '{}', // if app doesn't have firebase configured it will error
firebaseDataTemplate: '{}', // if app doesn't have firebase configured it will error
skipDevices: true, // skip config/device checks and sending to real devices
pushProviderName: 'staging' // one of your configured push providers
pushProviderType: 'apn' // one of supported provider types
}
*/
async testPushSettings(userID: string, data: TestPushDataInput = {}) {
return await this.post<CheckPushResponse>(this.baseURL + '/check_push', {
user_id: userID,
...(data.messageID ? { message_id: data.messageID } : {}),
...(data.apnTemplate ? { apn_template: data.apnTemplate } : {}),
...(data.firebaseTemplate ? { firebase_template: data.firebaseTemplate } : {}),
...(data.firebaseDataTemplate
? { firebase_data_template: data.firebaseDataTemplate }
: {}),
...(data.skipDevices ? { skip_devices: true } : {}),
...(data.pushProviderName ? { push_provider_name: data.pushProviderName } : {}),
...(data.pushProviderType ? { push_provider_type: data.pushProviderType } : {}),
});
}
/**
* testSQSSettings - Tests that the given or configured SQS configuration is valid
*
* @param {TestSQSDataInput} [data] Overrides SQS settings for testing if needed
* IE: {
sqs_key: 'auth_key',
sqs_secret: 'auth_secret',
sqs_url: 'url_to_queue',
}
*/
async testSQSSettings(data: TestSQSDataInput = {}) {
return await this.post<CheckSQSResponse>(this.baseURL + '/check_sqs', data);
}
/**
* testSNSSettings - Tests that the given or configured SNS configuration is valid
*
* @param {TestSNSDataInput} [data] Overrides SNS settings for testing if needed
* IE: {
sns_key: 'auth_key',
sns_secret: 'auth_secret',
sns_topic_arn: 'topic_to_publish_to',
}
*/
async testSNSSettings(data: TestSNSDataInput = {}) {
return await this.post<CheckSNSResponse>(this.baseURL + '/check_sns', data);
}
/**
* Disconnects the websocket and removes the user from client.
*
* @param timeout Max number of ms, to wait for close event of websocket, before forcefully assuming successful disconnection.
* https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent
*/
disconnectUser = (timeout?: number) => {
this.logger('info', 'client:disconnect() - Disconnecting the client', {
tags: ['connection', 'client'],
});
// remove the user specific fields
delete this.user;
delete this._user;
delete this.userID;
this.anonymous = false;
const closePromise = this.closeConnection(timeout);
for (const channel of Object.values(this.activeChannels)) {
channel._disconnect();
}
// ensure we no longer return inactive channels
this.activeChannels = {};
// reset client state
this.state = new ClientState({ client: this });
// reset thread manager
this.threads.resetState();
// reset token manager
setTimeout(this.tokenManager.reset); // delay reseting to use token for disconnect calls
// close the WS connection
return closePromise;
};
/**
*
* @deprecated Please use client.disconnectUser instead.
*
* Disconnects the websocket and removes the user from client.
*/
disconnect = this.disconnectUser;
/**
* connectAnonymousUser - Set an anonymous user and open a WebSocket connection
*/
connectAnonymousUser = () => {
if (
(this._isUsingServerAuth() || this.node) &&
!this.options.allowServerSideConnect
) {
console.warn(
'Please do not use connectUser server side. connectUser impacts MAU and concurrent connection usage and thus your bill. If you have a valid use-case, add "allowServerSideConnect: true" to the client options to disable this warning.',
);
}
this.anonymous = true;
this.userID = randomId();
const anonymousUser = {
id: this.userID,
anon: true,
} as UserResponse;
this._setToken(anonymousUser, '');
this._setUser(anonymousUser);
return this._setupConnection();
};
/**
* @deprecated Please use connectAnonymousUser. Its naming is more consistent with its functionality.
*/
setAnonymousUser = this.connectAnonymousUser;
/**
* setGuestUser - Setup a temporary guest user
*
* @param {UserResponse} user Data about this user. IE {name: "john"}
*
* @return {ConnectAPIResponse} Returns a promise that resolves when the connection is setup
*/
async setGuestUser(user: UserResponse) {
let response: { access_token: string; user: UserResponse } | undefined;
this.anonymous = true;
try {
response = await this.post<
APIResponse & {
access_token: string;
user: UserResponse;
}
>(this.baseURL + '/guest', { user });
} catch (e) {
this.anonymous = false;
throw e;
}
this.anonymous = false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { created_at, updated_at, last_active, online, ...guestUser } = response.user;
return await this.connectUser(guestUser as UserResponse, response.access_token);
}
/**
* createToken - Creates a token to authenticate this user. This function is used server side.
* The resulting token should be passed to the client side when the users registers or logs in.
*
* @param {string} userID The User ID
* @param {number} [exp] The expiration time for the token expressed in the number of seconds since the epoch
*
* @return {string} Returns a token
*/
createToken(userID: string, exp?: number, iat?: number) {
if (this.secret == null) {
throw Error(`tokens can only be created server-side using the API Secret`);
}
const extra: { exp?: number; iat?: number } = {};
if (exp) {
extra.exp = exp;
}
if (iat) {
extra.iat = iat;
}
return JWTUserToken(this.secret, userID, extra, {});
}
/**
* on - Listen to events on all channels and users your watching
*
* client.on('message.new', event => {console.log("my new message", event, channel.state.messages)})
* or
* client.on(event => {console.log(event.type)})
*
* @param {EventHandler | string} callbackOrString The event type to listen for (optional)
* @param {EventHandler} [callbackOrNothing] The callback to call
*
* @return {{ unsubscribe: () => void }} Description
*/
on(callback: EventHandler): { unsubscribe: () => void };
on(eventType: string, callback: EventHandler): { unsubscribe: () => void };
on(
callbackOrString: EventHandler | string,
callbackOrNothing?: EventHandler,
): { unsubscribe: () => void } {
const key = callbackOrNothing ? (callbackOrString as string) : 'all';
const callback = callbackOrNothing
? callbackOrNothing
: (callbackOrString as EventHandler);
if (!(key in this.listeners)) {
this.listeners[key] = [];
}
this.logger('info', `Attaching listener for ${key} event`, {
tags: ['event', 'client'],
});
this.listeners[key].push(callback);
return {
unsubscribe: () => {
this.logger('info', `Removing listener for ${key} event`, {
tags: ['event', 'client'],
});
this.listeners[key] = this.listeners[key].filter((el) => el !== callback);
},
};
}
/**
* off - Remove the event handler
*
*/
off(callback: EventHandler): void;
off(eventType: string, callback: EventHandler): void;
off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler) {
const key = callbackOrNothing ? (callbackOrString as string) : 'all';
const callback = callbackOrNothing
? callbackOrNothing
: (callbackOrString as EventHandler);
if (!(key in this.listeners)) {
this.listeners[key] = [];
}
this.logger('info', `Removing listener for ${key} event`, {
tags: ['event', 'client'],
});
this.listeners[key] = this.listeners[key].filter((value) => value !== callback);
}
_logApiRequest(
type: string,
url: string,
data: unknown,
config: AxiosRequestConfig & {
config?: AxiosRequestConfig & { maxBodyLength?: number };
},
) {
this.logger('info', `client: ${type} - Request - ${url}`, {
tags: ['api', 'api_request', 'client'],
url,
payload: data,
config,
});
}
_logApiResponse<T>(type: string, url: string, response: AxiosResponse<T>) {
this.logger(
'info',
`client:${type} - Response - url: ${url} > status ${response.status}`,
{
tags: ['api', 'api_response', 'client'],
url,
response,
},
);
}
_logApiError(type: string, url: string, error: unknown) {
this.logger('error', `client:${type} - Error - url: ${url}`, {
tags: ['api', 'api_response', 'client'],
url,
error,
});
}
doAxiosRequest = async <T>(
type: string,
url: string,
data?: unknown,
options: AxiosRequestConfig & {
config?: AxiosRequestConfig & { maxBodyLength?: number };
} = {},
): Promise<T> => {
await this.tokenManager.tokenReady();
const requestConfig = this._enrichAxiosOptions(options);
try {
let response: AxiosResponse<T>;
this._logApiRequest(type, url, data, requestConfig);
switch (type) {
case 'get':
response = await this.axiosInstance.get(url, requestConfig);
break;
case 'delete':
response = await this.axiosInstance.delete(url, requestConfig);
break;
case 'post':
response = await this.axiosInstance.post(url, data, requestConfig);
break;
case 'postForm':
response = await this.axiosInstance.postForm(url, data, requestConfig);
break;
case 'put':
response = await this.axiosInstance.put(url, data, requestConfig);
break;
case 'patch':
response = await this.axiosInstance.patch(url, data, requestConfig);
break;
case 'options':
response = await this.axiosInstance.options(url, requestConfig);
break;
default:
throw new Error('Invalid request type');
}
this._logApiResponse<T>(type, url, response);
this.consecutiveFailures = 0;
return this.handleResponse(response);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any /**TODO: generalize error types */) {
e.client_request_id = requestConfig.headers?.['x-client-request-id'];
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));
}
this.tokenManager.loadToken();
return await this.doAxiosRequest<T>(type, url, data, options);
}
return this.handleResponse(e.response);
} else {
throw e as AxiosError<APIErrorResponse>;
}
}
};
get<T>(url: string, params?: AxiosRequestConfig['params']) {
return this.doAxiosRequest<T>('get', url, null, { params });
}
put<T>(url: string, data?: unknown) {
return this.doAxiosRequest<T>('put', url, data);
}
post<T>(url: string, data?: unknown) {
return this.doAxiosRequest<T>('post', url, data);
}
patch<T>(url: string, data?: unknown) {
return this.doAxiosRequest<T>('patch', url, data);
}
delete<T>(url: string, params?: AxiosRequestConfig['params']) {
return this.doAxiosRequest<T>('delete', url, null, { params });
}
sendFile(
url: string,
uri: string | NodeJS.ReadableStream | Buffer | File,
name?: string,
contentType?: string,
user?: UserResponse,
) {
const data = addFileToFormData(uri, name, contentType || 'multipart/form-data');
if (user != null) data.append('user', JSON.stringify(user));
return this.doAxiosRequest<SendFileAPIResponse>('postForm', url, data, {
headers: data.getHeaders ? data.getHeaders() : {}, // node vs browser
config: {
timeout: 0,
maxContentLength: Infinity,
maxBodyLength: Infinity,
},
});
}
errorFromResponse(response: AxiosResponse<APIErrorResponse>) {
const message =
typeof response.data.code !== 'undefined'
? `StreamChat error code ${response.data.code}: ${response.data.message}`
: `StreamChat error HTTP code: ${response.status}`;
return new ErrorFromResponse<APIErrorResponse>(message, {
code: response.data.code ?? null,
response,
status: response.status,
});
}
handleResponse<T>(response: AxiosResponse<T>) {
const data = response.data;
if (isErrorResponse(response)) {
throw this.errorFromResponse(response);
}
return data;
}
dispatchEvent = (event: Event) => {
if (!event.received_at) event.received_at = new Date();
// client event handlers
const postListenerCallbacks = this._handleClientEvent(event);
// channel event handlers
const cid = event.cid;
const channel = cid ? this.activeChannels[cid] : undefined;
if (channel) {
channel._handleChannelEvent(event);
}
this._callClientListeners(event);
if (channel) {
channel._callChannelListeners(event);
}
postListenerCallbacks.forEach((c) => c());
this.offlineDb?.executeQuerySafely((db) => db.handleEvent({ event }), {
method: `handleEvent;${event.type}`,
});
};
handleEvent = (messageEvent: WebSocket.MessageEvent) => {
// dispatch the event to the channel listeners
const jsonString = messageEvent.data as string;
const event = JSON.parse(jsonString) as Event;
this.dispatchEvent(event);
};
/**
* Updates the members, watchers and read references of the currently active channels that contain this user
*
* @param {UserResponse} user
*/
_updateMemberWatcherReferences = (user: UserResponse) => {
const refMap = this.state.userChannelReferences[user.id] || {};
for (const channelID in refMap) {
const channel = this.activeChannels[channelID];
if (channel?.state) {
if (channel.state.members[user.id]) {
channel.state.members[user.id].user = user;
}
if (channel.state.watchers[user.id]) {
channel.state.watchers[user.id] = user;
}
if (channel.state.read[user.id]) {
channel.state.read[user.id].user = user;
}
}
}
};
/**
* @deprecated Please _updateMemberWatcherReferences instead.
* @private
*/
_updateUserReferences = this._updateMemberWatcherReferences;
/**
* @private
*
* Updates the messages from the currently active channels that contain this user,
* with updated user object.
*
* @param {UserResponse} user
*/
_updateUserMessageReferences = (user: UserResponse) => {
const refMap = this.state.userChannelReferences[user.id] || {};
for (const channelID in refMap) {
const channel = this.activeChannels[channelID];
if (!channel) continue;
const state = channel.state;
/** update the messages from this user. */
state?.updateUserMessages(user);
}
};
/**
* @private
*
* Deletes the messages from the currently active channels that contain this user
*
* If hardDelete is true, all the content of message will be stripped down.
* Otherwise, only 'message.type' will be set as 'deleted'.
*
* @param {UserResponse} user
* @param {boolean} hardDelete
*/
_deleteUserMessageReference = (user: UserResponse, hardDelete = false) => {
const refMap = this.state.userChannelReferences[user.id] || {};
for (const channelID in refMap) {
const channel = this.activeChannels[channelID];
if (channel) {
const state = channel.state;
/** deleted the messages from this user. */
state?.deleteUserMessages(user, hardDelete);
}
}
};
/**
* @private
*
* Handle following user related events:
* - user.presence.changed
* - user.updated
* - user.deleted
*
* @param {Event} event
*/
_handleUserEvent = (event: Event) => {
if (!event.user) {
return;
}
/** update the client.state with any changes to users */
if (event.type === 'user.presence.changed' || event.type === 'user.updated') {
if (event.user.id === this.userID) {
const user = { ...this.user } as NonNullable<StreamChat['user']>;
const _user = { ...this._user } as NonNullable<StreamChat['_user']>;
// Remove deleted properties from user objects.
for (const key in this.user) {
if (key in event.user || isOwnUserBaseProperty(key)) {
continue;
}
const deleteKey = key as keyof typeof user;
delete user[deleteKey];
delete _user[deleteKey];
}
/** Updating only available properties in _user object. */
for (const key in _user) {
const updateKey = key as keyof typeof _user;
if (updateKey in event.user) {
// @ts-expect-error it has an issue with this, not sure why
_user[updateKey] = event.user[updateKey];
}
}
this._user = _user;
this.user = { ...user, ...event.user };
}
this.state.updateUser(event.user);
this._updateMemberWatcherReferences(event.user);
}
if (event.type === 'user.updated') {
this._updateUserMessageReferences(event.user);
}
if (
event.type === 'user.deleted' &&
event.user.deleted_at &&
(event.mark_messages_deleted || event.hard_delete)
) {
this._deleteUserMessageReference(event.user, event.hard_delete);
}
};
_handleClientEvent(event: Event) {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const client = this;
const postListenerCallbacks = [];
this.logger(
'info',
`client:_handleClientEvent - Received event of type { ${event.type} }`,
{
tags: ['event', 'client'],
event,
},
);
if (
event.type === 'user.presence.changed' ||
event.type === 'user.updated' ||
event.type === 'user.deleted'
) {
this._handleUserEvent(event);
}
if (event.type === 'health.check' && event.me) {
client.user = event.me;
client.state.updateUser(event.me);
client.mutedChannels = event.me.channel_mutes;
client.mutedUsers = event.me.mutes;
}
if (event.channel && event.type === 'notification.message_new') {
const { channel } = event;
this._addChannelConfig(channel);
}
if (event.type === 'notification.channel_mutes_updated' && event.me?.channel_mutes) {
this.mutedChannels = event.me.channel_mutes;
}
if (event.type === 'notification.mutes_updated' && event.me?.mutes) {
this.mutedUsers = event.me.mutes;
}
if (event.type === 'notification.mark_read' && event.unread_channels === 0) {
const activeChannelKeys = Object.keys(this.activeChannels);
activeChannelKeys.forEach(
(activeChannelKey) =>
(this.activeChannels[activeChannelKey].state.unreadCount = 0),
);
}
if (
(event.type === 'channel.deleted' ||
event.type === 'notification.channel_deleted') &&
event.cid
) {
const { cid } = event;
client.state.deleteAllChannelReference(cid);
this.activeChannels[event.cid]?._disconnect();
postListenerCallbacks.push(() => {
if (!cid) return;
delete this.activeChannels[cid];
});
}
return postListenerCallbacks;
}
_muteStatus(cid: string) {
let muteStatus;
for (let i = 0; i < this.mutedChannels.length; i++) {
const mute = this.mutedChannels[i];
if (mute.channel?.cid === cid) {
muteStatus = {
muted: mute.expires
? new Date(mute.expires).getTime() > new Date().getTime()
: true,
createdAt: mute.created_at ? new Date(mute.created_at) : new Date(),
expiresAt: mute.expires ? new Date(mute.expires) : null,
};
break;
}
}
if (muteStatus) {
return muteStatus;
}
return {
muted: false,
createdAt: null,
expiresAt: null,
};
}
_callClientListeners = (event: Event) => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const client = this;
// gather and call the listeners
const listeners: Array<(event: Event) => void> = [];
if (client.listeners.all) {
listeners.push(...client.listeners.all);
}
if (client.listeners[event.type]) {
listeners.push(...client.listeners[event.type]);
}
// call the event and send it to the listeners
for (const listener of listeners) {
listener(event);
}
};
recoverState = async () => {
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) {
this.logger(
'info',
`client:recoverState() - Start the querying of ${cids.length} channels`,
{
tags: ['connection', 'client'],
},
);
await this.queryChannels(
{ cid: { $in: cids } } as ChannelFilters,
{ last_message_at: -1 },
{ limit: 30 },
);
this.logger('info', 'client:recoverState() - Querying channels finished', {
tags: ['connection', 'cl