UNPKG

@bitrix24/b24jssdk

Version:

Bitrix24 REST API JavaScript SDK

1 lines 136 kB
{"version":3,"file":"client.mjs","sources":["../../../src/pullClient/client.ts"],"sourcesContent":["import type { LoggerInterface } from '../logger'\nimport { LoggerFactory } from '../logger'\nimport { Type } from '../tools/type'\nimport { Text } from '../tools/text'\nimport { Browser } from '../tools/browser'\nimport { StorageManager } from './storage-manager'\nimport { JsonRpc } from './json-rpc'\nimport { SharedConfig } from './shared-config'\nimport { ChannelManager } from './channel-manager'\nimport { ResponseBatch, RequestBatch, IncomingMessage, Receiver } from './protobuf'\nimport { CloseReasons, ConnectionType, PullStatus, RpcMethod, SenderType, ServerMode, SubscriptionType, SystemCommands, LsKeys } from '../types/pull'\nimport { WebSocketConnector } from './web-socket-connector'\nimport { LongPollingConnector } from './long-polling-connector'\nimport type { StorageManagerParams, TypePullClientParams, TypePullClientSession, TypeStorageManager, SharedConfigParams, TypeChannelManagerParams, TypeConnector, RpcError, TypePullClientConfig, TypePullMessage, TypeSubscriptionOptions, TypeSubscriptionCommandHandler, TypePullClientEmitConfig, CommandHandlerFunctionV1, CommandHandlerFunctionV2, ConnectorParent, UserStatusCallback, TypePublicIdDescriptor, TypePullClientMessageBatch, TypeChanel, TypeSessionEvent, TypePullClientMessageBody } from '../types/pull'\nimport type { TypeB24 } from '../types/b24'\nimport type { AjaxResult } from '../core/http/ajax-result'\nimport type { SuccessPayload } from '../types/payloads'\nimport type { NumberString } from '../types/common'\n\n/**\n * @memo api revision - check module/pull/include.php\n */\nconst REVISION = 19\nconst RESTORE_WEBSOCKET_TIMEOUT = 30 * 60\nconst OFFLINE_STATUS_DELAY = 5_000\nconst CONFIG_CHECK_INTERVAL = 60 * 1000\nconst MAX_IDS_TO_STORE = 10\nconst PING_TIMEOUT = 10\nconst JSON_RPC_PING = 'ping'\nconst JSON_RPC_PONG = 'pong'\nconst LS_SESSION = 'bx-pull-session'\nconst LS_SESSION_CACHE_TIME = 20\n\nconst EmptyConfig = {\n api: {},\n channels: {},\n publicChannels: {},\n server: { timeShift: 0 },\n clientId: null,\n jwt: null,\n exp: 0\n} as TypePullClientConfig\n\nexport class PullClient implements ConnectorParent {\n // region Params ////\n private _logger: LoggerInterface\n private _restClient: TypeB24\n private _status: PullStatus\n private _context: string\n private readonly _guestMode: boolean\n private readonly _guestUserId: number\n private _userId: number\n\n private _configGetMethod: string\n private _getPublicListMethod: string\n\n private _siteId: string\n private _enabled: boolean\n\n private _unloading: boolean = false\n private _starting: boolean = false\n private _debug: boolean = false\n private _connectionAttempt: number = 0\n private _connectionType: ConnectionType = ConnectionType.WebSocket\n private _skipStorageInit: boolean\n private _skipCheckRevision: boolean\n\n private _subscribers: Record<string, any> = {}\n private _watchTagsQueue: Map<string, boolean> = new Map()\n private _watchUpdateInterval: number = 1_740_000\n private _watchForceUpdateInterval: number = 5_000\n private _configTimestamp: number = 0\n private _session: TypePullClientSession = {\n mid: null,\n tag: null,\n time: null,\n history: {},\n lastMessageIds: [],\n messageCount: 0\n }\n\n private _connectors: Record<ConnectionType, null | TypeConnector> = {\n [ConnectionType.Undefined]: null,\n [ConnectionType.WebSocket]: null,\n [ConnectionType.LongPolling]: null\n }\n\n private _isSecure: boolean\n\n private _config: null | TypePullClientConfig = null\n\n private _storage: null | TypeStorageManager = null\n private _sharedConfig: SharedConfig\n private _channelManager: ChannelManager\n private _jsonRpcAdapter: null | JsonRpc = null\n\n /**\n * @depricate\n */\n // private _notificationPopup: null = null\n\n // timers ////\n private _reconnectTimeout: ReturnType<typeof setTimeout> | null = null\n private _restartTimeout: ReturnType<typeof setTimeout> | null = null\n private _restoreWebSocketTimeout: ReturnType<typeof setTimeout> | null = null\n private _checkInterval: ReturnType<typeof setTimeout> | null = null\n private _offlineTimeout: ReturnType<typeof setTimeout> | null = null\n private _watchUpdateTimeout: ReturnType<typeof setTimeout> | null = null\n private _pingWaitTimeout: ReturnType<typeof setTimeout> | null = null\n\n // manual stop workaround ////\n private _isManualDisconnect: boolean = false\n\n private _loggingEnabled: boolean = false\n\n // bound event handlers ////\n private _onPingTimeoutHandler: () => void\n\n // [userId] => array of callbacks\n private _userStatusCallbacks: Record<number, UserStatusCallback[]> = {}\n\n private _connectPromise: null | {\n resolve: (response: any) => void\n reject: (error: string | RpcError | Error) => void\n } = null\n\n private _startingPromise: null | Promise<boolean> = null\n // endregion ////\n\n // region Init ////\n /**\n * @param params\n */\n constructor(params: TypePullClientParams) {\n this._logger = LoggerFactory.createNullLogger()\n this._restClient = params.b24\n this._status = PullStatus.Offline\n this._context = 'master'\n\n // region RestApplication ////\n if (params.restApplication) {\n if (typeof params.configGetMethod === 'undefined') {\n params.configGetMethod = 'pull.application.config.get'\n }\n\n if (typeof params.skipCheckRevision === 'undefined') {\n params.skipCheckRevision = true\n }\n\n if (Type.isStringFilled(params.restApplication)) {\n params.siteId = params.restApplication\n }\n\n params.serverEnabled = true\n }\n // endregion ////\n\n // region Params ////\n this._guestMode = params.guestMode\n ? Text.toBoolean(params.guestMode)\n : false\n\n this._guestUserId = params.guestUserId\n ? Text.toInteger(params.guestUserId)\n : 0\n\n if (this._guestMode && this._guestUserId > 0) {\n this._userId = this._guestUserId\n } else {\n this._guestMode = false\n this._userId = params.userId ? Text.toInteger(params.userId) : 0\n }\n\n this._siteId = params.siteId ?? 'none'\n\n this._enabled = !Type.isUndefined(params.serverEnabled)\n ? params.serverEnabled === true\n : true\n\n this._configGetMethod = !Type.isStringFilled(params.configGetMethod)\n ? 'pull.config.get'\n : params.configGetMethod || ''\n\n this._getPublicListMethod = !Type.isStringFilled(params.getPublicListMethod)\n ? 'pull.channel.public.list'\n : params.getPublicListMethod || ''\n\n this._skipStorageInit = params.skipStorageInit === true\n this._skipCheckRevision = params.skipCheckRevision === true\n\n if (!Type.isUndefined(params.configTimestamp)) {\n this._configTimestamp = Text.toInteger(params.configTimestamp)\n }\n // endregion ////\n\n this._isSecure = document?.location.href.indexOf('https') === 0\n\n if (this._userId && !this._skipStorageInit) {\n this._storage = new StorageManager({\n userId: this._userId,\n siteId: this._siteId\n } as StorageManagerParams)\n }\n\n this._sharedConfig = new SharedConfig({\n onWebSocketBlockChanged: this.onWebSocketBlockChanged.bind(this),\n storage: this._storage\n } as SharedConfigParams)\n\n this._channelManager = new ChannelManager({\n b24: this._restClient,\n getPublicListMethod: this._getPublicListMethod\n } as TypeChannelManagerParams)\n\n this._loggingEnabled = this._sharedConfig.isLoggingEnabled()\n\n // bound event handlers ////\n this._onPingTimeoutHandler = this.onPingTimeout.bind(this)\n }\n\n setLogger(logger: LoggerInterface): void {\n this._logger = logger\n this._jsonRpcAdapter?.setLogger(this.getLogger())\n this._storage?.setLogger(this.getLogger())\n this._sharedConfig.setLogger(this.getLogger())\n this._channelManager.setLogger(this.getLogger())\n\n this._connectors.webSocket?.setLogger(this.getLogger())\n this._connectors.longPolling?.setLogger(this.getLogger())\n }\n\n getLogger(): LoggerInterface {\n return this._logger\n }\n\n destroy(): void {\n this.stop(CloseReasons.NORMAL_CLOSURE, 'manual stop')\n\n this.onBeforeUnload()\n }\n\n private init(): void {\n this._connectors.webSocket = new WebSocketConnector({\n parent: this,\n onOpen: this.onWebSocketOpen.bind(this),\n onMessage: this.onIncomingMessage.bind(this),\n onDisconnect: this.onWebSocketDisconnect.bind(this),\n onError: this.onWebSocketError.bind(this)\n })\n\n this._connectors.longPolling = new LongPollingConnector({\n parent: this,\n onOpen: this.onLongPollingOpen.bind(this),\n onMessage: this.onIncomingMessage.bind(this),\n onDisconnect: this.onLongPollingDisconnect.bind(this),\n onError: this.onLongPollingError.bind(this)\n })\n\n this._connectionType = this.isWebSocketAllowed()\n ? ConnectionType.WebSocket\n : ConnectionType.LongPolling\n\n window.addEventListener('beforeunload', this.onBeforeUnload.bind(this))\n window.addEventListener('offline', this.onOffline.bind(this))\n window.addEventListener('online', this.onOnline.bind(this))\n\n /**\n * @memo Not use under Node.js\n */\n /* /\n if (BX && BX.addCustomEvent)\n {\n BX.addCustomEvent('BXLinkOpened', this.connect.bind(this))\n }\n\n if (BX && BX.desktop)\n {\n BX.addCustomEvent('onDesktopReload', () => {\n this._session.mid = null\n this._session.tag = null\n this._session.time = null\n })\n\n BX.desktop.addCustomEvent('BXLoginSuccess', () => this.restart(1_000, 'desktop login'))\n }\n // */\n\n this._jsonRpcAdapter = new JsonRpc({\n connector: this._connectors.webSocket,\n handlers: {\n 'incoming.message': this.handleRpcIncomingMessage.bind(this)\n }\n })\n }\n\n // endregion ////\n\n // region Get-Set ////\n get connector(): null | TypeConnector {\n return this._connectors[this._connectionType]\n }\n\n get status(): PullStatus {\n return this._status\n }\n\n /**\n * @param status\n */\n set status(status: PullStatus) {\n if (this._status === status) {\n return\n }\n\n this._status = status\n if (this._offlineTimeout) {\n clearTimeout(this._offlineTimeout)\n this._offlineTimeout = null\n }\n\n if (status === PullStatus.Offline) {\n this.sendPullStatusDelayed(status, OFFLINE_STATUS_DELAY)\n } else {\n this.sendPullStatus(status)\n }\n }\n\n get session(): TypePullClientSession {\n return this._session\n }\n\n // endregion ////\n\n // region Public /////\n /**\n * Creates a subscription to incoming messages.\n *\n * @param {TypeSubscriptionOptions | TypeSubscriptionCommandHandler} params\n * @returns { () => void } - Unsubscribe callback function\n */\n public subscribe(\n params: TypeSubscriptionOptions | TypeSubscriptionCommandHandler\n ): () => void {\n if (!Type.isPlainObject(params)) {\n return this.attachCommandHandler(params as TypeSubscriptionCommandHandler)\n }\n\n params = params as TypeSubscriptionOptions\n\n params.type = params.type || SubscriptionType.Server\n params.command = params.command || null\n\n if (\n params.type == SubscriptionType.Server\n || params.type == SubscriptionType.Client\n ) {\n if (typeof params.moduleId === 'undefined') {\n throw new TypeError(\n `${Text.getDateForLog()}: Pull.subscribe: parameter moduleId is not specified`\n )\n }\n\n if (typeof this._subscribers[params.type] === 'undefined') {\n this._subscribers[params.type] = {}\n }\n\n if (\n typeof this._subscribers[params.type][params.moduleId] === 'undefined'\n ) {\n this._subscribers[params.type][params.moduleId] = {\n callbacks: [],\n commands: {}\n }\n }\n\n if (params.command) {\n if (\n typeof this._subscribers[params.type][params.moduleId]['commands'][\n params.command\n ] === 'undefined'\n ) {\n this._subscribers[params.type][params.moduleId]['commands'][\n params.command\n ] = []\n }\n\n this._subscribers[params.type][params.moduleId]['commands'][\n params.command\n ].push(params.callback)\n\n return () => {\n if (\n typeof params.type === 'undefined'\n || typeof params.moduleId === 'undefined'\n || typeof params.command === 'undefined'\n || null === params.command\n ) {\n return\n }\n\n this._subscribers[params.type][params.moduleId]['commands'][\n params.command\n ] = this._subscribers[params.type][params.moduleId]['commands'][\n params.command\n ].filter((element: any) => {\n return element !== params.callback\n })\n }\n } else {\n this._subscribers[params.type][params.moduleId]['callbacks'].push(\n params.callback\n )\n\n return () => {\n if (\n typeof params.type === 'undefined'\n || typeof params.moduleId === 'undefined'\n ) {\n return\n }\n\n this._subscribers[params.type][params.moduleId]['callbacks']\n = this._subscribers[params.type][params.moduleId]['callbacks'].filter(\n (element: any) => {\n return element !== params.callback\n }\n )\n }\n }\n } else {\n if (typeof this._subscribers[params.type] === 'undefined') {\n this._subscribers[params.type] = []\n }\n\n this._subscribers[params.type].push(params.callback)\n\n return () => {\n if (typeof params.type === 'undefined') {\n return\n }\n\n this._subscribers[params.type] = this._subscribers[params.type].filter(\n (element: any) => {\n return element !== params.callback\n }\n )\n }\n }\n }\n\n /**\n * @param {TypeSubscriptionCommandHandler} handler\n * @returns {() => void} - Unsubscribe callback function\n */\n private attachCommandHandler(\n handler: TypeSubscriptionCommandHandler\n ): () => void {\n if (\n typeof handler.getModuleId !== 'function'\n || typeof handler.getModuleId() !== 'string'\n ) {\n this.getLogger().error(`${Text.getDateForLog()}: Pull.attachCommandHandler: result of handler.getModuleId() is not a string.`)\n return () => {}\n }\n\n let type = SubscriptionType.Server\n if (typeof handler.getSubscriptionType === 'function') {\n type = handler.getSubscriptionType()\n }\n\n return this.subscribe({\n type: type,\n moduleId: handler.getModuleId(),\n callback: (data: TypePullMessage) => {\n let method = null\n\n if (typeof handler.getMap === 'function') {\n const mapping = handler.getMap()\n if (mapping && typeof mapping === 'object') {\n const rowMapping = mapping[data.command]\n if (typeof rowMapping === 'function') {\n method = rowMapping.bind(handler)\n } else if (\n typeof rowMapping === 'string'\n && typeof handler[rowMapping] === 'function'\n ) {\n method = handler[rowMapping].bind(handler)\n }\n }\n }\n\n /**\n * handler.handleSomeCommandName: CommandHandlerFunction\n */\n if (!method) {\n const methodName = `handle${Text.capitalize(data.command)}`\n if (typeof handler[methodName] === 'function') {\n method = handler[methodName].bind(handler)\n }\n }\n\n if (method) {\n if (this._debug && this._context !== 'master') {\n this.getLogger().warning(\n `${Text.getDateForLog()}: Pull.attachCommandHandler: result of handler.getModuleId() is not a string`,\n { data }\n )\n }\n\n method(data.params, data.extra, data.command)\n }\n }\n })\n }\n\n /**\n * @param config\n */\n public async start(\n config:\n | null\n | (TypePullClientConfig & {\n skipReconnectToLastSession?: boolean\n }) = null\n ): Promise<boolean> {\n let allowConfigCaching = true\n\n if (this.isConnected()) {\n return Promise.resolve(true)\n }\n\n if (this._starting && this._startingPromise) {\n return this._startingPromise\n }\n\n if (!this._userId) {\n throw new Error('Not set userId')\n }\n\n if (this._siteId === 'none') {\n throw new Error('Not set siteId')\n }\n\n let skipReconnectToLastSession = false\n if (!!config && Type.isPlainObject(config)) {\n if (typeof config?.skipReconnectToLastSession !== 'undefined') {\n skipReconnectToLastSession = config.skipReconnectToLastSession\n delete config.skipReconnectToLastSession\n }\n\n this._config = config\n allowConfigCaching = false\n }\n\n if (!this._enabled) {\n return Promise.reject({\n ex: {\n error: 'PULL_DISABLED',\n error_description: 'Push & Pull server is disabled'\n }\n })\n }\n\n const now = Date.now()\n let oldSession\n if (!skipReconnectToLastSession && this._storage) {\n oldSession = this._storage.get(LS_SESSION, null)\n }\n\n if (\n Type.isPlainObject(oldSession)\n && Object.prototype.hasOwnProperty.call(oldSession, 'ttl')\n && oldSession['ttl'] >= now\n ) {\n this._session.mid = oldSession['mid']\n }\n\n this._starting = true\n return (this._startingPromise = new Promise((resolve, reject) => {\n this.loadConfig('client_start')\n .then((config) => {\n this.setConfig(config as TypePullClientConfig, allowConfigCaching)\n this.init()\n this.updateWatch(true)\n this.startCheckConfig()\n\n this.connect().then(\n () => resolve(true),\n error => reject(error)\n )\n })\n .catch((error) => {\n this._starting = false\n this.status = PullStatus.Offline\n this.stopCheckConfig()\n this.getLogger().error(\n `${Text.getDateForLog()}: Pull: could not read push-server config`,\n { error }\n )\n reject(error)\n })\n }))\n }\n\n /**\n * @param disconnectCode\n * @param disconnectReason\n */\n public restart(\n disconnectCode: number | CloseReasons = CloseReasons.NORMAL_CLOSURE,\n disconnectReason: string = 'manual restart'\n ): void {\n if (this._restartTimeout) {\n clearTimeout(this._restartTimeout)\n this._restartTimeout = null\n }\n\n this.getLogger().debug(\n `${Text.getDateForLog()}: Pull: restarting with code ${disconnectCode}`\n )\n\n this.disconnect(disconnectCode, disconnectReason)\n\n if (this._storage) {\n this._storage.remove(LsKeys.PullConfig)\n }\n\n this._config = null\n\n const loadConfigReason = `${disconnectCode}_${disconnectReason.replaceAll(' ', '_')}`\n this.loadConfig(loadConfigReason).then(\n (config) => {\n this.setConfig(config, true)\n this.updateWatch()\n this.startCheckConfig()\n this.connect().catch((error) => {\n this.getLogger().error('restart error', { error })\n })\n },\n (error) => {\n this.getLogger().error(\n `${Text.getDateForLog()}: Pull: could not read push-server config `,\n { error }\n )\n\n this.status = PullStatus.Offline\n if (this._reconnectTimeout) {\n clearTimeout(this._reconnectTimeout)\n this._reconnectTimeout = null\n }\n\n if (error?.status == 401 || error?.status == 403) {\n this.stopCheckConfig()\n this.onCustomEvent('onPullError', ['AUTHORIZE_ERROR'])\n }\n }\n )\n }\n\n public stop(\n disconnectCode: number | CloseReasons = CloseReasons.NORMAL_CLOSURE,\n disconnectReason: string = 'manual stop'\n ): void {\n this.disconnect(disconnectCode, disconnectReason)\n\n this.stopCheckConfig()\n }\n\n public reconnect(\n disconnectCode: number | CloseReasons,\n disconnectReason: string,\n delay: number = 1\n ): void {\n this.disconnect(disconnectCode, disconnectReason)\n\n this.scheduleReconnect(delay)\n }\n\n /**\n * @param lastMessageId\n */\n public setLastMessageId(lastMessageId: string): void {\n this._session.mid = lastMessageId\n }\n\n /**\n * Send a single message to the specified users.\n *\n * @param users User ids of the message receivers.\n * @param moduleId Name of the module to receive a message,\n * @param command Command name.\n * @param {object} params Command parameters.\n * @param [expiry] Message expiry time in seconds.\n * @return {Promise}\n */\n public async sendMessage(\n users: number[],\n moduleId: string,\n command: string,\n params: any,\n expiry?: number\n ): Promise<any> {\n const message = {\n userList: users,\n body: {\n module_id: moduleId,\n command: command,\n params: params\n },\n expiry: expiry\n } as TypePullClientMessageBatch\n\n if (this.isJsonRpc()) {\n return this._jsonRpcAdapter?.executeOutgoingRpcCommand(\n RpcMethod.Publish,\n message\n )\n } else {\n return this.sendMessageBatch([message])\n }\n }\n\n /**\n * Send a single message to the specified public channels.\n *\n * @param publicChannels Public ids of the channels to receive a message.\n * @param moduleId Name of the module to receive a message,\n * @param command Command name.\n * @param {object} params Command parameters.\n * @param [expiry] Message expiry time in seconds.\n * @return {Promise}\n */\n public async sendMessageToChannels(\n publicChannels: string[],\n moduleId: string,\n command: string,\n params: any,\n expiry?: number\n ): Promise<any> {\n const message = {\n channelList: publicChannels,\n body: {\n module_id: moduleId,\n command: command,\n params: params\n },\n expiry: expiry\n } as TypePullClientMessageBatch\n\n if (this.isJsonRpc()) {\n return this._jsonRpcAdapter?.executeOutgoingRpcCommand(\n RpcMethod.Publish,\n message\n )\n } else {\n return this.sendMessageBatch([message])\n }\n }\n\n /**\n * @param debugFlag\n */\n public capturePullEvent(debugFlag: boolean = true): void {\n this._debug = debugFlag\n }\n\n /**\n * @param loggingFlag\n */\n public enableLogging(loggingFlag: boolean = true): void {\n this._sharedConfig.setLoggingEnabled(loggingFlag)\n this._loggingEnabled = loggingFlag\n }\n\n /**\n * Returns list channels that the connection is subscribed to.\n *\n * @returns {Promise}\n */\n public async listChannels(): Promise<any> {\n return (\n this._jsonRpcAdapter?.executeOutgoingRpcCommand(\n RpcMethod.ListChannels,\n {}\n ) || Promise.reject(new Error('jsonRpcAdapter not init'))\n )\n }\n\n /**\n * Returns \"last seen\" time in seconds for the users.\n * Result format: Object{userId: int}\n * If the user is currently connected - will return 0.\n * If the user is offline - will return the diff between the current timestamp and the last seen timestamp in seconds.\n * If the user was never online - the record for the user will be missing from the result object.\n *\n * @param {integer[]} userList List of user ids.\n * @returns {Promise}\n */\n public async getUsersLastSeen(\n userList: number[]\n ): Promise<Record<number, number>> {\n if (\n !Type.isArray(userList)\n || !userList.every(item => typeof item === 'number')\n ) {\n throw new Error('userList must be an array of numbers')\n }\n\n const result: Record<number, number> = {}\n\n return new Promise((resolve, reject) => {\n this._jsonRpcAdapter\n ?.executeOutgoingRpcCommand(RpcMethod.GetUsersLastSeen, {\n userList: userList\n })\n .then((response: any) => {\n const unresolved = []\n\n for (let i = 0; i < userList.length; i++) {\n if (!Object.prototype.hasOwnProperty.call(response, userList[i]!)) {\n unresolved.push(userList[i])\n }\n }\n\n if (unresolved.length === 0) {\n return resolve(result)\n }\n\n const params = {\n userIds: unresolved,\n sendToQueueSever: true\n }\n\n this._restClient\n .actions.v2.call.make({\n method: 'pull.api.user.getLastSeen',\n params\n })\n .then((response: AjaxResult) => {\n const data = (\n response.getData() as SuccessPayload<Record<NumberString, NumberString>>\n ).result\n for (const userId in data) {\n result[Number(userId)] = Number(data[userId])\n }\n\n return resolve(result)\n })\n .catch((error) => {\n this.getLogger().error('getUsersLastSeen', { error })\n reject(error)\n })\n })\n .catch((error) => {\n this.getLogger().error('getUsersLastSeen', { error })\n reject(error)\n })\n })\n }\n\n /**\n * Pings server.\n * In case of success promise will be resolved, otherwise - rejected.\n *\n * @param {number} timeout Request timeout in seconds\n * @returns {Promise}\n */\n public async ping(timeout: number = 5): Promise<void> {\n return this._jsonRpcAdapter?.executeOutgoingRpcCommand(\n RpcMethod.Ping,\n {},\n timeout\n )\n }\n\n /**\n * @param userId {number}\n * @param callback {UserStatusCallback}\n * @returns {Promise}\n */\n public async subscribeUserStatusChange(\n userId: number,\n callback: UserStatusCallback\n ): Promise<void> {\n return new Promise((resolve, reject) => {\n this._jsonRpcAdapter\n ?.executeOutgoingRpcCommand(RpcMethod.SubscribeStatusChange, {\n userId\n })\n .then(() => {\n if (!this._userStatusCallbacks[userId]) {\n this._userStatusCallbacks[userId] = []\n }\n\n if (Type.isFunction(callback)) {\n this._userStatusCallbacks[userId].push(callback)\n }\n\n return resolve()\n })\n .catch(error => reject(error))\n })\n }\n\n /**\n * @param {number} userId\n * @param {UserStatusCallback} callback\n * @returns {Promise}\n */\n public async unsubscribeUserStatusChange(\n userId: number,\n callback: UserStatusCallback\n ): Promise<void> {\n if (this._userStatusCallbacks[userId]) {\n this._userStatusCallbacks[userId] = this._userStatusCallbacks[\n userId\n ].filter(cb => cb !== callback)\n\n if (this._userStatusCallbacks[userId].length === 0) {\n return this._jsonRpcAdapter?.executeOutgoingRpcCommand(\n RpcMethod.UnsubscribeStatusChange,\n {\n userId\n }\n )\n }\n }\n\n return Promise.resolve()\n }\n\n // endregion ////\n\n // region Get ////\n public getRevision(): number | null {\n return this._config && this._config.api\n ? this._config.api.revision_web\n : null\n }\n\n public getServerVersion(): number {\n return this._config && this._config.server ? this._config.server.version : 0\n }\n\n public getServerMode(): string | null {\n return this._config && this._config.server ? this._config.server.mode : null\n }\n\n public getConfig(): null | TypePullClientConfig {\n return this._config\n }\n\n public getDebugInfo(): any {\n if (!JSON || !JSON.stringify) {\n return {}\n }\n\n let configDump\n if (this._config && this._config.channels) {\n configDump = {\n ChannelID: this._config.channels.private?.id || 'n/a',\n ChannelDie: this._config.channels.private?.end || 'n/a',\n ChannelDieShared: this._config.channels.shared?.end || 'n/a'\n }\n } else {\n configDump = {\n ConfigError: 'config is not loaded'\n }\n }\n\n let websocketMode = '-'\n if (\n this._connectors.webSocket\n && (this._connectors.webSocket as WebSocketConnector)?.socket\n ) {\n if (this.isJsonRpc()) {\n websocketMode = 'json-rpc'\n } else {\n websocketMode\n\n = (\n this._connectors.webSocket as WebSocketConnector\n )?.socket?.url.search('binaryMode=true') != -1\n ? 'protobuf'\n : 'text'\n }\n }\n\n return {\n 'UserId': this._userId + (this._userId > 0 ? '' : '(guest)'),\n 'Guest userId':\n this._guestMode && this._guestUserId !== 0 ? this._guestUserId : '-',\n 'Browser online': navigator.onLine ? 'Y' : 'N',\n 'Connect': this.isConnected() ? 'Y' : 'N',\n 'Server type': this.isSharedMode() ? 'cloud' : 'local',\n 'WebSocket supported': this.isWebSocketSupported() ? 'Y' : 'N',\n 'WebSocket connected':\n this._connectors.webSocket && this._connectors.webSocket.connected\n ? 'Y'\n : 'N',\n 'WebSocket mode': websocketMode,\n\n 'Try connect': this._reconnectTimeout ? 'Y' : 'N',\n 'Try number': this._connectionAttempt,\n\n 'Path': this.connector?.connectionPath || '-',\n ...configDump,\n\n 'Last message': this._session.mid || '-',\n 'Session history': this._session.history,\n 'Watch tags': this._watchTagsQueue.entries()\n }\n }\n\n /**\n * @process\n * @param connectionType\n */\n public getConnectionPath(connectionType: ConnectionType): string {\n let path\n const params: any = {}\n\n switch (connectionType) {\n case ConnectionType.WebSocket:\n path = this._isSecure\n ? this._config?.server.websocket_secure\n : this._config?.server.websocket\n break\n case ConnectionType.LongPolling:\n path = this._isSecure\n ? this._config?.server.long_pooling_secure\n : this._config?.server.long_polling\n break\n default:\n throw new Error(`Unknown connection type ${connectionType}`)\n }\n\n if (!Type.isStringFilled(path)) {\n throw new Error(`Empty path`)\n }\n\n if (typeof this._config?.jwt === 'string' && this._config?.jwt !== '') {\n params['token'] = this._config?.jwt\n } else {\n const channels: string[] = []\n\n if (this._config?.channels?.private) {\n channels.push(this._config.channels.private?.id || '')\n }\n\n if (this._config?.channels.private?.id) {\n channels.push(this._config.channels.private.id)\n }\n\n if (this._config?.channels.shared?.id) {\n channels.push(this._config.channels.shared.id)\n }\n\n if (channels.length === 0) {\n throw new Error(`Empty channels`)\n }\n\n params['CHANNEL_ID'] = channels.join('/')\n }\n\n if (this.isJsonRpc()) {\n params.jsonRpc = 'true'\n } else if (this.isProtobufSupported()) {\n params.binaryMode = 'true'\n }\n\n if (this.isSharedMode()) {\n if (!this._config?.clientId) {\n throw new Error(\n 'Push-server is in shared mode, but clientId is not set'\n )\n }\n\n params.clientId = this._config.clientId\n }\n if (this._session.mid) {\n params.mid = this._session.mid\n }\n if (this._session.tag) {\n params.tag = this._session.tag\n }\n if (this._session.time) {\n params.time = this._session.time\n }\n params.revision = REVISION\n\n return `${path}?${Text.buildQueryString(params)}`\n }\n\n /**\n * @process\n */\n public getPublicationPath(): string {\n const path = this._isSecure\n ? this._config?.server.publish_secure\n : this._config?.server.publish\n\n if (!path) {\n return ''\n }\n\n const channels: string[] = []\n\n if (this._config?.channels.private?.id) {\n channels.push(this._config.channels.private.id)\n }\n\n if (this._config?.channels.shared?.id) {\n channels.push(this._config.channels.shared.id)\n }\n\n const params = {\n CHANNEL_ID: channels.join('/')\n }\n\n return path + '?' + Text.buildQueryString(params)\n }\n\n // endregion ////\n\n // region Is* ////\n public isConnected(): boolean {\n return this.connector ? this.connector.connected : false\n }\n\n public isWebSocketSupported(): boolean {\n return typeof window.WebSocket !== 'undefined'\n }\n\n public isWebSocketAllowed(): boolean {\n if (this._sharedConfig.isWebSocketBlocked()) {\n return false\n }\n\n return this.isWebSocketEnabled()\n }\n\n public isWebSocketEnabled(): boolean {\n if (!this.isWebSocketSupported()) {\n return false\n }\n\n if (!this._config) {\n return false\n }\n\n if (!this._config.server) {\n return false\n }\n\n return this._config.server.websocket_enabled\n }\n\n public isPublishingSupported(): boolean {\n return this.getServerVersion() > 3\n }\n\n public isPublishingEnabled(): boolean {\n if (!this.isPublishingSupported()) {\n return false\n }\n\n return this._config?.server.publish_enabled === true\n }\n\n public isProtobufSupported(): boolean {\n return this.getServerVersion() == 4 && !Browser.isIE()\n }\n\n public isJsonRpc(): boolean {\n return this.getServerVersion() >= 5\n }\n\n public isSharedMode(): boolean {\n return this.getServerMode() === ServerMode.Shared\n }\n\n // endregion ////\n\n // region Events ////\n /**\n * @param {TypePullClientEmitConfig} params\n * @returns {boolean}\n */\n private emit(params: TypePullClientEmitConfig): boolean {\n if (\n params.type == SubscriptionType.Server\n || params.type == SubscriptionType.Client\n ) {\n if (typeof this._subscribers[params.type] === 'undefined') {\n this._subscribers[params.type] = {}\n }\n\n if (typeof params.moduleId === 'undefined') {\n throw new TypeError(\n `${Text.getDateForLog()}: Pull.emit: parameter moduleId is not specified`\n )\n }\n\n if (\n typeof this._subscribers[params.type][params.moduleId] === 'undefined'\n ) {\n this._subscribers[params.type][params.moduleId] = {\n callbacks: [],\n commands: {}\n }\n }\n\n if (\n this._subscribers[params.type][params.moduleId]['callbacks'].length > 0\n ) {\n this._subscribers[params.type][params.moduleId]['callbacks'].forEach(\n (callback: CommandHandlerFunctionV1) => {\n callback(params.data as Record<string, any>, {\n type: params.type,\n moduleId: params.moduleId ?? '?'\n })\n }\n )\n }\n\n if (\n !(typeof params.data === 'undefined')\n && !(typeof params.data['command'] === 'undefined')\n && this._subscribers[params.type][params.moduleId]['commands'][\n params.data['command']\n ]\n && this._subscribers[params.type][params.moduleId]['commands'][\n params.data['command']\n ].length > 0\n ) {\n this._subscribers[params.type][params.moduleId]['commands'][\n params.data['command']\n\n ].forEach((callback: CommandHandlerFunctionV2) => {\n if (typeof params.data === 'undefined') {\n return\n }\n\n callback(\n params.data['params'],\n params.data['extra'],\n params.data['command'],\n {\n type: params.type,\n moduleId: params.moduleId as string\n }\n )\n })\n }\n\n return true\n } else {\n if (typeof this._subscribers[params.type] === 'undefined') {\n this._subscribers[params.type] = []\n }\n\n if (this._subscribers[params.type].length <= 0) {\n return true\n }\n\n this._subscribers[params.type].forEach(\n (callback: CommandHandlerFunctionV1) => {\n callback(params.data as Record<string, any>, {\n type: params.type\n })\n }\n )\n\n return true\n }\n }\n\n /**\n * @process\n *\n * @param message\n */\n private broadcastMessage(message: TypePullClientMessageBody): void {\n const moduleId = (message.module_id = message.module_id.toLowerCase())\n const command = message.command\n\n if (!message.extra) {\n message.extra = {}\n }\n\n if (message.extra.server_time_unix) {\n message.extra.server_time_ago\n = (Date.now() - message.extra.server_time_unix * 1000) / 1000\n - (this._config?.server.timeShift || 0)\n message.extra.server_time_ago = Math.max(message.extra.server_time_ago, 0)\n }\n\n this.logMessage(message)\n try {\n if (\n message.extra.sender\n && message.extra.sender.type === SenderType.Client\n ) {\n this.onCustomEvent(\n 'onPullClientEvent-' + moduleId,\n [command, message.params, message.extra],\n true\n )\n this.onCustomEvent(\n 'onPullClientEvent',\n [moduleId, command, message.params, message.extra],\n true\n )\n\n this.emit({\n type: SubscriptionType.Client,\n moduleId: moduleId,\n data: {\n command: command,\n params: Type.clone(message.params),\n extra: Type.clone(message.extra)\n }\n })\n } else if (moduleId === 'pull') {\n this.handleInternalPullEvent(command, message)\n } else if (moduleId == 'online') {\n if ((message?.extra?.server_time_ago || 0) < 240) {\n this.onCustomEvent(\n 'onPullOnlineEvent',\n [command, message.params, message.extra],\n true\n )\n\n this.emit({\n type: SubscriptionType.Online,\n data: {\n command: command,\n params: Type.clone(message.params),\n extra: Type.clone(message.extra)\n }\n })\n }\n\n if (command === 'userStatusChange') {\n this.emitUserStatusChange(\n message.params.user_id,\n message.params.online\n )\n }\n } else {\n this.onCustomEvent(\n 'onPullEvent-' + moduleId,\n [command, message.params, message.extra],\n true\n )\n this.onCustomEvent(\n 'onPullEvent',\n [moduleId, command, message.params, message.extra],\n true\n )\n\n this.emit({\n type: SubscriptionType.Server,\n moduleId: moduleId,\n data: {\n command: command,\n params: Type.clone(message.params),\n extra: Type.clone(message.extra)\n }\n })\n }\n } catch (error) {\n this.getLogger().warning('PULL ERROR', {\n errorType: 'broadcastMessages execute error',\n errorEvent: error,\n message\n })\n }\n\n if (message.extra && message.extra.revision_web) {\n this.checkRevision(Text.toInteger(message.extra.revision_web))\n }\n }\n\n /**\n * @process\n *\n * @param messages\n */\n private broadcastMessages(messages: TypePullClientMessageBody[]): void {\n for (const message of messages) {\n this.broadcastMessage(message)\n }\n }\n\n // endregion ////\n\n // region sendMessage ////\n /**\n * Sends batch of messages to the multiple public channels.\n *\n * @param messageBatchList Array of messages to send.\n * @return void\n */\n private async sendMessageBatch(\n messageBatchList: TypePullClientMessageBatch[]\n ): Promise<any> {\n if (!this.isPublishingEnabled()) {\n this.getLogger().error(`Client publishing is not supported or is disabled`)\n return Promise.reject(\n new Error(`Client publishing is not supported or is disabled`)\n )\n }\n\n if (this.isJsonRpc()) {\n const rpcRequest\n = this._jsonRpcAdapter?.createPublishRequest(messageBatchList)\n this.connector?.send(JSON.stringify(rpcRequest))\n\n return Promise.resolve(true)\n } else {\n const userIds: Record<number, number> = {}\n\n for (const messageBatch of messageBatchList) {\n if (typeof messageBatch.userList !== 'undefined') {\n for (const user of messageBatch.userList) {\n const userId = Number(user)\n userIds[userId] = userId\n }\n }\n }\n\n this._channelManager\n ?.getPublicIds(Object.values(userIds))\n .then((publicIds) => {\n const response = this.connector?.send(\n this.encodeMessageBatch(messageBatchList, publicIds)\n )\n\n return Promise.resolve(response)\n })\n }\n }\n\n /**\n * @param messageBatchList\n * @param publicIds\n */\n private encodeMessageBatch(\n messageBatchList: TypePullClientMessageBatch[],\n publicIds: Record<number, TypeChanel>\n ): ArrayBuffer | string {\n const messages: any[] = []\n\n messageBatchList.forEach((messageFields) => {\n const messageBody = messageFields.body\n\n let receivers: any[] = []\n if (messageFields.userList) {\n receivers = this.createMessageReceivers(\n messageFields.userList,\n publicIds\n )\n }\n\n if (messageFields.channelList) {\n if (!Type.isArray(messageFields.channelList)) {\n throw new TypeError('messageFields.publicChannels must be an array')\n }\n\n messageFields.channelList.forEach((publicChannel) => {\n let publicId\n let signature\n if (\n typeof publicChannel === 'string'\n && publicChannel.includes('.')\n ) {\n const fields = publicChannel.toString().split('.')\n publicId = fields[0]\n signature = fields[1]\n } else if (\n typeof publicChannel === 'object'\n && 'publicId' in publicChannel\n && 'signature' in publicChannel\n ) {\n publicId = publicChannel?.publicId\n signature = publicChannel?.signature\n } else {\n throw new Error(\n 'Public channel MUST be either a string, formatted like \"{publicId}.{signature}\" or an object with fields \\'publicId\\' and \\'signature\\''\n )\n }\n\n receivers.push(\n Receiver.create({\n id: this.encodeId(publicId!),\n signature: this.encodeId(signature!)\n })\n )\n })\n }\n\n const message = IncomingMessage.create({\n receivers: receivers,\n body: JSON.stringify(messageBody),\n expiry: messageFields.expiry || 0\n })\n messages.push(message)\n })\n\n const requestBatch = RequestBatch.create({\n requests: [\n {\n incomingMessages: {\n messages: messages\n }\n }\n ]\n })\n\n return RequestBatch.encode(requestBatch).finish()\n }\n\n /**\n * @memo fix return type\n * @param users\n * @param publicIds\n */\n private createMessageReceivers(\n users: number[],\n publicIds: Record<number, TypeChanel>\n ): any[] {\n const result = []\n\n for (const userId of users) {\n if (!publicIds[userId] || !publicIds[userId].publicId) {\n throw new Error(`Could not determine public id for user ${userId}`)\n }\n\n result.push(\n Receiver.create({\n id: this.encodeId(publicIds[userId].publicId),\n signature: this.encodeId(publicIds[userId].signature)\n })\n )\n }\n\n return result\n }\n\n // endregion ////\n\n // region _userStatusCallbacks ////\n /**\n * @param userId\n * @param isOnline\n */\n private emitUserStatusChange(userId: number, isOnline: boolean): void {\n if (this._userStatusCallbacks[userId]) {\n for (const callback of this._userStatusCallbacks[userId]) {\n callback({\n userId,\n isOnline\n })\n }\n }\n }\n\n private restoreUserStatusSubscription(): void {\n for (const userId in this._userStatusCallbacks) {\n if (\n Object.prototype.hasOwnProperty.call(this._userStatusCallbacks, userId)\n && this._userStatusCallbacks[userId]!.length > 0\n ) {\n this._jsonRpcAdapter?.executeOutgoingRpcCommand(\n RpcMethod.SubscribeStatusChange,\n {\n userId: userId\n }\n )\n }\n }\n }\n\n // endregion ////\n\n // region Config ////\n private async loadConfig(_logTag?: string): Promise<TypePullClientConfig> {\n if (!this._config) {\n this._config = Object.assign({}, EmptyConfig)\n\n let config: any\n if (this._storage) {\n config = this._storage.get(LsKeys.PullConfig, null)\n }\n\n if (\n this.isConfigActual(config)\n && this.checkRevision(config.api.revision_web)\n ) {\n return Promise.resolve(config)\n } else if (this._storage) {\n this._storage.remove(LsKeys.PullConfig)\n }\n } else if (\n this.isConfigActual(this._config)\n && this.checkRevision(this._config.api.revision_web)\n ) {\n return Promise.resolve(this._config)\n } else {\n this._config = Object.assign({}, EmptyConfig)\n }\n\n return new Promise((resolve, reject) => {\n this._restClient\n .actions.v2.call.make({\n method: this._configGetMethod,\n params: { CACHE: 'N' }\n })\n .then((response) => {\n const data = response.getData()!.result\n\n const timeShift = Math.floor(\n (Date.now() - new Date(data.serverTime).getTime()) / 1000\n )\n\n delete data.serverTime\n\n const config = Object.assign({}, data)\n config.server.timeShift = timeShift\n\n resolve(config)\n })\n .catch((error: unknown) => { reject(error) })\n })\n }\n\n /**\n * @param config\n */\n private isConfigActual(config: any): boolean {\n if (!Type.isPlainObject(config)) {\n return false\n }\n\n if (Number(config['server'].config_timestamp) !== this._configTimestamp) {\n return false\n }\n\n const now = new Date()\n\n if (\n Type.isNumber(config['exp'])\n && config['exp'] > 0\n && config['exp'] < now.getTime() / 1000\n ) {\n return false\n }\n\n const channelCount = Object.keys(config['channels']).length\n if (channelCount === 0) {\n return false\n }\n\n for (const channelType in config['channels']) {\n if (!Object.prototype.hasOwnProperty.call(config['channels'], channelType)) {\n continue\n }\n\n const channel = config['channels'][channelType]\n const channelEnd = new Date(channel.end)\n\n if (channelEnd < now) {\n return false\n }\n }\n\n return true\n }\n\n private startCheckConfig(): void {\n if (this._checkInterval) {\n clearInterval(this._checkInterval)\n this._checkInterval = null\n }\n\n this._checkInterval = setInterval(\n this.checkConfig.bind(this),\n CONFIG_CHECK_INTERVAL\n )\n }\n\n private stopCheckConfig(): void {\n if (this._checkInterval) {\n clearInterval(this._checkInterval)\n }\n this._checkInterval = null\n }\n\n private checkConfig(): boolean {\n if (this.isConfigActual(this._config)) {\n if (!this.checkRevision(Text.toInteger(this._config?.api.revision_web))) {\n return false\n }\n } else {\n this.logToConsole('Stale config detected. Restarting')\n this.restart(CloseReasons.CONFIG_EXPIRED, 'config expired')\n }\n\n return true\n }\n\n /**\n * @param config\n * @param allowCaching\n */\n private setConfig(config: TypePullClientConfig, allowCaching: boolean): void {\n for (const key in config) {\n if (\n Object.prototype.hasOwnProperty.call(config, key)\n && Object.prototype.hasOwnProperty.call(this._config, key)\n ) {\n // @ts-expect-error this normal work - see ory code\n this._config[key] = config[key]\n }\n }\n\n if (config.publicChannels) {\n this.setPublicIds(Object.values(config.publicChannels))\n }\n\n this._configTimestamp = Number(config.server.config_timestamp)\n\n if (this._storage && allowCaching) {\n try {\n this._storage.set(LsKeys.PullConfig, config)\n } catch (error) {\n /**\n * @memotry to delete the key \"history\"\n * (landing site change history, see http://jabber.bx/view.php?id=136492)\n */\n if (localStorage && localStorage.removeItem) {\n localStorage.removeItem('history')\n }\n this.getLogger().error(\n `${Text.getDateForLog()}: Pull: Could not cache config in local storage.`,\n { error }\n )\n }\n }\n }\n\n private setPublicIds(publicIds: TypePublicIdDescriptor[]): void {\n this._channelManager.setPublicIds(publicIds)\n }\n\n /**\n * @param serverRevision\n */\n private checkRevision(serverRevision: number): boolean {\n if (this._skipCheckRevision) {\n