bitmart-api
Version:
Complete & robust Node.js SDK for BitMart's REST APIs and WebSockets, with TypeScript declarations.
482 lines • 21.6 kB
JavaScript
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