UNPKG

okx-api

Version:

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

576 lines 23.6 kB
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */ import { BaseWebsocketClient, } from './util/BaseWSClient.js'; import { isConnCountEvent, isWSAPIResponse, isWsDataEvent, isWsErrorEvent, isWsLoginEvent, isWsSubscribeEvent, isWsUnsubscribeEvent, neverGuard, } from './util/typeGuards.js'; import { signMessage, } from './util/webCryptoAPI.js'; import { getDemoWsKey, getPromiseRefForWSAPIRequest, getWsKeyForMarket, getWsKeyForTopicChannel, getWsUrlForWsKey, isWsPong, PRIVATE_WS_KEYS, PUBLIC_WS_KEYS, requiresWSAPITag, validateWSAPITag, WS_EVENT_CODE_ENUM, WS_KEY_MAP, WS_LOGGER_CATEGORY, } from './util/websocket-util.js'; export class WebsocketClient extends BaseWebsocketClient { constructor(options, logger) { super(options, logger); 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.'); } } /** * 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 = getWsKeyForMarket(this.options.market, isPrivate, !!businessEndpoint); return this.connect(WS_KEY_MAP[wsKey]); } connectPrivate(businessEndpoint) { const isPrivate = true; const wsKey = getWsKeyForMarket(this.options.market, isPrivate, !!businessEndpoint); return this.connect(WS_KEY_MAP[wsKey]); } /** * Ensures the WS API connection is active and ready. * * You do not need to call this, but if you call this before making any WS API requests, * it can accelerate the first request (by preparing the connection in advance). */ connectWSAPI() { /** This call automatically ensures the connection is active AND authenticated before resolving */ return Promise.allSettled([ this.assertIsAuthenticated(this.getMarketWsKey('private'), false), this.assertIsAuthenticated(this.getMarketWsKey('business'), false), ]); } /** * 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]; const topicRequestsByWsKey = {}; // Format and batch topic requests by WsKey (resolved dynamically) wsEventArgs.forEach((wsEvent) => { const { channel, ...payload } = wsEvent; const normalisedEvent = { topic: channel, payload, }; const wsKey = getWsKeyForTopicChannel(this.options.market, channel, isPrivateTopic); // Arrange into per-wsKey sorted lists if (!topicRequestsByWsKey[wsKey]) { topicRequestsByWsKey[wsKey] = []; } topicRequestsByWsKey[wsKey].push(normalisedEvent); }); const subscribeRequestPromises = []; for (const wsKeyUntyped in topicRequestsByWsKey) { subscribeRequestPromises.push(this.subscribeTopicsForWsKey(topicRequestsByWsKey[wsKeyUntyped], wsKeyUntyped)); } return subscribeRequestPromises; } /** * 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(wsEvents, isPrivateTopic) { const wsEventArgs = Array.isArray(wsEvents) ? wsEvents : [wsEvents]; const topicRequestsByWsKey = {}; // Format and batch topic requests by WsKey (resolved dynamically) wsEventArgs.forEach((wsEvent) => { const { channel, ...payload } = wsEvent; const normalisedEvent = { topic: channel, payload, }; const wsKey = getWsKeyForTopicChannel(this.options.market, channel, isPrivateTopic); // Arrange into per-wsKey sorted lists if (!topicRequestsByWsKey[wsKey]) { topicRequestsByWsKey[wsKey] = []; } topicRequestsByWsKey[wsKey].push(normalisedEvent); }); const unsubscribeRequestPromises = []; for (const wsKeyUntyped in topicRequestsByWsKey) { unsubscribeRequestPromises.push(this.unsubscribeTopicsForWsKey(topicRequestsByWsKey[wsKeyUntyped], wsKeyUntyped)); } return unsubscribeRequestPromises; } /** * * * Internal methods required to integrate with the BaseWSClient * * */ getMarketWsKey(type) { // returns private or business ws key for the active api market // defaults to global // automatically resolves to demo trading wsKeys under the hood (WSClient) const isPrivateType = type === 'private'; const isBusinessType = type === 'business'; switch (this.options.market) { case undefined: case 'prod': case 'GLOBAL': { return isPrivateType ? WS_KEY_MAP.prodPrivate : isBusinessType ? WS_KEY_MAP.prodBusiness : WS_KEY_MAP.prodPublic; } case 'EEA': { return isPrivateType ? WS_KEY_MAP.eeaLivePrivate : isBusinessType ? WS_KEY_MAP.eeaLiveBusiness : WS_KEY_MAP.eeaLivePublic; } case 'US': { return isPrivateType ? WS_KEY_MAP.usLivePrivate : isBusinessType ? WS_KEY_MAP.usLiveBusiness : WS_KEY_MAP.usLivePublic; } default: { throw neverGuard(this.options.market, `Unhandled market type "${this.options.market}"`); } } } sendPingEvent(wsKey) { this.tryWsSend(wsKey, 'ping'); } sendPongEvent(wsKey) { this.tryWsSend(wsKey, 'pong'); } isWsPing(data) { if (data?.data === 'ping') { return true; } return false; } isWsPong(data) { return isWsPong(data); } isPrivateTopicRequest(_request, wsKey) { return PRIVATE_WS_KEYS.includes(wsKey); } getPrivateWSKeys() { return PRIVATE_WS_KEYS; } isAuthOnConnectWsKey(wsKey) { return PRIVATE_WS_KEYS.includes(wsKey); } async getWsUrl(wsKey) { return getWsUrlForWsKey(wsKey, this.options, this.logger); } getMaxTopicsPerSubscribeEvent() { return null; } /** * @returns one or more correctly structured request events for performing a operations over WS. This can vary per exchange spec. */ async getWsRequestEvents(operation, requests) { const wsRequestBuildingErrors = []; const topics = requests.map((r) => r.topic + ',' + Object.values(r.payload || {}).join(',')); // Previously used to track topics in a request. Keeping this for subscribe/unsubscribe requests, no need for incremental values const req_id = ['subscribe', 'unsubscribe'].includes(operation) && topics.length ? topics.join(',') : this.getNewRequestId().toFixed(); /** { "op":"subscribe", "args":[ { "channel": "tickers", "instId": "BTC-USDT" }, { "channel": "tickers", "instId": "BTC-USDT" } ] } */ const wsEvent = { id: `${this.getNewRequestId()}`, op: operation, args: requests.map((request) => { // const request = { // topic: 'tickers', // payload: { instId: 'BTC-USDT' }, // }; // becomes: // const request = { // channel: 'ticker', // instId: 'BTC-USDT', // }; return { channel: request.topic, ...request.payload, }; }), }; const midflightWsEvent = { requestKey: req_id, requestEvent: wsEvent, }; if (wsRequestBuildingErrors.length) { const label = wsRequestBuildingErrors.length === requests.length ? 'all' : 'some'; this.logger.error(`Failed to build/send ${wsRequestBuildingErrors.length} event(s) for ${label} WS requests due to exceptions`, { ...WS_LOGGER_CATEGORY, wsRequestBuildingErrors, wsRequestBuildingErrorsStringified: JSON.stringify(wsRequestBuildingErrors, null, 2), }); } return [midflightWsEvent]; } async signMessage(paramsStr, secret, method, algorithm) { if (typeof this.options.customSignMessageFn === 'function') { return this.options.customSignMessageFn(paramsStr, secret); } return await signMessage(paramsStr, secret, method, algorithm); } async getWsAuthRequestEvent(wsKey, skipIsPublicWsKeyCheck) { const isPublicWsKey = PUBLIC_WS_KEYS.includes(wsKey); const accounts = this.options.accounts; const hasAccountsToAuth = !!accounts?.length; if (isPublicWsKey && !skipIsPublicWsKeyCheck) { this.logger.trace('Starting public only websocket client. No auth needed.', { ...WS_LOGGER_CATEGORY, wsKey, isPublicWsKey, hasAccountsToAuth, skipIsPublicWsKeyCheck, }); return null; } if (!accounts || !hasAccountsToAuth) { this.logger.trace('Starting public only websocket client - missing keys.', { ...WS_LOGGER_CATEGORY, wsKey, isPublicWsKey, hasAccountsToAuth, skipIsPublicWsKeyCheck, }); throw new Error('Cannot auth - missing api or secret or pass in config'); } 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}`, e?.stack); } return; }); const signedAuthAccountRequests = await Promise.all(authAccountRequests); // Filter out failed accounts const authRequests = signedAuthAccountRequests.filter((request) => !!request); const authParams = { id: `${this.getNewRequestId()}`, op: 'login', args: authRequests, }; return authParams; } catch (e) { this.logger.error({ ...WS_LOGGER_CATEGORY, wsKey, error: e, }); throw e; } } async getWsAuthSignature(wsKey, credentials) { const { apiKey, apiSecret } = credentials; if (!apiKey || !apiSecret) { this.logger.info('Cannot authenticate websocket, either api or secret missing.', { ...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", { ...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 this.signMessage(signatureRequest, apiSecret, 'base64', 'SHA-256'); return { signature, timestamp, }; } /** * Abstraction called to sort ws events into emittable event types (response to a request, data update, etc) */ resolveEmittableEvents(wsKey, event) { const results = []; const logContext = { ...WS_LOGGER_CATEGORY, wsKey, method: 'resolveEmittableEvents', }; try { const msg = JSON.parse(event.data); const emittableEvent = { ...msg, wsKey }; /** * WS API response handling */ if (isWSAPIResponse(emittableEvent)) { // const eg1 = { // id: '2', // op: 'order', // code: '1', // msg: '', // data: [ // { // tag: '159881cb7207BCDE', // ts: '1753783406701', // ordId: '', // clOrdId: '', // sCode: '51008', // sMsg: 'Order failed. Insufficient USDT balance in account.', // }, // ], // inTime: '1753783406701275', // outTime: '1753783406702251', // wsKey: 'prodPrivate', // }; const retCode = emittableEvent.code; const reqId = emittableEvent.id; const isError = retCode !== '0'; // check getPromiseRefForWSAPIRequest const promiseRef = [emittableEvent.id, emittableEvent.op].join('_'); const loggableContext = { wsKey, promiseRef, parsedEvent: emittableEvent, }; if (!reqId) { this.logger.error('WS API response is missing reqId - promisified workflow could get stuck. If this happens, please get in touch with steps to reproduce. Trace:', loggableContext); } if (isError) { try { this.getWsStore().rejectDeferredPromise(wsKey, promiseRef, emittableEvent, true); } catch (e) { this.logger.error('Exception trying to reject WSAPI promise', { ...loggableContext, error: e, }); } results.push({ eventType: 'exception', event: emittableEvent, isWSAPIResponse: true, }); return results; } // WS API Success try { this.getWsStore().resolveDeferredPromise(wsKey, promiseRef, emittableEvent, true); } catch (e) { this.logger.error('Exception trying to resolve WSAPI promise', { ...loggableContext, error: e, }); } results.push({ eventType: 'response', event: emittableEvent, isWSAPIResponse: true, }); return results; } if (isWsErrorEvent(msg)) { this.logger.error('WS Error received', { ...logContext, wsKey, message: msg || 'no message', // messageType: typeof msg, // messageString: JSON.stringify(msg), event, }); results.push({ eventType: 'exception', event: emittableEvent, }); return results; } if (isWsDataEvent(msg)) { results.push({ eventType: 'update', event: emittableEvent, }); return results; } if (isWsLoginEvent(msg)) { // Successfully authenticated if (msg.code === WS_EVENT_CODE_ENUM.OK) { this.logger.info(`Authenticated successfully on wsKey(${wsKey})`, logContext); results.push({ eventType: 'response', event: emittableEvent, }); results.push({ eventType: 'authenticated', event: emittableEvent, }); return results; } this.logger.error('Authentication failed: ', { ...logContext, ...msg, wsKey, }); results.push({ eventType: 'exception', event: emittableEvent, }); return results; } if (isWsSubscribeEvent(msg) || isWsUnsubscribeEvent(msg)) { results.push({ eventType: 'response', event: emittableEvent, }); // this.logger.trace(`Ws subscribe reply:`, { ...msg, wsKey }); return results; } if (isConnCountEvent(msg)) { results.push({ eventType: 'response', event: emittableEvent, }); return results; } if (msg.event === 'notice') { const WSNOTICE = { CLOSING_FOR_UPGRADE_RECOMMEND_RECONNECT: '64008', }; if (msg?.code === WSNOTICE.CLOSING_FOR_UPGRADE_RECOMMEND_RECONNECT) { const closeReason = `Received notice (${msg.code} - "${msg?.msg}") - closing socket to reconnect`; this.logger.info(closeReason, { ...WS_LOGGER_CATEGORY, ...msg, wsKey, }); // Queue immediate reconnection workflow this.executeReconnectableClose(wsKey, closeReason); // Emit notice to client for visibility results.push({ eventType: 'update', event: emittableEvent, }); return results; } } this.logger.info('Unhandled/unrecognised ws event message', { ...WS_LOGGER_CATEGORY, message: msg || 'no message', // messageType: typeof msg, // messageString: JSON.stringify(msg), event, wsKey, }); // fallback emit anyway results.push({ eventType: 'update', event: emittableEvent, }); return results; } catch (e) { this.logger.error('Failed to parse ws event message', { ...WS_LOGGER_CATEGORY, error: e, event, wsKey, }); } return results; } /** * OKX supports order placement via WebSockets. This is the WS API: * https://www.okx.com/docs-v5/en/#order-book-trading-trade-ws-place-order * * For convenient promise-wrapped usage of the WS API, instance the WebsocketAPIClient class exported by this SDK. * * For demo trading, set demoTrading:true in the WS Client config. * * @returns a promise that resolves/rejects when a matching response arrives */ async sendWSAPIRequest(rawWsKey, operation, params, requestFlags) { // If demo trading, enforce demo wskey for WS API calls const wsKey = this.options.demoTrading ? getDemoWsKey(rawWsKey) : rawWsKey; this.logger.trace(`sendWSAPIRequest(): assert "${wsKey}" is connected`); await this.assertIsConnected(wsKey); this.logger.trace('sendWSAPIRequest()->assertIsConnected() ok'); if (requestFlags?.authIsOptional !== true) { this.logger.trace('sendWSAPIRequest(): assertIsAuthenticated(${wsKey})...'); await this.assertIsAuthenticated(wsKey, false); this.logger.trace('sendWSAPIRequest(): assertIsAuthenticated(${wsKey}) ok'); } const request = { id: `${this.getNewRequestId()}`, op: operation, // Ensure "args" is always wrapped as array args: Array.isArray(params) ? params : [params], }; if (requestFlags?.expTime) { request.expTime = requestFlags.expTime; } if (requiresWSAPITag(operation, wsKey)) { validateWSAPITag(request, wsKey); } // Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events const promiseRef = getPromiseRefForWSAPIRequest(request); const deferredPromise = this.getWsStore().createDeferredPromise(wsKey, promiseRef, false); // Enrich returned promise with request context for easier debugging deferredPromise.promise ?.then((res) => { if (!Array.isArray(res)) { res.request = { wsKey, ...request, }; } return res; }) .catch((e) => { if (typeof e === 'string') { this.logger.error('Unexpected string thrown without Error object:', { e, wsKey, request, }); return e; } e.request = { wsKey, operation, params: params, }; // throw e; return e; }); this.logger.trace(`sendWSAPIRequest(): sending raw request: ${JSON.stringify(request, null, 2)}`); // Send event const throwExceptions = false; this.tryWsSend(wsKey, JSON.stringify(request), throwExceptions); this.logger.trace(`sendWSAPIRequest(): sent "${operation}" event with promiseRef(${promiseRef})`); // Return deferred promise, so caller can await this call return deferredPromise.promise; } } //# sourceMappingURL=websocket-client.js.map