kucoin-api
Version:
Complete & robust Node.js SDK for Kucoin's REST APIs and WebSockets, with TypeScript & strong end to end tests.
578 lines • 25.1 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.WebsocketClient = exports.PUBLIC_WS_KEYS = exports.WS_LOGGER_CATEGORY = void 0;
const FuturesClient_js_1 = require("./FuturesClient.js");
const BaseWSClient_js_1 = require("./lib/BaseWSClient.js");
const misc_util_js_1 = require("./lib/misc-util.js");
const requestUtils_js_1 = require("./lib/requestUtils.js");
const webCryptoAPI_js_1 = require("./lib/webCryptoAPI.js");
const websocket_util_js_1 = require("./lib/websocket/websocket-util.js");
const SpotClient_js_1 = require("./SpotClient.js");
function getRandomInt(max) {
return Math.floor(Math.random() * max);
}
exports.WS_LOGGER_CATEGORY = { category: 'kucoin-ws' };
/** Any WS keys in this list will trigger auth on connect, if credentials are available */
const PRIVATE_WS_KEYS = [
websocket_util_js_1.WS_KEY_MAP.spotPrivateV1,
websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1,
];
/** Any WS keys in this list will ALWAYS skip the authentication process, even if credentials are available */
exports.PUBLIC_WS_KEYS = [
websocket_util_js_1.WS_KEY_MAP.spotPublicV1,
websocket_util_js_1.WS_KEY_MAP.futuresPublicV1,
];
class WebsocketClient extends BaseWSClient_js_1.BaseWebsocketClient {
RESTClientCache = {
spot: undefined,
futures: undefined,
};
getRESTClient(wsKey) {
const getClientType = (wsKey) => {
if (wsKey.startsWith('spot'))
return 'spot';
if (wsKey.startsWith('futures'))
return 'futures';
return null;
};
const clientType = getClientType(wsKey);
if (!clientType) {
throw new Error(`Unhandled WsKey: "${wsKey}"`);
}
if (this.RESTClientCache[clientType]) {
return this.RESTClientCache[clientType];
}
const ClientClass = clientType === 'spot' ? SpotClient_js_1.SpotClient : FuturesClient_js_1.FuturesClient;
const newClient = new ClientClass(this.getRestClientOptions(), this.options.requestOptions);
this.RESTClientCache[clientType] = newClient;
return newClient;
}
getRestClientOptions() {
return {
apiKey: this.options.apiKey,
apiSecret: this.options.apiSecret,
...this.options,
...this.options.restOptions,
};
}
async getWSConnectionInfo(wsKey) {
const restClient = this.getRESTClient(wsKey);
if (PRIVATE_WS_KEYS.includes(wsKey)) {
return restClient.getPrivateWSConnectionToken();
}
return restClient.getPublicWSConnectionToken();
}
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);
}
/**
* Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library
*/
connectAll() {
return Promise.all([
this.connect(websocket_util_js_1.WS_KEY_MAP.spotPublicV1),
this.connect(websocket_util_js_1.WS_KEY_MAP.spotPrivateV1),
this.connect(websocket_util_js_1.WS_KEY_MAP.futuresPublicV1),
this.connect(websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1),
]);
}
/**
* Request subscription to one or more topics. Pass topics as either an array of strings, or array of objects (if the topic has parameters).
* Objects should be formatted as {topic: string, params: object}.
*
* - Subscriptions are automatically routed to the correct websocket connection.
* - Authentication/connection is automatic.
* - Resubscribe after network issues is automatic.
*
* Call `unsubscribe(topics)` to remove topics
*/
subscribe(requests, wsKey) {
if (!Array.isArray(requests)) {
this.subscribeTopicsForWsKey([requests], wsKey);
return;
}
if (requests.length) {
this.subscribeTopicsForWsKey(requests, wsKey);
}
}
/**
* Unsubscribe from one or more topics. Similar to subscribe() but in reverse.
*
* - Requests are automatically routed to the correct websocket connection.
* - These topics will be removed from the topic cache, so they won't be subscribed to again.
*/
unsubscribe(requests, wsKey) {
if (!Array.isArray(requests)) {
this.unsubscribeTopicsForWsKey([requests], wsKey);
return;
}
if (requests.length) {
this.unsubscribeTopicsForWsKey(requests, wsKey);
}
}
async sendWSAPIRequest(wsKey, operation, params, requestFlags) {
/**
* Base Info:
* - https://www.kucoin.com/docs-new/websocket-api/base-info/introduction
*
* Add/Cancel API info:
* - https://www.kucoin.com/docs-new/3470133w0
**/
// this.logger.trace(`sendWSAPIRequest(): assertIsConnected("${wsKey}")...`);
await this.assertIsConnected(wsKey);
// this.logger.trace('sendWSAPIRequest(): assertIsConnected(${wsKey}) ok');
// Some commands don't require authentication.
if (requestFlags?.authIsOptional !== true) {
// this.logger.trace(
// 'sendWSAPIRequest(): assertIsAuthenticated(${wsKey})...',
// );
await this.assertIsAuthenticated(wsKey);
// this.logger.trace(
// 'sendWSAPIRequest(): assertIsAuthenticated(${wsKey}) ok',
// );
}
const request = {
id: this.getNewRequestId(),
op: operation,
args: Array.isArray(params)
? [...params]
: {
...params,
},
};
// Sign, if needed
const signedEvent = await this.signWSAPIRequest(request);
// Store deferred promise, resolved within the "resolveEmittableEvents" method while parsing incoming events
const promiseRef = (0, websocket_util_js_1.getPromiseRefForWSAPIRequest)(wsKey, signedEvent);
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,
...signedEvent,
};
}
return res;
})
.catch((e) => {
if (typeof e === 'string') {
this.logger.error('unexpcted string', { e });
return e;
}
e.request = {
wsKey,
operation,
params: signedEvent.args,
};
// throw e;
return e;
});
this.logger.trace(`sendWSAPIRequest(): sending raw request: ${JSON.stringify(signedEvent)} with promiseRef(${promiseRef})`);
// Send event.
const throwExceptions = true;
this.tryWsSend(wsKey, JSON.stringify(signedEvent), throwExceptions);
this.logger.trace(`sendWSAPIRequest(): sent "${operation}" event with promiseRef(${promiseRef})`);
// Return deferred promise, so caller can await this call
return deferredPromise.promise;
}
/**
*
* Internal methods
*
*/
async signWSAPIRequest(requestEvent) {
return requestEvent;
}
/**
* Whatever url this method returns, it's connected to as-is!
*
* If a token or anything else is needed in the URL, this is a good place to add it.
*/
async getWsUrl(wsKey) {
if (this.options.wsUrl) {
return this.options.wsUrl;
}
switch (wsKey) {
case websocket_util_js_1.WS_KEY_MAP.spotPublicV1:
case websocket_util_js_1.WS_KEY_MAP.spotPrivateV1:
case websocket_util_js_1.WS_KEY_MAP.futuresPublicV1:
case websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1: {
// These WS URLs are dynamically fetched via the REST API, as per API spec
const connectionInfo = await this.getWSConnectionInfo(wsKey);
this.logger.trace('getWSConnectionInfo', {
wsKey,
...connectionInfo,
});
const server = connectionInfo.data.instanceServers[0];
if (!server) {
this.logger.error('No servers returned by connection info response?', JSON.stringify({
wsKey,
connectionInfo,
}, null, 2));
throw new Error('No servers returned by connection info response?');
}
const connectionUrl = `${server.endpoint}?token=${connectionInfo.data.token}`;
return connectionUrl;
}
case websocket_util_js_1.WS_KEY_MAP.wsApiSpotV1:
case websocket_util_js_1.WS_KEY_MAP.wsApiFuturesV1: {
// WS API URL works differently: https://www.kucoin.com/docs-new/3470133w0
// wss://wsapi.kucoin.com/v1/private?apikey=xxx&sign=xxx&passphrase=xxx×tamp=xxx
const WS_API_ENDPOINT = 'v1/private';
const WS_API_BASE_URL = 'wss://wsapi.kucoin.com/';
const isSpotWsKey = wsKey === websocket_util_js_1.WS_KEY_MAP.wsApiSpotV1;
// ws_url = f"{url}/v1/private?{url_path}&sign={sign_value}&passphrase={passphrase_sign}"
const queryString = {
apikey: this.options.apiKey,
timestamp: Date.now(),
sign: '',
passphrase: '',
partner: isSpotWsKey ? requestUtils_js_1.APIIDMain : requestUtils_js_1.APIIDFutures,
partner_sign: '',
};
// original = f"{apikey}{timestamp}"
const paramsStr = `${queryString.apikey}${queryString.timestamp}`;
queryString.passphrase = await this.signMessage(this.options.apiPassphrase, this.options.apiSecret, 'base64', 'SHA-256');
queryString.sign = await this.signMessage(paramsStr, this.options.apiSecret, 'base64', 'SHA-256');
const partnerSignParam = `${queryString.timestamp}${queryString.partner}${queryString.apikey}`;
queryString.partner_sign = await this.signMessage(partnerSignParam, isSpotWsKey ? requestUtils_js_1.APIIDMainSign : requestUtils_js_1.APIIDFuturesSign, 'base64', 'SHA-256');
const strictParamValidation = false;
const encodeQueryStringValues = true;
const finalQueryString = (0, requestUtils_js_1.serializeParams)(queryString, strictParamValidation, encodeQueryStringValues, '?');
const finalUrl = WS_API_BASE_URL + WS_API_ENDPOINT + finalQueryString;
// console.log('signParams: ', {
// paramsStr,
// partnerSignParam,
// queryString,
// finalUrl,
// });
return finalUrl;
}
default: {
throw (0, misc_util_js_1.neverGuard)(wsKey, `Unhandled WsKey "${wsKey}} in getWsUrl()`);
}
}
}
sendPingEvent(wsKey) {
if ((0, websocket_util_js_1.isWSAPIWsKey)(wsKey)) {
return this.tryWsSend(wsKey, `{"id": "ping-${this.getNewRequestId()}", "op": "ping", "timestamp": "${Date.now()}"}`);
}
return this.tryWsSend(wsKey, `{ "id": "${Date.now()}", "type": "ping" }`);
}
sendPongEvent(wsKey) {
try {
this.logger.trace('Sending upstream ws PONG: ', {
...exports.WS_LOGGER_CATEGORY,
wsMessage: 'PONG',
wsKey,
});
if (!wsKey) {
throw new Error('Cannot send PONG, no wsKey provided');
}
const wsState = this.getWsStore().get(wsKey);
if (!wsState || !wsState?.ws) {
throw new Error(`Cannot send pong, ${wsKey} socket not connected yet`);
}
// Send a protocol layer pong
wsState.ws.pong();
}
catch (e) {
this.logger.error('Failed to send WS PONG', {
...exports.WS_LOGGER_CATEGORY,
wsMessage: 'PONG',
wsKey,
exception: e,
});
}
}
// Not really used for kucoin - they don't send pings
isWsPing(msg) {
if (msg?.data === 'ping') {
return true;
}
return false;
}
isWsPong(msg) {
if (msg?.data?.includes('pong')) {
return true;
}
// this.logger.info(`Not a pong: `, msg);
return false;
}
resolveEmittableEvents(wsKey, event) {
const results = [];
try {
const parsed = JSON.parse(event.data);
const isForWSAPIWsKey = (0, websocket_util_js_1.isWSAPIWsKey)(wsKey);
const responseEvents = ['subscribe', 'unsubscribe', 'ack'];
const authenticatedEvents = ['login', 'access'];
const connectionReadyEvents = ['welcome'];
const eventType = parsed.event || parsed.type;
const traceEmittable = false;
if (traceEmittable) {
this.logger.info('resolveEmittableEvents', {
...exports.WS_LOGGER_CATEGORY,
wsKey,
parsedEvent: JSON.stringify(event),
parsedEventData: JSON.stringify(parsed),
eventType,
properties: {
parsedEventId: parsed?.id,
parsedEventErrorCode: parsed?.code,
},
// parsed: JSON.stringify(parsed, null, 2),
});
}
if (typeof eventType === 'string') {
if (parsed.success === false) {
results.push({
eventType: 'exception',
event: parsed,
});
return results;
}
if (connectionReadyEvents.includes(eventType)) {
results.push({
eventType: 'connectionReady',
event: parsed,
});
return results;
}
// These are request/reply pattern events (e.g. after subscribing to topics or authenticating)
if (responseEvents.includes(eventType)) {
results.push({
eventType: 'response',
event: parsed,
});
return results;
}
// Request/reply pattern for authentication success
if (authenticatedEvents.includes(eventType)) {
results.push({
eventType: 'authenticated',
event: parsed,
});
return results;
}
if (eventType === 'message') {
return [{ eventType: 'update', event: parsed }];
}
this.logger.error(`!! (${wsKey}) Unhandled string event type "${eventType}". Defaulting to "update" channel...`, parsed);
results.push({
eventType: 'update',
event: parsed,
});
return results;
}
if (!eventType) {
if (isForWSAPIWsKey) {
const isWSAPIResponse = typeof parsed.op === 'string';
if (isWSAPIResponse) {
const parsedEventErrorCode = Number(parsed.code);
const parsedEventId = parsed.id;
const isError = typeof parsedEventErrorCode === 'number' &&
parsedEventErrorCode !== 0 &&
parsedEventErrorCode !== 200000;
// This is the counterpart to getPromiseRefForWSAPIRequest
const promiseRef = [wsKey, parsedEventId].join('_');
if (!parsedEventId) {
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:', {
wsKey,
promiseRef,
parsedEvent: parsed,
});
}
// WS API Exception
if (isError) {
try {
this.getWsStore().rejectDeferredPromise(wsKey, promiseRef, {
wsKey,
...parsed,
}, true);
}
catch (e) {
this.logger.error('Exception trying to reject WSAPI promise', {
wsKey,
promiseRef,
parsedEvent: parsed,
e,
});
}
results.push({
eventType: 'exception',
event: parsed,
isWSAPIResponse: isWSAPIResponse,
});
return results;
}
// WS API Success
try {
this.getWsStore().resolveDeferredPromise(wsKey, promiseRef, {
wsKey,
...parsed,
}, true);
}
catch (e) {
this.logger.error('Exception trying to resolve WSAPI promise', {
wsKey,
promiseRef,
parsedEvent: parsed,
e,
});
}
results.push({
eventType: 'response',
event: {
...parsed,
},
isWSAPIResponse: isWSAPIResponse,
});
return results;
}
if (parsed.sessionId && parsed.data === 'welcome') {
results.push({
eventType: 'authenticated',
event: parsed,
});
return results;
}
if (parsed.sessionId && parsed.timestamp) {
results.push({
eventType: 'connectionReady',
event: parsed,
});
results.push({
eventType: 'connectionReadyForAuth',
event: parsed,
});
return results;
}
}
}
this.logger.error(`!! (${wsKey}) Unhandled non-string event type "${eventType}". Defaulting to "update" channel...`, parsed);
results.push({
eventType: 'update',
event: parsed,
});
}
catch (e) {
results.push({
event: {
message: 'Failed to parse event data due to exception',
exception: e,
eventData: event.data,
},
eventType: 'exception',
});
this.logger.error('Failed to parse event data due to exception: ', {
exception: e,
eventData: event.data,
});
}
return results;
}
/**
* Determines if a topic is for a private channel, using a hardcoded list of strings
*/
isPrivateTopicRequest(request, wsKey) {
return request && PRIVATE_WS_KEYS.includes(wsKey);
}
getWsKeyForMarket(market, isPrivate) {
return isPrivate
? market === 'spot'
? websocket_util_js_1.WS_KEY_MAP.spotPrivateV1
: websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1
: market === 'spot'
? websocket_util_js_1.WS_KEY_MAP.spotPublicV1
: websocket_util_js_1.WS_KEY_MAP.futuresPublicV1;
}
getWsMarketForWsKey(key) {
switch (key) {
case websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1:
case websocket_util_js_1.WS_KEY_MAP.futuresPublicV1: {
return 'futures';
}
case websocket_util_js_1.WS_KEY_MAP.spotPrivateV1:
case websocket_util_js_1.WS_KEY_MAP.spotPublicV1: {
return 'spot';
}
case websocket_util_js_1.WS_KEY_MAP.wsApiSpotV1: {
return 'spot';
}
case websocket_util_js_1.WS_KEY_MAP.wsApiFuturesV1: {
return 'futures';
}
default: {
throw (0, misc_util_js_1.neverGuard)(key, `Unhandled ws key "${key}"`);
}
}
}
getPrivateWSKeys() {
return PRIVATE_WS_KEYS;
}
/** Force subscription requests to be sent in smaller batches, if a number is returned */
getMaxTopicsPerSubscribeEvent(wsKey) {
switch (wsKey) {
case websocket_util_js_1.WS_KEY_MAP.futuresPrivateV1:
case websocket_util_js_1.WS_KEY_MAP.futuresPublicV1:
case websocket_util_js_1.WS_KEY_MAP.spotPrivateV1:
case websocket_util_js_1.WS_KEY_MAP.spotPublicV1:
case websocket_util_js_1.WS_KEY_MAP.wsApiSpotV1:
case websocket_util_js_1.WS_KEY_MAP.wsApiFuturesV1: {
// Return a number if there's a limit on the number of sub topics per rq
// Always 1 at a time for this exchange
return 1;
}
default: {
throw (0, misc_util_js_1.neverGuard)(wsKey, 'getMaxTopicsPerSubscribeEvent(): Unhandled wsKey');
}
}
}
/**
* Map one or more topics into fully prepared "subscribe request" events (already stringified and ready to send)
*/
async getWsOperationEventsForTopics(topicRequests, wsKey, operation) {
if (!topicRequests.length) {
return [];
}
// Operations structured in a way that this exchange understands
const operationEvents = topicRequests.map((topicRequest) => {
const isPrivateWsTopic = this.isPrivateTopicRequest(topicRequest, wsKey);
const wsRequestEvent = {
id: getRandomInt(999999999999),
type: operation,
topic: topicRequest.topic,
privateChannel: isPrivateWsTopic,
response: true,
...topicRequest.payload,
};
return wsRequestEvent;
});
// Events that are ready to send (usually stringified JSON)
return operationEvents.map((event) => JSON.stringify(event));
}
async getWsAuthRequestEvent(wsKey, eventToAuth) {
// Send anything for WS API
if ((0, websocket_util_js_1.isWSAPIWsKey)(wsKey)) {
if (eventToAuth) {
const eventToAuthAsString = JSON.stringify(eventToAuth);
this.logger.trace(`getWsAuthRequestEvent(${wsKey}): responding to WS API auth handshake...`, {
eventToAuth,
});
const sessionInfo = await this.signMessage(eventToAuthAsString, this.options.apiSecret, 'base64', 'SHA-256');
return sessionInfo;
}
// Don't send anything, don't resolve auth promise. Wait for auth handshake from server
return 'waitForEvent';
}
// Don't send anything for all other WS connections, since they auth as part of the connection (not after connect). Returning an empty value here will short-circuit the assertIsAuthenticated workflow.
return;
}
}
exports.WebsocketClient = WebsocketClient;
//# sourceMappingURL=WebsocketClient.js.map