UNPKG

okx-api

Version:

Complete Node.js SDK for OKX's REST APIs and WebSockets, with TypeScript & end-to-end tests

524 lines 24 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WebsocketClientLegacy = void 0; /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ const events_1 = require("events"); const isomorphic_ws_1 = __importDefault(require("isomorphic-ws")); const logger_js_1 = require("./util/logger.js"); const typeGuards_js_1 = require("./util/typeGuards.js"); const webCryptoAPI_js_1 = require("./util/webCryptoAPI.js"); const websocket_util_js_1 = require("./util/websocket-util.js"); const WsStore_js_1 = __importDefault(require("./util/WsStore.js")); const WsStore_types_js_1 = require("./util/WsStore.types.js"); /** * @deprecated This is the old WebsocketClient that was part of the SDK prior to the V3 release. This legacy WebsocketClient is temporarily included but will be removed with the next major release. */ class WebsocketClientLegacy extends events_1.EventEmitter { logger; options; wsStore; constructor(options, logger) { super(); this.logger = logger || logger_js_1.DefaultLogger; this.wsStore = new WsStore_js_1.default(this.logger); this.options = { market: 'prod', pongTimeout: 2000, pingInterval: 10000, reconnectTimeout: 500, // Automatically send an authentication op/request after a connection opens, for private connections. authPrivateConnectionsOnConnect: true, // Individual requests do not require a signature, so this is disabled. authPrivateRequests: false, useNativeHeartbeats: false, ...options, }; if (this.options.market === 'demo') { throw new Error('ERROR: to use demo trading, set the "demoTrading: true" flag in the constructor. The "demo" market is not valid any more.'); } } /** * 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, websocket_util_js_1.getWsKeyForTopicChannel)(this.options.market, wsEventArg.channel, isPrivateTopic); // Persist topic for reconnects this.wsStore.addTopic(wsKey, wsEventArg); // if connected, send subscription request if (this.wsStore.isConnectionState(wsKey, WsStore_types_js_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, WsStore_types_js_1.WsConnectionStateEnum.CONNECTING) && !this.wsStore.isConnectionState(wsKey, WsStore_types_js_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, websocket_util_js_1.getWsKeyForTopicChannel)(this.options.market, wsEventArg.channel, isPrivateTopic); // Remove topic from persistence for reconnects this.wsStore.deleteTopic(wsKey, wsEventArg); // unsubscribe request only necessary if active connection exists if (this.wsStore.isConnectionState(wsKey, WsStore_types_js_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', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey }); this.wsStore.setConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.CLOSING); this.clearTimers(wsKey); const ws = this.wsStore.getWs(wsKey); ws?.close(); if (force) { (0, websocket_util_js_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_js_1.getWsKeyForMarket)(this.options.market, isPrivate, !!businessEndpoint); return this.connect(websocket_util_js_1.WS_KEY_MAP[wsKey]); } connectPrivate(businessEndpoint) { const isPrivate = true; const wsKey = (0, websocket_util_js_1.getWsKeyForMarket)(this.options.market, isPrivate, !!businessEndpoint); return this.connect(websocket_util_js_1.WS_KEY_MAP[wsKey]); } async connect(wsKey) { try { if (this.wsStore.isWsOpen(wsKey)) { this.logger.error('Refused to connect to ws with existing active connection', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey }); return this.wsStore.getWs(wsKey); } if (this.wsStore.isConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.CONNECTING)) { this.logger.error('Refused to connect to ws, connection attempt already active', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey }); return; } if (!this.wsStore.getConnectionState(wsKey) || this.wsStore.isConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.INITIAL)) { this.wsStore.setConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.CONNECTING); } const url = (0, websocket_util_js_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('exception', error); return; } switch (error.message) { default: if (this.wsStore.getConnectionState(wsKey) !== WsStore_types_js_1.WsConnectionStateEnum.CLOSING) { this.logger.error(`${context} due to unexpected response error: "${error?.msg || error?.message || error}"`, { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, error }); this.executeReconnectableClose(wsKey, 'unhandled onWsError'); } else { this.logger.info(`${wsKey} socket forcefully closed. Will not reconnect.`); } break; } this.emit('exception', error); } /** * Return params required to make authorized request */ async getWsAuthRequest(wsKey) { const isPublicWsKey = websocket_util_js_1.PUBLIC_WS_KEYS.includes(wsKey); const accounts = this.options.accounts; const hasAccountsToAuth = !!accounts?.length; if (isPublicWsKey || !accounts || !hasAccountsToAuth) { this.logger.trace('Starting public only websocket client.', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, isPublicWsKey, hasAccountsToAuth, }); return; } try { const authAccountRequests = accounts.map(async (credentials) => { try { const { signature, timestamp } = await 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 = await 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, { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey }); return; } } async getWsAuthSignature(wsKey, credentials) { const { apiKey, apiSecret } = credentials; if (!apiKey || !apiSecret) { this.logger.info('Cannot authenticate websocket, either api or secret missing.', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey }); throw new Error(`Cannot auth - missing api or secret in config (key: ${apiKey})`); } this.logger.trace("Getting auth'd request params", { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, }); const timestamp = (Date.now() / 1000).toFixed(0); // const signatureExpiresAt = timestamp + 5; const signatureRequest = timestamp + 'GET' + '/users/self/verify'; const signature = await (0, webCryptoAPI_js_1.signMessage)(signatureRequest, apiSecret, 'base64', 'SHA-256'); return { signature, timestamp, }; } async sendAuthRequest(wsKey) { const logContext = { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, method: 'sendAuthRequest', }; this.logger.info('Sending auth request...', logContext); try { const authRequest = await this.getWsAuthRequest(wsKey); if (!authRequest) { throw new Error('Cannot authenticate this connection'); } this.logger.info(`Sending authentication request on wsKey(${wsKey})`, logContext); this.logger.trace(`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) { this.clearTimers(wsKey); if (this.wsStore.getConnectionState(wsKey) !== WsStore_types_js_1.WsConnectionStateEnum.CONNECTING) { this.wsStore.setConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.RECONNECTING); } if (this.wsStore.get(wsKey)?.activeReconnectTimer) { this.clearReconnectTimer(wsKey); } this.wsStore.get(wsKey, true).activeReconnectTimer = setTimeout(() => { this.logger.info('Reconnecting to websocket', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, }); this.clearReconnectTimer(wsKey); this.connect(wsKey); }, connectionDelayMs); } ping(wsKey) { if (this.wsStore.get(wsKey, true).activePongTimer) { return; } this.clearPongTimer(wsKey); this.logger.trace('Sending ping', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, 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`, { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, reason, }); const wasOpen = this.wsStore.isWsOpen(wsKey); if (wasOpen) { (0, websocket_util_js_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`, { ...websocket_util_js_1.WS_LOGGER_CATEGORY, 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?.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; } } clearReconnectTimer(wsKey) { const wsState = this.wsStore.get(wsKey); if (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, websocket_util_js_1.getMaxTopicsPerSubscribeEventForMarket)(this.options.market); if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) { this.logger.trace(`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.trace(`Subscribing to batch of ${batch.length}`); this.requestSubscribeTopics(wsKey, batch); } this.logger.trace(`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, websocket_util_js_1.getMaxTopicsPerSubscribeEventForMarket)(this.options.market); if (maxTopicsPerEvent && topics.length > maxTopicsPerEvent) { this.logger.trace(`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.trace(`Unsubscribing to batch of ${batch.length}`); this.requestUnsubscribeTopics(wsKey, batch); } this.logger.trace(`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.trace('Sending upstream ws message: ', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, 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', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsMessage, wsKey, exception: e, }); } } connectToWsUrl(url, wsKey) { this.logger.trace(`Opening WS connection to URL: ${url}`, { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, }); const { protocols = [], ...wsOptions } = this.options.wsOptions || {}; const ws = new isomorphic_ws_1.default(url, protocols, wsOptions); ws.onopen = (event) => this.onWsOpen(event, wsKey, url, ws); ws.onmessage = (event) => this.onWsMessage(event, wsKey, ws); ws.onerror = (event) => this.parseWsError('Websocket onWsError', event, wsKey); ws.onclose = (event) => this.onWsClose(event, wsKey); return ws; } async onWsOpen(event, wsKey, url, ws) { if (this.wsStore.isConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.CONNECTING)) { this.logger.info('Websocket connected', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, market: this.options.market, }); this.emit('open', { wsKey, event, wsUrl: url, ws }); } else if (this.wsStore.isConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.RECONNECTING)) { this.logger.info('Websocket reconnected', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, }); this.emit('reconnected', { wsKey, event, wsUrl: url, ws }); } this.wsStore.setConnectionState(wsKey, WsStore_types_js_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 (websocket_util_js_1.PRIVATE_WS_KEYS.includes(wsKey)) { // Any requested private topics will be subscribed to once authentication succeeds (in onWsMessage handler) await 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, _ws) { const logContext = { ...websocket_util_js_1.WS_LOGGER_CATEGORY, 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, websocket_util_js_1.isWsPong)(event)) { this.logger.trace('Received pong', logContext); return; } const msg = JSON.parse(event?.data || event); if ((0, typeGuards_js_1.isWsErrorEvent)(msg)) { this.logger.error('WS error event: ', { ...msg, wsKey }); return this.emit('exception', { ...msg, wsKey }); } if ((0, typeGuards_js_1.isWsDataEvent)(msg)) { return this.emit('update', { ...msg, wsKey }); } if ((0, typeGuards_js_1.isWsLoginEvent)(msg)) { // Successfully authenticated if (msg.code === websocket_util_js_1.WS_EVENT_CODE_ENUM.OK) { this.logger.info(`Authenticated successfully on wsKey(${wsKey})`, logContext); this.emit('response', { ...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: ', { ...logContext, ...msg, wsKey, }); return this.emit('exception', { ...msg, wsKey }); } if ((0, typeGuards_js_1.isWsSubscribeEvent)(msg) || (0, typeGuards_js_1.isWsUnsubscribeEvent)(msg)) { // this.logger.trace(`Ws subscribe reply:`, { ...msg, wsKey }); return this.emit('response', { ...msg, wsKey }); } if ((0, typeGuards_js_1.isConnCountEvent)(msg)) { return this.emit('response', { ...msg, wsKey }); } this.logger.error('Unhandled/unrecognised ws event message', { ...logContext, eventName: msg.event, msg: JSON.stringify(msg, null, 2), wsKey, }); } catch (e) { this.logger.error('Failed to parse ws event message', { ...logContext, error: e, event, wsKey, }); } } onWsClose(event, wsKey) { this.logger.info('Websocket connection closed', { ...websocket_util_js_1.WS_LOGGER_CATEGORY, wsKey, }); if (this.wsStore.getConnectionState(wsKey) !== WsStore_types_js_1.WsConnectionStateEnum.CLOSING) { this.reconnectWithDelay(wsKey, this.options.reconnectTimeout); this.emit('reconnect', { wsKey, event }); } else { this.wsStore.setConnectionState(wsKey, WsStore_types_js_1.WsConnectionStateEnum.INITIAL); this.emit('close', { wsKey, event }); } } } exports.WebsocketClientLegacy = WebsocketClientLegacy; //# sourceMappingURL=websocket-client-legacy.js.map