UNPKG

gateio-api

Version:

Complete & Robust Node.js SDK for Gate.com's REST APIs, WebSockets & WebSocket APIs, with TypeScript declarations.

593 lines 24.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.WebsocketClient = exports.WS_LOGGER_CATEGORY = void 0; 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"); exports.WS_LOGGER_CATEGORY = { category: 'gate-ws' }; class WebsocketClient extends BaseWSClient_js_1.BaseWebsocketClient { /** * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library. * * Returns array of promises that individually resolve when each connection is successfully opened. */ connectAll() { return [ this.connect(websocket_util_js_1.WS_KEY_MAP.spotV4), this.connect(websocket_util_js_1.WS_KEY_MAP.perpFuturesUSDTV4), this.connect(websocket_util_js_1.WS_KEY_MAP.deliveryFuturesUSDTV4), this.connect(websocket_util_js_1.WS_KEY_MAP.optionsV4), this.connect(websocket_util_js_1.WS_KEY_MAP.announcementsV4), ]; } /** * 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(wsKey, skipAuth) { if (skipAuth) { return this.assertIsConnected(wsKey); } /** This call automatically ensures the connection is active AND authenticated before resolving */ return this.assertIsAuthenticated(wsKey); } /** * 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, channel, params, requestFlags) { this.logger.trace(`sendWSAPIRequest(): assert "${wsKey}" is connected`); await this.assertIsConnected(wsKey); // 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 timestampBeforeAuth = Date.now(); const signTimestamp = Date.now() + this.options.recvWindow; const timeInSeconds = +(signTimestamp / 1000).toFixed(0); const requestEvent = { time: timeInSeconds, // id: timeInSeconds, channel, event: 'api', payload: { req_id: this.getNewRequestId(), req_header: { 'X-Gate-Channel-Id': requestUtils_js_1.CHANNEL_ID, }, api_key: this.options.apiKey, req_param: params ? params : '', timestamp: `${timeInSeconds}`, }, }; const timestampAfterAuth = Date.now(); /** * Some WS API requests require a timestamp to be included. assertIsConnected and assertIsAuthenticated * can introduce a small delay before the actual request is sent, if not connected before that request is * made. This can lead to a curious race condition, where the request timestamp is before * the "authorizedSince" timestamp - as such, binance does not recognise the session as already authenticated. * * The below mechanism measures any delay introduced from the assert calls, and if the request includes a timestamp, * it offsets that timestamp by the delay. */ const delayFromAuthAssert = timestampAfterAuth - timestampBeforeAuth; if (delayFromAuthAssert && requestEvent.payload?.timestamp) { requestEvent.payload.timestamp += delayFromAuthAssert; this.logger.trace(`sendWSAPIRequest(): adjust timestamp - delay seen by connect/auth assert and delayed request includes timestamp, adjusting timestamp by ${delayFromAuthAssert}ms`); } // Sign request const signedEvent = await this.signWSAPIRequest(requestEvent); // Store deferred promise const promiseRef = (0, websocket_util_js_1.getPromiseRefForWSAPIRequest)(requestEvent); 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: wsKey, ...signedEvent, }; } return res; }) .catch((e) => { if (typeof e === 'string') { this.logger.error('unexpcted string', { e }); return e; } e.request = { wsKey: wsKey, channel, payload: signedEvent.payload, }; // throw e; return e; }); // Send event const throwExceptions = true; this.tryWsSend(wsKey, JSON.stringify(signedEvent), throwExceptions); this.logger.trace(`sendWSAPIRequest(): sent "${channel}" event with promiseRef(${promiseRef})`); // Return deferred promise, so caller can await this call return deferredPromise.promise; } /** * * Internal methods - not intended for public use * */ getWsUrl(wsKey) { if (this.options.wsUrl) { return this.options.wsUrl; } const useTestnet = this.options.useTestnet; const networkKey = useTestnet ? 'testnet' : 'livenet'; const baseUrl = websocket_util_js_1.WS_BASE_URL_MAP[wsKey][networkKey]; return baseUrl; } sendPingEvent(wsKey) { let pingChannel; switch (wsKey) { case 'deliveryFuturesBTCV4': case 'deliveryFuturesUSDTV4': case 'perpFuturesBTCV4': case 'perpFuturesUSDTV4': { pingChannel = 'futures.ping'; break; } case 'announcementsV4': { pingChannel = 'announcement.ping'; break; } case 'optionsV4': { pingChannel = 'options.ping'; break; } case 'spotV4': { pingChannel = 'spot.ping'; break; } default: { throw (0, misc_util_js_1.neverGuard)(wsKey, `Unhandled WsKey "${wsKey}"`); } } const signTimestamp = Date.now() + this.options.recvWindow; const timeInS = (signTimestamp / 1000).toFixed(0); return this.tryWsSend(wsKey, `{ "time": ${timeInS}, "channel": "${pingChannel}" }`); } 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 IN USE for gate.io, pings for gate are protocol layer pings // eslint-disable-next-line @typescript-eslint/no-unused-vars isWsPing(_msg) { return false; } isWsPong(msg) { if (typeof msg?.data === 'string' && msg.data.includes('.pong"')) { return true; } return false; } /** * Parse incoming events into categories, before emitting to the user */ resolveEmittableEvents(wsKey, event) { const results = []; try { const parsed = JSON.parse(event.data); const responseEvents = ['subscribe', 'unsubscribe']; const authenticatedEvents = ['auth']; const eventHeaders = parsed?.header; const eventChannel = eventHeaders?.channel; const eventType = eventHeaders?.event; const eventStatusCode = eventHeaders?.status; const requestId = parsed?.request_id; const promiseRef = [eventChannel, requestId].join('_'); const eventAction = parsed.event || parsed.action || parsed?.header?.data || parsed.channel; if (eventType === 'api') { const isError = eventStatusCode !== '200'; // 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, error: e, }); } results.push({ eventType: 'exception', event: parsed, }); 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, error: e, }); } if (eventChannel.includes('.login')) { results.push({ eventType: 'authenticated', event: { ...parsed, isWSAPI: true, WSAPIAuthChannel: eventChannel, }, }); } results.push({ eventType: 'response', event: parsed, }); return results; } if (typeof eventAction === 'string') { if (parsed.success === false) { results.push({ eventType: 'exception', event: parsed, }); return results; } // Most events use "event: 'update'" for topic updates // The legacy "futures.order_book" topic uses "all" for this field // 'futures.obu' is used for the orderbook v2 event. Oddly in a different structure than the other topics. if (['update', 'all', 'futures.obu'].includes(eventAction)) { results.push({ eventType: 'update', event: parsed, }); return results; } // These are request/reply pattern events (e.g. after subscribing to topics or authenticating) if (responseEvents.includes(eventAction)) { results.push({ eventType: 'response', event: parsed, }); return results; } // Request/reply pattern for authentication success if (authenticatedEvents.includes(eventAction)) { results.push({ eventType: 'authenticated', event: parsed, }); return results; } this.logger.error(`!! Unhandled string event type "${eventAction}". Defaulting to "update" channel...`, parsed); } else { this.logger.error(`!! Unhandled non-string event type "${eventAction}". 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) { const topicName = request?.topic?.toLowerCase(); if (!topicName) { return false; } switch (wsKey) { case 'spotV4': return (0, websocket_util_js_1.getPrivateSpotTopics)().includes(topicName); case 'perpFuturesBTCV4': case 'perpFuturesUSDTV4': case 'deliveryFuturesBTCV4': case 'deliveryFuturesUSDTV4': return (0, websocket_util_js_1.getPrivateFuturesTopics)().includes(topicName); case 'optionsV4': return (0, websocket_util_js_1.getPrivateOptionsTopics)().includes(topicName); // No private announcements channels case 'announcementsV4': return false; default: throw (0, misc_util_js_1.neverGuard)(wsKey, `Unhandled WsKey "${wsKey}"`); } } /** * Not in use for gate.io */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getWsMarketForWsKey(_wsKey) { return 'all'; } /** * Not in use for gate.io */ getPrivateWSKeys() { return []; } isAuthOnConnectWsKey(wsKey) { return this.getPrivateWSKeys().includes(wsKey); } /** Force subscription requests to be sent in smaller batches, if a number is returned */ // eslint-disable-next-line @typescript-eslint/no-unused-vars getMaxTopicsPerSubscribeEvent(_wsKey) { return 1; } /** * Map one or more topics into fully prepared "subscribe request" events (already stringified and ready to send) */ async getWsOperationEventsForTopics(topics, wsKey, operation) { // console.log(new Date(), `called getWsSubscribeEventsForTopics()`, topics); // console.trace(); if (!topics.length) { return []; } // Events that are ready to send (usually stringified JSON) const jsonStringEvents = []; const market = this.getWsMarketForWsKey(wsKey); const maxTopicsPerEvent = this.getMaxTopicsPerSubscribeEvent(wsKey); if (maxTopicsPerEvent && maxTopicsPerEvent !== null && topics.length > maxTopicsPerEvent) { for (let i = 0; i < topics.length; i += maxTopicsPerEvent) { const batch = topics.slice(i, i + maxTopicsPerEvent); const subscribeRequestEvents = await this.getWsRequestEvents(market, operation, batch, wsKey); for (const event of subscribeRequestEvents) { jsonStringEvents.push(JSON.stringify(event)); } } return jsonStringEvents; } const subscribeRequestEvents = await this.getWsRequestEvents(market, operation, topics, wsKey); for (const event of subscribeRequestEvents) { jsonStringEvents.push(JSON.stringify(event)); } return jsonStringEvents; } /** * @returns one or more correctly structured request events for performing a operations over WS. This can vary per exchange spec. */ async getWsRequestEvents(market, operation, requests, wsKey) { const wsRequestEvents = []; const wsRequestBuildingErrors = []; switch (market) { case 'all': { for (const request of requests) { const signTimestamp = Date.now() + this.options.recvWindow; const timeInSeconds = +(signTimestamp / 1000).toFixed(0); const wsEvent = { time: timeInSeconds, channel: request.topic, event: operation, }; if (request.payload) { wsEvent.payload = request.payload; } if (!this.isPrivateTopicRequest(request, wsKey)) { wsRequestEvents.push(wsEvent); continue; } // If private topic request, build auth part for request // No key or secret, push event as failed if (!this.options.apiKey || !this.options.apiSecret) { wsRequestBuildingErrors.push({ error: 'apiKey or apiSecret missing from config', operation, event: wsEvent, }); continue; } const signAlgoritm = 'SHA-512'; const signEncoding = 'hex'; const signInput = `channel=${wsEvent.channel}&event=${wsEvent.event}&time=${timeInSeconds}`; try { const sign = await this.signMessage(signInput, this.options.apiSecret, signEncoding, signAlgoritm); wsRequestEvents.push({ ...wsEvent, auth: { method: 'api_key', KEY: this.options.apiKey, SIGN: sign, }, }); } catch (e) { wsRequestBuildingErrors.push({ error: 'exception during sign', errorTrace: e, operation, event: wsEvent, }); } } break; } default: { throw (0, misc_util_js_1.neverGuard)(market, `Unhandled market "${market}"`); } } 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`, { ...exports.WS_LOGGER_CATEGORY, wsRequestBuildingErrors, wsRequestBuildingErrorsStringified: JSON.stringify(wsRequestBuildingErrors, null, 2), }); } return wsRequestEvents; } 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) { if (!this.options.apiKey || !this.options.apiSecret) { throw new Error('Cannot auth - missing api key, secret or memo in config'); } let channel; switch (wsKey) { case 'spotV4': { channel = 'spot.login'; break; } case 'perpFuturesBTCV4': case 'perpFuturesUSDTV4': { channel = 'futures.login'; break; } case 'announcementsV4': case 'deliveryFuturesBTCV4': case 'deliveryFuturesUSDTV4': case 'optionsV4': { return {}; } default: { throw (0, misc_util_js_1.neverGuard)(wsKey, `Unhandled wsKey "${wsKey}"`); } } const signTimestamp = Date.now() + this.options.recvWindow; const timeInSeconds = +(signTimestamp / 1000).toFixed(0); const requestEvent = { time: timeInSeconds, // id: timeInSeconds, channel, event: 'api', payload: { req_id: this.getNewRequestId(), req_header: { 'X-Gate-Channel-Id': requestUtils_js_1.CHANNEL_ID, }, api_key: this.options.apiKey, req_param: '', timestamp: `${timeInSeconds}`, }, }; const signedEvent = await this.signWSAPIRequest(requestEvent); return signedEvent; } /** * * @param requestEvent * @returns A signed updated WS API request object, ready to be sent */ async signWSAPIRequest(requestEvent) { if (!this.options.apiSecret) { throw new Error('API Secret missing'); } const payload = requestEvent.payload; const toSign = [ requestEvent.event, requestEvent.channel, JSON.stringify(payload.req_param), requestEvent.time, ].join('\n'); const signEncoding = 'hex'; const signAlgoritm = 'SHA-512'; return { ...requestEvent, payload: { ...requestEvent.payload, req_header: { 'X-Gate-Channel-Id': requestUtils_js_1.CHANNEL_ID, }, signature: await this.signMessage(toSign, this.options.apiSecret, signEncoding, signAlgoritm), }, }; } } exports.WebsocketClient = WebsocketClient; //# sourceMappingURL=WebsocketClient.js.map