UNPKG

bitmart-api

Version:

Complete & robust Node.js SDK for BitMart's REST APIs and WebSockets, with TypeScript declarations.

482 lines 21.6 kB
import EventEmitter from 'events'; import WebSocket from 'isomorphic-ws'; import { WS_LOGGER_CATEGORY } from '../WebsocketClient.js'; import { DefaultLogger } from './logger.js'; import { isMessageEvent } from './requestUtils.js'; import { checkWebCryptoAPISupported } from './webCryptoAPI.js'; import { safeTerminateWs } from './websocket/websocket-util.js'; import { WsStore } from './websocket/WsStore.js'; import { WsConnectionStateEnum } from './websocket/WsStore.types.js'; // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class BaseWebsocketClient extends EventEmitter { wsStore; logger; options; constructor(options, logger) { super(); this.logger = logger || DefaultLogger; this.wsStore = new WsStore(this.logger); this.options = { pongTimeout: 1000, pingInterval: 10000, reconnectTimeout: 500, recvWindow: 0, ...options, }; // Check Web Crypto API support when credentials are provided and no custom sign function is used if (this.options.apiKey && this.options.apiSecret && this.options.apiMemo && !this.options.customSignMessageFn) { // Provide a user friendly error message if the user is using an outdated Node.js version (where Web Crypto API is not available). // A few users have been caught out by using the end-of-life Node.js v18 release. checkWebCryptoAPISupported(); } } isPrivateWsKey(wsKey) { return this.getPrivateWSKeys().includes(wsKey); } /** * Subscribe to one or more topics on a WS connection (identified by WS Key). * * - Topics are automatically cached * - Connections are automatically opened, if not yet connected * - Authentication is automatically handled * - Topics are automatically resubscribed to, if something happens to the connection, unless you call unsubsribeTopicsForWsKey(topics, key). * * @param wsTopics array of topics to subscribe to * @param wsKey ws key referring to the ws connection these topics should be subscribed on */ subscribeTopicsForWsKey(wsTopics, wsKey) { // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically for (const topic of wsTopics) { this.wsStore.addTopic(wsKey, topic); } const isConnected = this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED); // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect if (!isConnected && !this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) && !this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.RECONNECTING)) { return this.connect(wsKey); } // We're connected. Check if auth is needed and if already authenticated const isPrivateConnection = this.isPrivateWsKey(wsKey); const isAuthenticated = this.wsStore.get(wsKey)?.isAuthenticated; if (isPrivateConnection && !isAuthenticated) { /** * If not authenticated yet and auth is required, don't request topics yet. * Topics will automatically subscribe post-auth success. */ return false; } // Finally, request subscription to topics if the connection is healthy and ready this.requestSubscribeTopics(wsKey, wsTopics); } unsubscribeTopicsForWsKey(wsTopics, wsKey) { // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically for (const topic of wsTopics) { this.wsStore.addTopic(wsKey, topic); } const isConnected = this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED); // If not connected, don't need to do anything if (!isConnected) { return; } // We're connected. Check if auth is needed and if already authenticated const isPrivateConnection = this.isPrivateWsKey(wsKey); const isAuthenticated = this.wsStore.get(wsKey)?.isAuthenticated; if (isPrivateConnection && !isAuthenticated) { /** * If not authenticated yet and auth is required, don't need to do anything. * We don't subscribe to topics until auth is complete anyway. */ return; } // Finally, request subscription to topics if the connection is healthy and ready this.requestUnsubscribeTopics(wsKey, wsTopics); } /** * Subscribe to topics & track/persist them. They will be automatically resubscribed to if the connection drops/reconnects. * @param wsTopics topic or list of topics * @param isPrivate optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) */ subscribe(wsTopics, market, isPrivate) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach((topic) => { const isPrivateTopic = isPrivate || this.isPrivateChannel(topic); const wsKey = this.getWsKeyForMarket(market, isPrivateTopic); // Persist this topic to the expected topics list this.wsStore.addTopic(wsKey, topic); // if connected, send subscription request if (this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED)) { // if not authenticated, dont sub to private topics yet. // This'll happen automatically once authenticated if (isPrivateTopic && !this.wsStore.get(wsKey)?.isAuthenticated) { return; } return this.requestSubscribeTopics(wsKey, topics); } // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect if (!this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) && !this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.RECONNECTING)) { return this.connect(wsKey); } }); } /** * Unsubscribe from topics & remove them from memory. They won't be re-subscribed to if the connection reconnects. * @param wsTopics topic or list of topics * @param isPrivateTopic optional - the library will try to detect private topics, you can use this to mark a topic as private (if the topic isn't recognised yet) */ unsubscribe(wsTopics, market, isPrivateTopic) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach((topic) => { const wsKey = this.getWsKeyForMarket(market, isPrivateTopic); this.wsStore.deleteTopic(wsKey, topic); // unsubscribe request only necessary if active connection exists if (this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED)) { this.requestUnsubscribeTopics(wsKey, [topic]); } }); } /** Get the WsStore that tracks websockets & topics */ getWsStore() { return this.wsStore; } close(wsKey, force) { this.logger.info('Closing connection', { ...WS_LOGGER_CATEGORY, wsKey }); this.setWsState(wsKey, WsConnectionStateEnum.CLOSING); this.clearTimers(wsKey); const ws = this.getWs(wsKey); ws?.close(); if (force) { safeTerminateWs(ws); } } closeAll(force) { this.wsStore.getKeys().forEach((key) => { this.close(key, force); }); } isConnected(wsKey) { return this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED); } /** * Request connection to a specific websocket, instead of waiting for automatic connection. */ async connect(wsKey) { try { if (this.wsStore.isWsOpen(wsKey)) { this.logger.error('Refused to connect to ws with existing active connection', { ...WS_LOGGER_CATEGORY, wsKey }); return this.wsStore.getWs(wsKey); } if (this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING)) { this.logger.error('Refused to connect to ws, connection attempt already active', { ...WS_LOGGER_CATEGORY, wsKey }); return; } if (!this.wsStore.getConnectionState(wsKey) || this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.INITIAL)) { this.setWsState(wsKey, WsConnectionStateEnum.CONNECTING); } const url = this.getWsUrl(wsKey); // + authParams; const ws = this.connectToWsUrl(url, wsKey); return this.wsStore.setWs(wsKey, ws); } catch (err) { this.parseWsError('Connection failed', err, wsKey); this.reconnectWithDelay(wsKey, this.options.reconnectTimeout); } } parseWsError(context, error, wsKey) { if (!error.message) { this.logger.error(`${context} due to unexpected error: `, error); this.emit('response', { ...error, wsKey }); this.emit('exception', { ...error, wsKey }); return; } switch (error.message) { case 'Unexpected server response: 401': this.logger.error(`${context} due to 401 authorization failure.`, { ...WS_LOGGER_CATEGORY, wsKey, }); break; default: this.logger.error(`${context} due to unexpected response error: "${error?.msg || error?.message || error}"`, { ...WS_LOGGER_CATEGORY, wsKey, error }); break; } this.emit('response', { ...error, wsKey }); this.emit('exception', { ...error, wsKey }); } /** Get a signature, build the auth request and send it */ async sendAuthRequest(wsKey) { try { this.logger.info('Sending auth request...', { ...WS_LOGGER_CATEGORY, wsKey, }); const request = await this.getWsAuthRequestEvent(wsKey); // console.log('ws auth req', request); return this.tryWsSend(wsKey, JSON.stringify(request)); } catch (e) { this.logger.trace(e, { ...WS_LOGGER_CATEGORY, wsKey }); } } reconnectWithDelay(wsKey, connectionDelayMs) { this.clearTimers(wsKey); if (this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CONNECTING) { this.setWsState(wsKey, WsConnectionStateEnum.RECONNECTING); } this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => { this.logger.info('Reconnecting to websocket', { ...WS_LOGGER_CATEGORY, wsKey, }); this.connect(wsKey); }, connectionDelayMs); } ping(wsKey) { if (this.wsStore.get(wsKey, true).activePongTimer) { return; } this.clearPongTimer(wsKey); this.logger.trace('Sending ping', { ...WS_LOGGER_CATEGORY, wsKey }); const ws = this.wsStore.get(wsKey, true).ws; if (!ws) { this.logger.error(`Unable to send ping for wsKey "${wsKey}" - no connection found`); return; } this.sendPingEvent(wsKey, ws); this.wsStore.get(wsKey, true).activePongTimer = setTimeout(() => { this.logger.info('Pong timeout - closing socket to reconnect', { ...WS_LOGGER_CATEGORY, wsKey, }); safeTerminateWs(this.getWs(wsKey), true); delete this.wsStore.get(wsKey, true).activePongTimer; }, this.options.pongTimeout); } clearTimers(wsKey) { this.clearPingTimer(wsKey); this.clearPongTimer(wsKey); const wsState = this.wsStore.get(wsKey); if (wsState?.activeReconnectTimer) { clearTimeout(wsState.activeReconnectTimer); } } // Send a ping at intervals clearPingTimer(wsKey) { const wsState = this.wsStore.get(wsKey); if (wsState?.activePingTimer) { clearInterval(wsState.activePingTimer); wsState.activePingTimer = undefined; } } // Expect a pong within a time limit clearPongTimer(wsKey) { const wsState = this.wsStore.get(wsKey); if (wsState?.activePongTimer) { clearTimeout(wsState.activePongTimer); wsState.activePongTimer = undefined; } } /** * Simply builds and sends subscribe events for a list of topics for a ws key * * @private Use the `subscribe(topics)` or `subscribeTopicsForWsKey(topics, wsKey)` method to subscribe to topics. Send WS message to subscribe to topics. */ requestSubscribeTopics(wsKey, topics) { if (!topics.length) { return; } const subscribeWsMessages = this.getWsSubscribeEventsForTopics(topics, wsKey); this.logger.trace(`Subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches. Events: "${JSON.stringify(topics)}"`); for (const wsMessage of subscribeWsMessages) { this.logger.trace(`Sending batch via message: "${wsMessage}"`); this.tryWsSend(wsKey, wsMessage); } this.logger.trace(`Finished subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`); } /** * Simply builds and sends unsubscribe events for a list of topics for a ws key * * @private Use the `unsubscribe(topics)` method to unsubscribe from topics. Send WS message to unsubscribe from topics. */ requestUnsubscribeTopics(wsKey, topics) { if (!topics.length) { return; } const subscribeWsMessages = this.getWsUnsubscribeEventsForTopics(topics, wsKey); this.logger.trace(`Subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches. Events: "${JSON.stringify(topics)}"`); for (const wsMessage of subscribeWsMessages) { this.logger.trace(`Sending batch via message: "${wsMessage}"`); this.tryWsSend(wsKey, wsMessage); } this.logger.trace(`Finished subscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`); } /** * Try sending a string event on a WS connection (identified by the WS Key) */ tryWsSend(wsKey, wsMessage) { try { this.logger.trace('Sending upstream ws message: ', { ...WS_LOGGER_CATEGORY, wsMessage, wsKey, }); if (!wsKey) { throw new Error('Cannot send message due to no known websocket for this wsKey'); } const ws = this.getWs(wsKey); if (!ws) { throw new Error(`${wsKey} socket not connected yet, call "connectAll()" first then try again when the "open" event arrives`); } ws.send(wsMessage); } catch (e) { this.logger.error('Failed to send WS message', { ...WS_LOGGER_CATEGORY, wsMessage, wsKey, exception: e, }); } } connectToWsUrl(url, wsKey) { this.logger.trace(`Opening WS connection to URL: ${url}`, { ...WS_LOGGER_CATEGORY, wsKey, }); const ws = new WebSocket(url, undefined); ws.onopen = (event) => this.onWsOpen(event, wsKey); ws.onmessage = (event) => this.onWsMessage(event, wsKey); ws.onerror = (event) => this.parseWsError('websocket error', event, wsKey); ws.onclose = (event) => this.onWsClose(event, wsKey); return ws; } async onWsOpen(event, wsKey) { if (this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING)) { this.logger.info('Websocket connected', { ...WS_LOGGER_CATEGORY, wsKey, }); this.emit('open', { wsKey, event }); } else if (this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.RECONNECTING)) { this.logger.info('Websocket reconnected', { ...WS_LOGGER_CATEGORY, wsKey, }); this.emit('reconnected', { wsKey, event }); } this.setWsState(wsKey, WsConnectionStateEnum.CONNECTED); // Some websockets require an auth packet to be sent after opening the connection if (this.isPrivateWsKey(wsKey)) { await this.sendAuthRequest(wsKey); } // Reconnect to topics known before it connected // Private topics will be resubscribed to once reconnected const topics = [...this.wsStore.getTopics(wsKey)]; const publicTopics = topics.filter((topic) => !this.isPrivateChannel(topic)); this.requestSubscribeTopics(wsKey, publicTopics); this.logger.trace('Enabled ping timer', { ...WS_LOGGER_CATEGORY, wsKey }); this.wsStore.get(wsKey, true).activePingTimer = setInterval(() => this.ping(wsKey), this.options.pingInterval); } /** Handle subscription to private topics _after_ authentication successfully completes asynchronously */ onWsAuthenticated(wsKey) { const wsState = this.wsStore.get(wsKey, true); wsState.isAuthenticated = true; const topics = [...this.wsStore.getTopics(wsKey)]; const privateTopics = topics.filter((topic) => this.isPrivateChannel(topic)); if (privateTopics.length) { this.subscribe(privateTopics, this.getWsMarketForWsKey(wsKey), true); } } onWsMessage(event, wsKey) { try { // any message can clear the pong timer - wouldn't get a message if the ws wasn't working this.clearPongTimer(wsKey); if (this.isWsPong(event)) { this.logger.trace('Received pong', { ...WS_LOGGER_CATEGORY, wsKey }); return; } if (isMessageEvent(event)) { const data = event.data; const dataType = event.type; const emittableEvents = this.resolveEmittableEvents(event); if (!emittableEvents.length) { // console.log(`raw event: `, { data, dataType, emittableEvents }); this.logger.error('Unhandled/unrecognised ws event message - returned no emittable data', { ...WS_LOGGER_CATEGORY, message: data || 'no message', dataType, event, wsKey, }); return this.emit('update', { ...event, wsKey }); } for (const emittable of emittableEvents) { if (this.isWsPong(emittable)) { this.logger.trace('Received pong', { ...WS_LOGGER_CATEGORY, wsKey, data, }); continue; } if (emittable.eventType === 'authenticated') { this.logger.trace('Successfully authenticated', { ...WS_LOGGER_CATEGORY, wsKey, }); this.emit(emittable.eventType, { ...emittable.event, wsKey }); this.onWsAuthenticated(wsKey); continue; } this.emit(emittable.eventType, { ...emittable.event, wsKey }); } return; } this.logger.error('Unhandled/unrecognised ws event message - unexpected message format', { ...WS_LOGGER_CATEGORY, message: event || 'no message', event, wsKey, }); } catch (e) { this.logger.error('Failed to parse ws event message', { ...WS_LOGGER_CATEGORY, error: e, event, wsKey, }); } } onWsClose(event, wsKey) { this.logger.info('Websocket connection closed', { ...WS_LOGGER_CATEGORY, wsKey, }); if (this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING) { this.reconnectWithDelay(wsKey, this.options.reconnectTimeout); this.emit('reconnect', { wsKey, event }); } else { // intentional close - clean up this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); // This was an intentional close, delete all state for this connection, as if it never existed: this.wsStore.delete(wsKey); this.emit('close', { wsKey, event }); } } getWs(wsKey) { return this.wsStore.getWs(wsKey); } setWsState(wsKey, state) { this.wsStore.setConnectionState(wsKey, state); } } //# sourceMappingURL=BaseWSClient.js.map