okx-api
Version:
Complete Node.js SDK for OKX's REST APIs and WebSockets, with TypeScript & end-to-end tests
580 lines • 24.4 kB
JavaScript
"use strict";
/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebsocketClient = void 0;
const BaseWSClient_js_1 = require("./util/BaseWSClient.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");
class WebsocketClient extends BaseWSClient_js_1.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 = (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]);
}
/**
* 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 = (0, websocket_util_js_1.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 = (0, websocket_util_js_1.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
? websocket_util_js_1.WS_KEY_MAP.prodPrivate
: isBusinessType
? websocket_util_js_1.WS_KEY_MAP.prodBusiness
: websocket_util_js_1.WS_KEY_MAP.prodPublic;
}
case 'EEA': {
return isPrivateType
? websocket_util_js_1.WS_KEY_MAP.eeaLivePrivate
: isBusinessType
? websocket_util_js_1.WS_KEY_MAP.eeaLiveBusiness
: websocket_util_js_1.WS_KEY_MAP.eeaLivePublic;
}
case 'US': {
return isPrivateType
? websocket_util_js_1.WS_KEY_MAP.usLivePrivate
: isBusinessType
? websocket_util_js_1.WS_KEY_MAP.usLiveBusiness
: websocket_util_js_1.WS_KEY_MAP.usLivePublic;
}
default: {
throw (0, typeGuards_js_1.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 (0, websocket_util_js_1.isWsPong)(data);
}
isPrivateTopicRequest(_request, wsKey) {
return websocket_util_js_1.PRIVATE_WS_KEYS.includes(wsKey);
}
getPrivateWSKeys() {
return websocket_util_js_1.PRIVATE_WS_KEYS;
}
isAuthOnConnectWsKey(wsKey) {
return websocket_util_js_1.PRIVATE_WS_KEYS.includes(wsKey);
}
async getWsUrl(wsKey) {
return (0, websocket_util_js_1.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`, {
...websocket_util_js_1.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 (0, webCryptoAPI_js_1.signMessage)(paramsStr, secret, method, algorithm);
}
async getWsAuthRequestEvent(wsKey, skipIsPublicWsKeyCheck) {
const isPublicWsKey = websocket_util_js_1.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.', {
...websocket_util_js_1.WS_LOGGER_CATEGORY,
wsKey,
isPublicWsKey,
hasAccountsToAuth,
skipIsPublicWsKeyCheck,
});
return null;
}
if (!accounts || !hasAccountsToAuth) {
this.logger.trace('Starting public only websocket client - missing keys.', {
...websocket_util_js_1.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({
...websocket_util_js_1.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.', { ...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 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 = {
...websocket_util_js_1.WS_LOGGER_CATEGORY,
wsKey,
method: 'resolveEmittableEvents',
};
try {
const msg = JSON.parse(event.data);
const emittableEvent = { ...msg, wsKey };
/**
* WS API response handling
*/
if ((0, typeGuards_js_1.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 ((0, typeGuards_js_1.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 ((0, typeGuards_js_1.isWsDataEvent)(msg)) {
results.push({
eventType: 'update',
event: emittableEvent,
});
return results;
}
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);
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 ((0, typeGuards_js_1.isWsSubscribeEvent)(msg) || (0, typeGuards_js_1.isWsUnsubscribeEvent)(msg)) {
results.push({
eventType: 'response',
event: emittableEvent,
});
// this.logger.trace(`Ws subscribe reply:`, { ...msg, wsKey });
return results;
}
if ((0, typeGuards_js_1.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, {
...websocket_util_js_1.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', {
...websocket_util_js_1.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', {
...websocket_util_js_1.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 ? (0, websocket_util_js_1.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 ((0, websocket_util_js_1.requiresWSAPITag)(operation, wsKey)) {
(0, websocket_util_js_1.validateWSAPITag)(request, wsKey);
}
// Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events
const promiseRef = (0, websocket_util_js_1.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;
}
}
exports.WebsocketClient = WebsocketClient;
//# sourceMappingURL=websocket-client.js.map