okx-api
Version:
Complete & robust Node.js SDK for OKX's REST APIs and WebSockets, with TypeScript & end-to-end tests
472 lines • 24.3 kB
JavaScript
;
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebsocketClient = void 0;
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
const events_1 = require("events");
const isomorphic_ws_1 = __importDefault(require("isomorphic-ws"));
const util_1 = require("./util");
const node_support_1 = require("./util/node-support");
const websocket_util_1 = require("./util/websocket-util");
const loggerCategory = { category: 'okx-ws' };
class WebsocketClient extends events_1.EventEmitter {
constructor(options, logger) {
super();
this.logger = logger || util_1.DefaultLogger;
this.wsStore = new util_1.WsStore(this.logger);
this.options = Object.assign({ market: 'prod', pongTimeout: 2000, pingInterval: 10000, reconnectTimeout: 500 }, options);
// add default error handling so this doesn't crash node (if the user didn't set a handler)
this.on('error', () => { });
}
/**
* Subscribe to topics & track/persist them. They will be automatically resubscribed to if the connection drops/reconnects.
* @param wsEvents 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)
*/
subscribe(wsEvents, isPrivateTopic) {
const wsEventArgs = Array.isArray(wsEvents) ? wsEvents : [wsEvents];
wsEventArgs.forEach((wsEventArg) => {
const wsKey = (0, util_1.getWsKeyForTopicChannel)(this.options.market, wsEventArg.channel, isPrivateTopic);
// Persist topic for reconnects
this.wsStore.addComplexTopic(wsKey, wsEventArg);
// if connected, send subscription request
if (this.wsStore.isConnectionState(wsKey, util_1.WsConnectionStateEnum.CONNECTED)) {
return this.requestSubscribeTopics(wsKey, [wsEventArg]);
}
// start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect
if (!this.wsStore.isConnectionState(wsKey, util_1.WsConnectionStateEnum.CONNECTING) &&
!this.wsStore.isConnectionState(wsKey, util_1.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, isPrivateTopic) {
const wsEventArgs = Array.isArray(wsTopics) ? wsTopics : [wsTopics];
wsEventArgs.forEach((wsEventArg) => {
const wsKey = (0, util_1.getWsKeyForTopicChannel)(this.options.market, wsEventArg.channel, isPrivateTopic);
// Remove topic from persistence for reconnects
this.wsStore.deleteComplexTopic(wsKey, wsEventArg);
// unsubscribe request only necessary if active connection exists
if (this.wsStore.isConnectionState(wsKey, util_1.WsConnectionStateEnum.CONNECTED)) {
this.requestUnsubscribeTopics(wsKey, [wsEventArg]);
}
});
}
/** Get the WsStore that tracks websocket & topic state */
getWsStore() {
return this.wsStore;
}
close(wsKey, force) {
this.logger.info('Closing connection', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
this.wsStore.setConnectionState(wsKey, util_1.WsConnectionStateEnum.CLOSING);
this.clearTimers(wsKey);
const ws = this.wsStore.getWs(wsKey);
ws === null || ws === void 0 ? void 0 : ws.close();
if (force) {
(0, websocket_util_1.safeTerminateWs)(ws);
}
}
closeAll(force) {
const keys = this.wsStore.getKeys();
this.logger.info(`Closing all ws connections: ${keys}`);
keys.forEach((key) => {
this.close(key, force);
});
}
/**
* Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library
*/
connectAll() {
return [this.connectPublic(), this.connectPrivate()];
}
connectPublic(businessEndpoint) {
const isPrivate = false;
const wsKey = (0, websocket_util_1.getWsKeyForMarket)(this.options.market, isPrivate, !!businessEndpoint);
return this.connect(util_1.WS_KEY_MAP[wsKey]);
}
connectPrivate(businessEndpoint) {
const isPrivate = true;
const wsKey = (0, websocket_util_1.getWsKeyForMarket)(this.options.market, isPrivate, !!businessEndpoint);
return this.connect(util_1.WS_KEY_MAP[wsKey]);
}
connect(wsKey) {
return __awaiter(this, void 0, void 0, function* () {
try {
if (this.wsStore.isWsOpen(wsKey)) {
this.logger.error('Refused to connect to ws with existing active connection', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
return this.wsStore.getWs(wsKey);
}
if (this.wsStore.isConnectionState(wsKey, util_1.WsConnectionStateEnum.CONNECTING)) {
this.logger.error('Refused to connect to ws, connection attempt already active', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
return;
}
if (!this.wsStore.getConnectionState(wsKey) ||
this.wsStore.isConnectionState(wsKey, util_1.WsConnectionStateEnum.INITIAL)) {
this.wsStore.setConnectionState(wsKey, util_1.WsConnectionStateEnum.CONNECTING);
}
const url = (0, websocket_util_1.getWsUrlForWsKey)(wsKey, this.options, this.logger);
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('error', error);
return;
}
switch (error.message) {
default:
if (this.wsStore.getConnectionState(wsKey) !==
util_1.WsConnectionStateEnum.CLOSING) {
this.logger.error(`${context} due to unexpected response error: "${(error === null || error === void 0 ? void 0 : error.msg) || (error === null || error === void 0 ? void 0 : error.message) || error}"`, Object.assign(Object.assign({}, loggerCategory), { wsKey, error }));
this.executeReconnectableClose(wsKey, 'unhandled onWsError');
}
else {
this.logger.info(`${wsKey} socket forcefully closed. Will not reconnect.`);
}
break;
}
this.emit('error', error);
}
/**
* Return params required to make authorized request
*/
getWsAuthRequest(wsKey) {
return __awaiter(this, void 0, void 0, function* () {
const isPublicWsKey = util_1.PUBLIC_WS_KEYS.includes(wsKey);
const accounts = this.options.accounts;
const hasAccountsToAuth = !!(accounts === null || accounts === void 0 ? void 0 : accounts.length);
if (isPublicWsKey || !accounts || !hasAccountsToAuth) {
this.logger.debug('Starting public only websocket client.', Object.assign(Object.assign({}, loggerCategory), { wsKey,
isPublicWsKey,
hasAccountsToAuth }));
return;
}
try {
const authAccountRequests = accounts.map((credentials) => __awaiter(this, void 0, void 0, function* () {
try {
const { signature, timestamp } = yield this.getWsAuthSignature(wsKey, credentials);
return {
apiKey: credentials.apiKey,
passphrase: credentials.apiPass,
timestamp: timestamp,
sign: signature,
};
}
catch (e) {
this.logger.error(`Account with key ${credentials.apiKey} could not be authenticateD: ${e}`);
}
return;
}));
const signedAuthAccountRequests = yield Promise.all(authAccountRequests);
// Filter out failed accounts
const authRequests = signedAuthAccountRequests.filter((request) => !!request);
const authParams = {
op: 'login',
args: authRequests,
};
return authParams;
}
catch (e) {
this.logger.error(e, Object.assign(Object.assign({}, loggerCategory), { wsKey }));
return;
}
});
}
getWsAuthSignature(wsKey, credentials) {
return __awaiter(this, void 0, void 0, function* () {
const { apiKey, apiSecret } = credentials;
if (!apiKey || !apiSecret) {
this.logger.warning('Cannot authenticate websocket, either api or secret missing.', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
throw new Error(`Cannot auth - missing api or secret in config (key: ${apiKey})`);
}
this.logger.debug("Getting auth'd request params", Object.assign(Object.assign({}, loggerCategory), { wsKey }));
const timestamp = (Date.now() / 1000).toFixed(0);
// const signatureExpiresAt = timestamp + 5;
const signatureRequest = timestamp + 'GET' + '/users/self/verify';
const signature = yield (0, node_support_1.signMessage)(signatureRequest, apiSecret);
return {
signature,
timestamp,
};
});
}
sendAuthRequest(wsKey) {
return __awaiter(this, void 0, void 0, function* () {
const logContext = Object.assign(Object.assign({}, loggerCategory), { wsKey, method: 'sendAuthRequest' });
this.logger.info('Sending auth request...', logContext);
try {
const authRequest = yield this.getWsAuthRequest(wsKey);
if (!authRequest) {
throw new Error('Cannot authenticate this connection');
}
this.logger.info(`Sending authentication request on wsKey(${wsKey})`, logContext);
this.logger.silly(`Authenticating with event: ${JSON.stringify(authRequest, null, 2)} on wsKey(${wsKey})`, logContext);
return this.tryWsSend(wsKey, JSON.stringify(authRequest));
}
catch (e) {
this.logger.error(e, logContext);
}
});
}
reconnectWithDelay(wsKey, connectionDelayMs) {
var _a;
this.clearTimers(wsKey);
if (this.wsStore.getConnectionState(wsKey) !==
util_1.WsConnectionStateEnum.CONNECTING) {
this.wsStore.setConnectionState(wsKey, util_1.WsConnectionStateEnum.RECONNECTING);
}
if ((_a = this.wsStore.get(wsKey)) === null || _a === void 0 ? void 0 : _a.activeReconnectTimer) {
this.clearReconnectTimer(wsKey);
}
this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => {
this.logger.info('Reconnecting to websocket', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
this.clearReconnectTimer(wsKey);
this.connect(wsKey);
}, connectionDelayMs);
}
ping(wsKey) {
if (this.wsStore.get(wsKey, true).activePongTimer) {
return;
}
this.clearPongTimer(wsKey);
this.logger.silly('Sending ping', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
this.tryWsSend(wsKey, 'ping');
this.wsStore.get(wsKey, true).activePongTimer = setTimeout(() => this.executeReconnectableClose(wsKey, 'Pong timeout'), this.options.pongTimeout);
}
/**
* Closes a connection, if it's even open. If open, this will trigger a reconnect asynchronously.
* If closed, trigger a reconnect immediately
*/
executeReconnectableClose(wsKey, reason) {
this.logger.info(`${reason} - closing socket to reconnect`, Object.assign(Object.assign({}, loggerCategory), { wsKey,
reason }));
const wasOpen = this.wsStore.isWsOpen(wsKey);
if (wasOpen) {
(0, websocket_util_1.safeTerminateWs)(this.wsStore.getWs(wsKey), true);
}
delete this.wsStore.get(wsKey, true).activePongTimer;
this.clearPingTimer(wsKey);
this.clearPongTimer(wsKey);
if (!wasOpen) {
this.logger.info(`${reason} - socket already closed - trigger immediate reconnect`, Object.assign(Object.assign({}, loggerCategory), { wsKey,
reason }));
this.reconnectWithDelay(wsKey, this.options.reconnectTimeout);
}
}
clearTimers(wsKey) {
this.clearPingTimer(wsKey);
this.clearPongTimer(wsKey);
this.clearReconnectTimer(wsKey);
}
// Send a ping at intervals
clearPingTimer(wsKey) {
const wsState = this.wsStore.get(wsKey);
if (wsState === null || wsState === void 0 ? void 0 : 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 === null || wsState === void 0 ? void 0 : wsState.activePongTimer) {
clearTimeout(wsState.activePongTimer);
wsState.activePongTimer = undefined;
}
}
clearReconnectTimer(wsKey) {
const wsState = this.wsStore.get(wsKey);
if (wsState === null || wsState === void 0 ? void 0 : wsState.activeReconnectTimer) {
clearTimeout(wsState.activeReconnectTimer);
wsState.activeReconnectTimer = undefined;
}
}
/**
* @private Use the `subscribe(topics)` method to subscribe to topics. Send WS message to subscribe to topics.
*/
requestSubscribeTopics(wsKey, topics) {
if (!topics.length) {
return;
}
const maxTopicsPerEvent = (0, util_1.getMaxTopicsPerSubscribeEvent)(this.options.market);
if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) {
this.logger.silly(`Subscribing to topics in batches of ${maxTopicsPerEvent}`);
for (let i = 0; i < topics.length; i += maxTopicsPerEvent) {
const batch = topics.slice(i, i + maxTopicsPerEvent);
this.logger.silly(`Subscribing to batch of ${batch.length}`);
this.requestSubscribeTopics(wsKey, batch);
}
this.logger.silly(`Finished batch subscribing to ${topics.length} topics`);
return;
}
const request = {
op: 'subscribe',
args: topics,
};
const wsMessage = JSON.stringify(request);
this.tryWsSend(wsKey, wsMessage);
}
/**
* @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 maxTopicsPerEvent = (0, util_1.getMaxTopicsPerSubscribeEvent)(this.options.market);
if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) {
this.logger.silly(`Unsubscribing to topics in batches of ${maxTopicsPerEvent}`);
for (let i = 0; i < topics.length; i += maxTopicsPerEvent) {
const batch = topics.slice(i, i + maxTopicsPerEvent);
this.logger.silly(`Unsubscribing to batch of ${batch.length}`);
this.requestUnsubscribeTopics(wsKey, batch);
}
this.logger.silly(`Finished batch unsubscribing to ${topics.length} topics`);
return;
}
const request = {
op: 'unsubscribe',
args: topics,
};
const wsMessage = JSON.stringify(request);
this.tryWsSend(wsKey, wsMessage);
}
tryWsSend(wsKey, wsMessage) {
try {
this.logger.silly('Sending upstream ws message: ', Object.assign(Object.assign({}, loggerCategory), { wsMessage,
wsKey }));
if (!wsKey) {
throw new Error(`Cannot send message (wsKey not provided: wsKey(${wsKey}))`);
}
const ws = this.wsStore.getWs(wsKey);
if (!ws) {
throw new Error(`${wsKey} socket not connected yet, call "connect(${wsKey}) first then try again when the "open" event arrives`);
}
ws.send(wsMessage);
}
catch (e) {
this.logger.error('Failed to send WS message', Object.assign(Object.assign({}, loggerCategory), { wsMessage,
wsKey, exception: e }));
}
}
connectToWsUrl(url, wsKey) {
var _a;
this.logger.silly(`Opening WS connection to URL: ${url}`, Object.assign(Object.assign({}, loggerCategory), { wsKey }));
const agent = (_a = this.options.requestOptions) === null || _a === void 0 ? void 0 : _a.agent;
const ws = new isomorphic_ws_1.default(url, undefined, agent ? { agent } : undefined);
ws.onopen = (event) => this.onWsOpen(event, wsKey);
ws.onmessage = (event) => this.onWsMessage(event, wsKey);
ws.onerror = (event) => this.parseWsError('Websocket onWsError', event, wsKey);
ws.onclose = (event) => this.onWsClose(event, wsKey);
return ws;
}
onWsOpen(event, wsKey) {
return __awaiter(this, void 0, void 0, function* () {
if (this.wsStore.isConnectionState(wsKey, util_1.WsConnectionStateEnum.CONNECTING)) {
this.logger.info('Websocket connected', Object.assign(Object.assign({}, loggerCategory), { wsKey, market: this.options.market }));
this.emit('open', { wsKey, event });
}
else if (this.wsStore.isConnectionState(wsKey, util_1.WsConnectionStateEnum.RECONNECTING)) {
this.logger.info('Websocket reconnected', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
this.emit('reconnected', { wsKey, event });
}
this.wsStore.setConnectionState(wsKey, util_1.WsConnectionStateEnum.CONNECTED);
this.wsStore.get(wsKey, true).activePingTimer = setInterval(() => this.ping(wsKey), this.options.pingInterval);
// Private websockets require an auth packet to be sent after opening the connection
if (util_1.PRIVATE_WS_KEYS.includes(wsKey)) {
// Any requested private topics will be subscribed to once authentication succeeds (in onWsMessage handler)
yield this.sendAuthRequest(wsKey);
// Private topics will be subscribed to once authentication is confirmed as successful
return;
}
// Public topics can be subscribed to immediately
const topics = [
...this.wsStore.getTopics(wsKey),
];
// Since public channels have their own ws key, these topics must be public ones already
this.requestSubscribeTopics(wsKey, topics);
});
}
onWsMessage(event, wsKey) {
const logContext = Object.assign(Object.assign({}, loggerCategory), { wsKey, method: 'onWsMessage' });
try {
// any message can clear the pong timer - wouldn't get a message if the ws dropped
this.clearPongTimer(wsKey);
if ((0, util_1.isWsPong)(event)) {
this.logger.silly('Received pong', logContext);
return;
}
const msg = JSON.parse((event === null || event === void 0 ? void 0 : event.data) || event);
if ((0, util_1.isWsErrorEvent)(msg)) {
this.logger.error('WS error event: ', Object.assign(Object.assign({}, msg), { wsKey }));
return this.emit('error', Object.assign(Object.assign({}, msg), { wsKey }));
}
if ((0, util_1.isWsDataEvent)(msg)) {
return this.emit('update', Object.assign(Object.assign({}, msg), { wsKey }));
}
if ((0, util_1.isWsLoginEvent)(msg)) {
// Successfully authenticated
if (msg.code === websocket_util_1.WS_EVENT_CODE_ENUM.OK) {
this.logger.info(`Authenticated successfully on wsKey(${wsKey})`, logContext);
this.emit('response', Object.assign(Object.assign({}, msg), { wsKey }));
const topics = [
...this.wsStore.getTopics(wsKey),
];
// Since private topics have a dedicated WsKey, these are automatically all private topics (no filtering required before the subscribe call)
this.requestSubscribeTopics(wsKey, topics);
return;
}
this.logger.error('Authentication failed: ', Object.assign(Object.assign(Object.assign({}, logContext), msg), { wsKey }));
return this.emit('error', Object.assign(Object.assign({}, msg), { wsKey }));
}
if ((0, util_1.isWsSubscribeEvent)(msg) || (0, util_1.isWsUnsubscribeEvent)(msg)) {
// this.logger.silly(`Ws subscribe reply:`, { ...msg, wsKey });
return this.emit('response', Object.assign(Object.assign({}, msg), { wsKey }));
}
if ((0, util_1.isConnCountEvent)(msg)) {
return this.emit('response', Object.assign(Object.assign({}, msg), { wsKey }));
}
this.logger.error('Unhandled/unrecognised ws event message', Object.assign(Object.assign({}, logContext), { eventName: msg.event, msg: JSON.stringify(msg, null, 2), wsKey }));
}
catch (e) {
this.logger.error('Failed to parse ws event message', Object.assign(Object.assign({}, logContext), { error: e, event,
wsKey }));
}
}
onWsClose(event, wsKey) {
this.logger.info('Websocket connection closed', Object.assign(Object.assign({}, loggerCategory), { wsKey }));
if (this.wsStore.getConnectionState(wsKey) !== util_1.WsConnectionStateEnum.CLOSING) {
this.reconnectWithDelay(wsKey, this.options.reconnectTimeout);
this.emit('reconnect', { wsKey, event });
}
else {
this.wsStore.setConnectionState(wsKey, util_1.WsConnectionStateEnum.INITIAL);
this.emit('close', { wsKey, event });
}
}
}
exports.WebsocketClient = WebsocketClient;
//# sourceMappingURL=websocket-client.js.map