From 13b829f7346d0b2c14419ea2d49c278fe3fa4f18 Mon Sep 17 00:00:00 2001 From: Tiago Siebler Date: Thu, 30 May 2024 12:42:57 +0100 Subject: [PATCH] feat(): prep work for gate WS client --- README.md | 36 ++- src/WebsocketClient.ts | 331 +++++++++++----------------- src/lib/BaseWSClient.ts | 229 ++++++++----------- src/lib/requestUtils.ts | 2 +- src/lib/websocket/WsStore.ts | 8 +- src/lib/websocket/websocket-util.ts | 100 +++++++-- src/types/websockets/client.ts | 5 +- src/types/websockets/requests.ts | 27 ++- 8 files changed, 367 insertions(+), 371 deletions(-) diff --git a/README.md b/README.md index 49950f2..b22ee9d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ # gateio-api + [![Tests](https://circleci.com/gh/tiagosiebler/gateio-api.svg?style=shield)](https://circleci.com/gh/tiagosiebler/gateio-api) [![npm version](https://img.shields.io/npm/v/gateio-api)][1] [![npm size](https://img.shields.io/bundlephobia/min/gateio-api/latest)][1] [![npm downloads](https://img.shields.io/npm/dt/gateio-api)][1] [![last commit](https://img.shields.io/github/last-commit/tiagosiebler/gateio-api)][1] @@ -11,14 +12,18 @@ WARNING: This package is still early beta, following the designs of my other con Node.js connector for the gateio APIs and WebSockets, with TypeScript & browser support. ## Installation + `npm install --save gateio-api` ## Issues & Discussion + - Issues? Check the [issues tab](https://github.com/tiagosiebler/gateio-api/issues). - Discuss & collaborate with other node devs? Join our [Node.js Algo Traders](https://t.me/nodetraders) engineering community on telegram. ## Related projects + Check out my related projects: + - Try my connectors: - [binance](https://www.npmjs.com/package/binance) - [bybit-api](https://www.npmjs.com/package/bybit-api) @@ -31,11 +36,15 @@ Check out my related projects: - [awesome-crypto-examples](https://github.com/tiagosiebler/awesome-crypto-examples) ## Documentation + Most methods accept JS objects. These can be populated using parameters specified by gateio's API documentation. + - [Gate.io API Documentation](https://www.gate.io/docs/developers/apiv4/en/). ## Structure + This project uses typescript. Resources are stored in 3 key structures: + - [src](./src) - the whole connector written in typescript - [lib](./lib) - the javascript version of the project (compiled from typescript). This should not be edited directly, as it will be overwritten with each release. - [dist](./dist) - the packed bundle of the project for use in browser environments. @@ -44,6 +53,7 @@ This project uses typescript. Resources are stored in 3 key structures: --- # Usage + @@ -111,7 +121,10 @@ client.getOrderBook({ symbol: 'BTCUSD' }) See [inverse-client.ts](./src/inverse-client.ts) for further information. --> ## WebSockets -Inverse, linear & spot WebSockets can be used via a shared `WebsocketClient`. However, make sure to make one instance of WebsocketClient per market type (spot vs inverse vs linear vs linearfutures): + +All available WebSockets can be used via a shared `WebsocketClient`. The WebSocket client will automatically open/track/manage connections as needed. Each unique connection (one per server URL) is tracked using a WsKey (each WsKey is a string - [WS_KEY_MAP](src/lib/websocket/websocket-util.ts). + +Any subscribe/unsubscribe events will need to include a WsKey, so the WebSocket client understands which connection the event should be routed to. See examples below or in the [examples](./examples/) folder on GitHub. ```javascript const { WebsocketClient } = require('gateio-api'); @@ -165,7 +178,7 @@ ws.subscribe(['position', 'execution', 'trade']); ws.subscribe('kline.BTCUSD.1m'); // Listen to events coming from websockets. This is the primary data source -ws.on('update', data => { +ws.on('update', (data) => { console.log('update', data); }); @@ -175,7 +188,7 @@ ws.on('open', ({ wsKey, event }) => { }); // Optional: Listen to responses to websocket queries (e.g. the response after subscribing to a topic) -ws.on('response', response => { +ws.on('response', (response) => { console.log('response', response); }); @@ -186,12 +199,11 @@ ws.on('close', () => { // Optional: Listen to raw error events. // Note: responses to invalid topics are currently only sent in the "response" event. -ws.on('error', err => { +ws.on('error', (err) => { console.error('ERR', err); }); ``` - See [websocket-client.ts](./src/websocket-client.ts) for further information. Note: for linear websockets, pass `linear: true` in the constructor options when instancing the `WebsocketClient`. To connect to both linear and inverse websockets, make two instances of the WebsocketClient. @@ -199,6 +211,7 @@ Note: for linear websockets, pass `linear: true` in the constructor options when --- ## Customise Logging + Pass a custom logger which supports the log methods `silly`, `debug`, `notice`, `info`, `warning` and `error`, or override methods from the default logger as desired. ```javascript @@ -207,14 +220,13 @@ const { WebsocketClient, DefaultLogger } = require('gateio-api'); // Disable all logging on the silly level DefaultLogger.silly = () => {}; -const ws = new WebsocketClient( - { key: 'xxx', secret: 'yyy' }, - DefaultLogger -); +const ws = new WebsocketClient({ key: 'xxx', secret: 'yyy' }, DefaultLogger); ``` ## Browser Usage + Build a bundle using webpack: + - `npm install` - `npm build` - `npm pack` @@ -224,17 +236,23 @@ The bundle can be found in `dist/`. Altough usage should be largely consistent, --- ## Contributions & Thanks + ### Donations + #### tiagosiebler + Support my efforts to make algo trading accessible to all - register with my referral links: + - [Bybit](https://www.bybit.com/en-US/register?affiliate_id=9410&language=en-US&group_id=0&group_type=1) - [Binance](https://www.binance.com/en/register?ref=20983262) - [OKX](https://www.okx.com/join/18504944) - [FTX](https://ftx.com/referrals#a=ftxapigithub) Or buy me a coffee using any of these: + - BTC: `1C6GWZL1XW3jrjpPTS863XtZiXL1aTK7Jk` - ETH (ERC20): `0xd773d8e6a50758e1ada699bb6c4f98bb4abf82da` ### Contributions & Pull Requests + Contributions are encouraged, I will review any incoming pull requests. See the issues tab for todo items. diff --git a/src/WebsocketClient.ts b/src/WebsocketClient.ts index 14fb187..874bc22 100644 --- a/src/WebsocketClient.ts +++ b/src/WebsocketClient.ts @@ -8,48 +8,76 @@ import { WS_BASE_URL_MAP, WS_KEY_MAP, WsKey, + WsMarket, + WsTopicRequest, } from './lib/websocket/websocket-util.js'; -import { WsMarket } from './types/websockets/client.js'; import { WsOperation, - WsRequestOperation, + WsRequestOperationGate, } from './types/websockets/requests.js'; -export const WS_LOGGER_CATEGORY = { category: 'woo-ws' }; +export const WS_LOGGER_CATEGORY = { category: 'gate-ws' }; /** Any WS keys in this list will trigger auth on connect, if credentials are available */ -const PRIVATE_WS_KEYS: WsKey[] = [WS_KEY_MAP.privateV1]; +const PRIVATE_WS_KEYS: WsKey[] = []; /** Any WS keys in this list will ALWAYS skip the authentication process, even if credentials are available */ -export const PUBLIC_WS_KEYS: WsKey[] = [WS_KEY_MAP.publicV1]; +export const PUBLIC_WS_KEYS: WsKey[] = []; /** - * WS topics are always a string for woo. Some exchanges use complex objects + * WS topics are always a string for gate. Some exchanges use complex objects */ -export type WsTopic = - | 'balance' - | 'executionreport' - | 'algoexecutionreportv2' - | 'position' - | 'marginassignment'; - -export class WebsocketClient extends BaseWebsocketClient< - WsMarket, - WsKey, - WsTopic -> { +export type WsTopic = string; + +// /** +// * Used to split sub/unsub logic by websocket connection. Groups & dedupes requests into per-WsKey arrays +// */ +// function arrangeTopicsIntoWsKeyGroups( +// requestOperations: WsRequest[], +// ): Record[]> { +// const topicsByWsKey: Record[]> = { +// [WS_KEY_MAP.spotV4]: [], +// [WS_KEY_MAP.perpFuturesUSDTV4]: [], +// [WS_KEY_MAP.perpFuturesBTCV4]: [], +// [WS_KEY_MAP.deliveryFuturesUSDTV4]: [], +// [WS_KEY_MAP.deliveryFuturesBTCV4]: [], +// [WS_KEY_MAP.optionsV4]: [], +// [WS_KEY_MAP.announcementsV4]: [], +// }; + +// for (const request of requestOperations) { +// const wsKeyForTopic = request.wsKey; + +// const requestsForWsKey = topicsByWsKey[wsKeyForTopic]; + +// const requestAlreadyInList = requestsForWsKey.find((p) => +// isDeepObjectMatch(p, request), +// ); +// if (!requestAlreadyInList) { +// requestsForWsKey.push(request); +// } +// } + +// return topicsByWsKey; +// } + +export class WebsocketClient extends BaseWebsocketClient { /** * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library */ public connectAll(): Promise[] { return [ - this.connect(WS_KEY_MAP.publicV1), - this.connect(WS_KEY_MAP.privateV1), + this.connect(WS_KEY_MAP.spotV4), + this.connect(WS_KEY_MAP.perpFuturesUSDTV4), + this.connect(WS_KEY_MAP.deliveryFuturesUSDTV4), + this.connect(WS_KEY_MAP.optionsV4), + this.connect(WS_KEY_MAP.announcementsV4), ]; } /** - * Request subscription to one or more topics. + * 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. @@ -57,35 +85,27 @@ export class WebsocketClient extends BaseWebsocketClient< * * Call `unsubscribe(topics)` to remove topics */ - public subscribe(topics: WsTopic[]) { - const topicsByWsKey = this.arrangeTopicsIntoWsKeyGroups(topics); - - for (const untypedWsKey in topicsByWsKey) { - const typedWsKey = untypedWsKey as WsKey; - const topics = topicsByWsKey[typedWsKey]; - - if (topics.length) { - this.subscribeTopicsForWsKey(topics, typedWsKey); - } + public subscribe( + requests: (WsTopicRequest | WsTopic)[], + wsKey: WsKey, + ) { + if (requests.length) { + this.subscribeTopicsForWsKey(requests, wsKey); } } /** - * Unsubscribe from one or more topics. + * 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. */ - public unsubscribe(topics: WsTopic[]) { - const topicsByWsKey = this.arrangeTopicsIntoWsKeyGroups(topics); - - for (const untypedWsKey in topicsByWsKey) { - const typedWsKey = untypedWsKey as WsKey; - const topics = topicsByWsKey[typedWsKey]; - - if (topics.length) { - this.unsubscribeTopicsForWsKey(topics, typedWsKey); - } + public unsubscribe( + requests: (WsTopicRequest | WsTopic)[], + wsKey: WsKey, + ) { + if (requests.length) { + this.unsubscribeTopicsForWsKey(requests, wsKey); } } @@ -96,27 +116,11 @@ export class WebsocketClient extends BaseWebsocketClient< */ protected sendPingEvent(wsKey: WsKey) { - switch (wsKey) { - case WS_KEY_MAP.publicV1: - case WS_KEY_MAP.privateV1: { - return this.tryWsSend(wsKey, '{"event":"ping"}'); - } - default: { - throw neverGuard(wsKey, `Unhandled ping format: "${wsKey}"`); - } - } + return this.tryWsSend(wsKey, '{"event":"ping"}'); } protected sendPongEvent(wsKey: WsKey) { - switch (wsKey) { - case WS_KEY_MAP.publicV1: - case WS_KEY_MAP.privateV1: { - return this.tryWsSend(wsKey, '{"event":"pong"}'); - } - default: { - throw neverGuard(wsKey, `Unhandled ping format: "${wsKey}"`); - } - } + return this.tryWsSend(wsKey, '{"event":"pong"}'); } protected isWsPing(msg: any): boolean { @@ -139,6 +143,9 @@ export class WebsocketClient extends BaseWebsocketClient< return false; } + /** + * Parse incoming events into categories + */ protected resolveEmittableEvents(event: MessageEventLike): EmittableEvent[] { const results: EmittableEvent[] = []; @@ -217,14 +224,20 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Determines if a topic is for a private channel, using a hardcoded list of strings */ - protected isPrivateChannel(topic: WsTopic): boolean { - const topicName = topic.toLowerCase(); + protected isPrivateTopicRequest(request: WsTopicRequest): boolean { + const topicName = request?.topic?.toLowerCase(); + if (!topicName) { + return false; + } + const privateTopics = [ - 'balance', - 'executionreport', - 'algoexecutionreportv2', - 'position', - 'marginassignment', + 'todo', + 'todo', + 'todo', + 'todo', + 'todo', + 'todo', + 'todo', ]; if (topicName && privateTopics.includes(topicName)) { @@ -234,29 +247,22 @@ export class WebsocketClient extends BaseWebsocketClient< return false; } - protected getWsKeyForMarket(_market: WsMarket, isPrivate: boolean): WsKey { - return isPrivate ? WS_KEY_MAP.privateV1 : WS_KEY_MAP.publicV1; - } - - protected getWsMarketForWsKey(key: WsKey): WsMarket { - switch (key) { - case 'publicV1': - case 'privateV1': { - return 'all'; - } - default: { - throw neverGuard(key, `Unhandled ws key "${key}"`); - } - } + /** + * Not in use for gate.io + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getWsKeyForTopic(_topic: WsTopic): WsKey { + return 'announcementsV4'; } - protected getWsKeyForTopic(topic: WsTopic): WsKey { - const market = this.getMarketForTopic(topic); - const isPrivateTopic = this.isPrivateChannel(topic); - - return this.getWsKeyForMarket(market, isPrivateTopic); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected getWsMarketForWsKey(_wsKey: WsKey): WsMarket { + return 'all'; } + /** + * Not in use for gate.io + */ protected getPrivateWSKeys(): WsKey[] { return PRIVATE_WS_KEYS; } @@ -266,36 +272,24 @@ export class WebsocketClient extends BaseWebsocketClient< return this.options.wsUrl; } - const applicationId = this.options.apiApplicationId; - const networkKey = 'livenet'; + const useTestnet = this.options.useTestnet; + const networkKey = useTestnet ? 'testnet' : 'livenet'; - switch (wsKey) { - case WS_KEY_MAP.publicV1: { - return WS_BASE_URL_MAP.publicV1.all[networkKey] + '/' + applicationId; - } - case WS_KEY_MAP.privateV1: { - return WS_BASE_URL_MAP.privateV1.all[networkKey] + '/' + applicationId; - } - default: { - this.logger.error('getWsUrl(): Unhandled wsKey: ', { - ...WS_LOGGER_CATEGORY, - wsKey, - }); - throw neverGuard(wsKey, `getWsUrl(): Unhandled wsKey`); - } - } + const baseUrl = WS_BASE_URL_MAP[wsKey][networkKey]; + return baseUrl; } /** Force subscription requests to be sent in smaller batches, if a number is returned */ protected getMaxTopicsPerSubscribeEvent(wsKey: WsKey): number | null { switch (wsKey) { - case 'publicV1': - case 'privateV1': { - // Return a number if there's a limit on the number of sub topics per rq - return 1; - } + // case 'publicV1': + // case 'privateV1': { + // // Return a number if there's a limit on the number of sub topics per rq + // return 1; + // } default: { - throw neverGuard(wsKey, `getWsKeyForTopic(): Unhandled wsKey`); + return 1; + // throw neverGuard(wsKey, `getWsKeyForTopic(): Unhandled wsKey`); } } } @@ -303,9 +297,10 @@ export class WebsocketClient extends BaseWebsocketClient< /** * Map one or more topics into fully prepared "subscribe request" events (already stringified and ready to send) */ - protected getWsSubscribeEventsForTopics( - topics: WsTopic[], + protected getWsOperationEventsForTopics( + topics: WsTopicRequest[], wsKey: WsKey, + operation: WsOperation, ): string[] { // console.log(new Date(), `called getWsSubscribeEventsForTopics()`, topics); // console.trace(); @@ -313,59 +308,10 @@ export class WebsocketClient extends BaseWebsocketClient< return []; } - const market = this.getWsMarketForWsKey(wsKey); - - const subscribeEvents: string[] = []; - - 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 = this.getWsRequestEvent( - market, - 'subscribe', - batch, - ); - - for (const event of subscribeRequestEvents) { - subscribeEvents.push(JSON.stringify(event)); - } - } - - return subscribeEvents; - } - - const subscribeRequestEvents = this.getWsRequestEvent( - market, - 'subscribe', - topics, - ); - - for (const event of subscribeRequestEvents) { - subscribeEvents.push(JSON.stringify(event)); - } - return subscribeEvents; - } - - /** - * Map one or more topics into fully prepared "unsubscribe request" events (already stringified and ready to send) - */ - protected getWsUnsubscribeEventsForTopics( - topics: WsTopic[], - wsKey: WsKey, - ): string[] { - if (!topics.length) { - return []; - } + // Events that are ready to send (usually stringified JSON) + const jsonStringEvents: string[] = []; const market = this.getWsMarketForWsKey(wsKey); - - const subscribeEvents: string[] = []; - const maxTopicsPerEvent = this.getMaxTopicsPerSubscribeEvent(wsKey); if ( maxTopicsPerEvent && @@ -376,27 +322,28 @@ export class WebsocketClient extends BaseWebsocketClient< const batch = topics.slice(i, i + maxTopicsPerEvent); const subscribeRequestEvents = this.getWsRequestEvent( market, - 'unsubscribe', + operation, batch, ); for (const event of subscribeRequestEvents) { - subscribeEvents.push(JSON.stringify(event)); + jsonStringEvents.push(JSON.stringify(event)); } } - return subscribeEvents; + return jsonStringEvents; } const subscribeRequestEvents = this.getWsRequestEvent( market, - 'unsubscribe', + operation, topics, ); + for (const event of subscribeRequestEvents) { - subscribeEvents.push(JSON.stringify(event)); + jsonStringEvents.push(JSON.stringify(event)); } - return subscribeEvents; + return jsonStringEvents; } /** @@ -405,17 +352,23 @@ export class WebsocketClient extends BaseWebsocketClient< private getWsRequestEvent( market: WsMarket, operation: WsOperation, - topics: WsTopic[], - ): WsRequestOperation[] { + requests: WsTopicRequest[], + ): WsRequestOperationGate[] { + const timeInSeconds = +(Date.now() / 1000).toFixed(0); switch (market) { case 'all': { - return topics.map((topic) => { - const wsRequestEvent: WsRequestOperation = { - id: `${operation}_${topic}`, + return requests.map((request) => { + const wsRequestEvent: WsRequestOperationGate = { + time: timeInSeconds, + channel: request.topic, event: operation, - topic: topic, + // payload: 'todo', }; + if (request.params) { + wsRequestEvent.payload = request.params; + } + return wsRequestEvent; }); } @@ -427,11 +380,7 @@ export class WebsocketClient extends BaseWebsocketClient< protected async getWsAuthRequestEvent(wsKey: WsKey): Promise { const market = this.getWsMarketForWsKey(wsKey); - if ( - !this.options.apiKey || - !this.options.apiSecret || - !this.options.apiApplicationId - ) { + if (!this.options.apiKey || !this.options.apiSecret) { throw new Error( `Cannot auth - missing api key, secret or memo in config`, ); @@ -484,28 +433,4 @@ export class WebsocketClient extends BaseWebsocketClient< throw new Error(`Could not resolve "market" for topic: "${topic}"`); } - - /** - * Used to split sub/unsub logic by websocket connection - */ - private arrangeTopicsIntoWsKeyGroups( - topics: WsTopic[], - ): Record { - const topicsByWsKey: Record = { - privateV1: [], - publicV1: [], - }; - - for (const untypedTopic of topics) { - const topic = untypedTopic as WsTopic; - const wsKeyForTopic = this.getWsKeyForTopic(topic); - - const wsKeyTopicList = topicsByWsKey[wsKeyForTopic]; - if (!wsKeyTopicList.includes(topic)) { - wsKeyTopicList.push(topic); - } - } - - return topicsByWsKey; - } } diff --git a/src/lib/BaseWSClient.ts b/src/lib/BaseWSClient.ts index 8281bb2..27cad12 100644 --- a/src/lib/BaseWSClient.ts +++ b/src/lib/BaseWSClient.ts @@ -5,9 +5,14 @@ import { WebsocketClientOptions, WSClientConfigurableOptions, } from '../types/websockets/client.js'; +import { WsOperation } from '../types/websockets/requests.js'; import { WS_LOGGER_CATEGORY } from '../WebsocketClient.js'; import { DefaultLogger } from './logger.js'; import { isMessageEvent, MessageEventLike } from './requestUtils.js'; +import { + WsTopicRequest, + WsTopicRequestOrStringTopic, +} from './websocket/websocket-util.js'; import { WsStore } from './websocket/WsStore.js'; import { WsConnectionStateEnum } from './websocket/WsStore.types.js'; @@ -36,13 +41,7 @@ export interface EmittableEvent { } // Type safety for on and emit handlers: https://stackoverflow.com/a/61609010/880837 -export interface BaseWebsocketClient< - // eslint-disable-next-line @typescript-eslint/no-unused-vars - TWSMarket extends string, - TWSKey extends string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - TWSTopic = any, -> { +export interface BaseWebsocketClient { on>( event: U, listener: WSClientEventMap[U], @@ -54,16 +53,47 @@ export interface BaseWebsocketClient< ): boolean; } +/** + * Users can conveniently pass topics as strings or objects (object has topic name + optional params). + * + * This method normalises topics into objects (object has topic name + optional params). + */ +function getNormalisedTopicRequests( + wsTopicRequests: WsTopicRequestOrStringTopic[], +): WsTopicRequest[] { + const normalisedTopicRequests: WsTopicRequest[] = []; + + for (const wsTopicRequest of wsTopicRequests) { + // passed as string, convert to object + if (typeof wsTopicRequest === 'string') { + const topicRequest: WsTopicRequest = { + topic: wsTopicRequest, + params: undefined, + }; + normalisedTopicRequests.push(topicRequest); + continue; + } + + // already a normalised object, thanks to user + normalisedTopicRequests.push(wsTopicRequest); + } + return normalisedTopicRequests; +} + +/** + * Base WebSocket abstraction layer. Handles connections, tracking each connection as a unique "WS Key" + */ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export abstract class BaseWebsocketClient< - TWSMarket extends string, - TWSKey extends string, /** - * The "topic" being subscribed to, not the event sent to subscribe to one or more topics + * The WS connections supported by the client, each identified by a unique primary key */ - TWSTopic extends string | object, + TWSKey extends string, > extends EventEmitter { - private wsStore: WsStore; + /** + * State store to track a list of topics (topic requests) we are expected to be subscribed to if reconnected + */ + private wsStore: WsStore>; protected logger: typeof DefaultLogger; protected options: WebsocketClientOptions; @@ -86,11 +116,6 @@ export abstract class BaseWebsocketClient< }; } - protected abstract getWsKeyForMarket( - market: TWSMarket, - isPrivate?: boolean, - ): TWSKey; - protected abstract sendPingEvent(wsKey: TWSKey, ws: WebSocket): void; protected abstract sendPongEvent(wsKey: TWSKey, ws: WebSocket): void; @@ -99,30 +124,24 @@ export abstract class BaseWebsocketClient< protected abstract getWsAuthRequestEvent(wsKey: TWSKey): Promise; - protected abstract getWsMarketForWsKey(key: TWSKey): TWSMarket; - - protected abstract isPrivateChannel(subscribeEvent: TWSTopic): boolean; + protected abstract isPrivateTopicRequest( + request: WsTopicRequest, + ): boolean; protected abstract getPrivateWSKeys(): TWSKey[]; protected abstract getWsUrl(wsKey: TWSKey): string; + protected abstract getMaxTopicsPerSubscribeEvent( wsKey: TWSKey, ): number | null; /** - * Returns a list of string events that can be individually sent upstream to complete subscribing to these topics - */ - protected abstract getWsSubscribeEventsForTopics( - topics: TWSTopic[], - wsKey: TWSKey, - ): string[]; - - /** - * Returns a list of string events that can be individually sent upstream to complete unsubscribing to these topics + * Returns a list of string events that can be individually sent upstream to complete subscribing/unsubscribing/etc to these topics */ - protected abstract getWsUnsubscribeEventsForTopics( - topics: TWSTopic[], + protected abstract getWsOperationEventsForTopics( + topics: WsTopicRequest[], wsKey: TWSKey, + operation: WsOperation, ): string[]; /** @@ -142,6 +161,8 @@ export abstract class BaseWebsocketClient< } /** + * Don't call directly! Use subscribe() instead! + * * Subscribe to one or more topics on a WS connection (identified by WS Key). * * - Topics are automatically cached @@ -149,12 +170,17 @@ export abstract class BaseWebsocketClient< * - Authentication is automatically handled * - Topics are automatically resubscribed to, if something happens to the connection, unless you call unsubsribeTopicsForWsKey(topics, key). * - * @param wsTopics array of topics to subscribe to + * @param wsRequests array of topics to subscribe to * @param wsKey ws key referring to the ws connection these topics should be subscribed on */ - public subscribeTopicsForWsKey(wsTopics: TWSTopic[], wsKey: TWSKey) { + protected subscribeTopicsForWsKey( + wsTopicRequests: WsTopicRequestOrStringTopic[], + wsKey: TWSKey, + ) { + const normalisedTopicRequests = getNormalisedTopicRequests(wsTopicRequests); + // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically - for (const topic of wsTopics) { + for (const topic of normalisedTopicRequests) { this.wsStore.addTopic(wsKey, topic); } @@ -181,19 +207,25 @@ export abstract class BaseWebsocketClient< if (isPrivateConnection && !isAuthenticated) { /** * If not authenticated yet and auth is required, don't request topics yet. - * Topics will automatically subscribe post-auth success. + * + * Auth should already automatically be in progress, so no action needed from here. Topics will automatically subscribe post-auth success. */ return false; } // Finally, request subscription to topics if the connection is healthy and ready - this.requestSubscribeTopics(wsKey, wsTopics); + this.requestSubscribeTopics(wsKey, normalisedTopicRequests); } - public unsubscribeTopicsForWsKey(wsTopics: TWSTopic[], wsKey: TWSKey) { + protected unsubscribeTopicsForWsKey( + wsTopicRequests: WsTopicRequestOrStringTopic[], + wsKey: TWSKey, + ) { + const normalisedTopicRequests = getNormalisedTopicRequests(wsTopicRequests); + // Store topics, so future automation (post-auth, post-reconnect) has everything needed to resubscribe automatically - for (const topic of wsTopics) { - this.wsStore.addTopic(wsKey, topic); + for (const topic of normalisedTopicRequests) { + this.wsStore.deleteTopic(wsKey, topic); } const isConnected = this.wsStore.isConnectionState( @@ -201,7 +233,8 @@ export abstract class BaseWebsocketClient< WsConnectionStateEnum.CONNECTED, ); - // If not connected, don't need to do anything + // If not connected, don't need to do anything. + // Removing the topic from the store is enough to stop it from being resubscribed to on reconnect. if (!isConnected) { return; } @@ -218,93 +251,11 @@ export abstract class BaseWebsocketClient< } // Finally, request subscription to topics if the connection is healthy and ready - this.requestUnsubscribeTopics(wsKey, wsTopics); + this.requestUnsubscribeTopics(wsKey, normalisedTopicRequests); } - // /** - // * Subscribe to topics & track/persist them. They will be automatically resubscribed to if the connection drops/reconnects. - // * @param wsTopics topic or list of topics - // * @param isPrivate 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) - // */ - // public subscribe( - // wsTopics: TWSTopic[] | TWSTopic, - // market: TWSMarket, - // isPrivate?: boolean, - // ) { - // const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - - // const topicsByWsKey = - - // topics.forEach((topic) => { - // const isPrivateTopic = isPrivate || this.isPrivateChannel(topic); - // const wsKey = this.getWsKeyForMarket(market, isPrivateTopic); - - // // Persist this topic to the expected topics list - // this.wsStore.addTopic(wsKey, topic); - - // // if connected, send subscription request - // if ( - // this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) - // ) { - // // if not authenticated, dont sub to private topics yet. - // // This'll happen automatically once authenticated - // if (isPrivateTopic && !this.wsStore.get(wsKey)?.isAuthenticated) { - // return; - // } - - // console.log(`subscribe(), `, { - // wsTopics, - // isPrivate, - // isAuthenticated: this.wsStore.get(wsKey)?.isAuthenticated, - // }); - // // TODO: this might need tidying up. - // // !!Takes many topics and then feeds them one by one, to a method that supports many topics - // return this.requestSubscribeTopics(wsKey, [topic]); - // } - - // // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect - // if ( - // !this.wsStore.isConnectionState( - // wsKey, - // WsConnectionStateEnum.CONNECTING, - // ) && - // !this.wsStore.isConnectionState( - // wsKey, - // 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) - // */ - // public unsubscribe( - // wsTopics: TWSTopic[] | TWSTopic, - // market: TWSMarket, - // isPrivateTopic?: boolean, - // ) { - // const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; - // topics.forEach((topic) => { - // const wsKey = this.getWsKeyForMarket(market, isPrivateTopic); - - // this.wsStore.deleteTopic(wsKey, topic); - - // // unsubscribe request only necessary if active connection exists - // if ( - // this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) - // ) { - // this.requestUnsubscribeTopics(wsKey, [topic]); - // } - // }); - // } - /** Get the WsStore that tracks websockets & topics */ - public getWsStore(): WsStore { + public getWsStore(): WsStore> { return this.wsStore; } @@ -410,6 +361,7 @@ export abstract class BaseWebsocketClient< ...WS_LOGGER_CATEGORY, wsKey, }); + // TODO:! const request = await this.getWsAuthRequestEvent(wsKey); @@ -499,14 +451,18 @@ export abstract class BaseWebsocketClient< * * @private Use the `subscribe(topics)` or `subscribeTopicsForWsKey(topics, wsKey)` method to subscribe to topics. Send WS message to subscribe to topics. */ - private requestSubscribeTopics(wsKey: TWSKey, topics: TWSTopic[]) { + private requestSubscribeTopics( + wsKey: TWSKey, + topics: WsTopicRequest[], + ) { if (!topics.length) { return; } - const subscribeWsMessages = this.getWsSubscribeEventsForTopics( + const subscribeWsMessages = this.getWsOperationEventsForTopics( topics, wsKey, + 'subscribe', ); this.logger.trace( @@ -530,18 +486,22 @@ export abstract class BaseWebsocketClient< * * @private Use the `unsubscribe(topics)` method to unsubscribe from topics. Send WS message to unsubscribe from topics. */ - private requestUnsubscribeTopics(wsKey: TWSKey, topics: TWSTopic[]) { - if (!topics.length) { + private requestUnsubscribeTopics( + wsKey: TWSKey, + wsTopicRequests: WsTopicRequest[], + ) { + if (!wsTopicRequests.length) { return; } - const subscribeWsMessages = this.getWsUnsubscribeEventsForTopics( - topics, + const subscribeWsMessages = this.getWsOperationEventsForTopics( + wsTopicRequests, wsKey, + 'unsubscribe', ); this.logger.trace( - `Unsubscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches. Events: "${JSON.stringify(topics)}"`, + `Unsubscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches. Events: "${JSON.stringify(wsTopicRequests)}"`, ); for (const wsMessage of subscribeWsMessages) { @@ -550,7 +510,7 @@ export abstract class BaseWebsocketClient< } this.logger.trace( - `Finished unsubscribing to ${topics.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, + `Finished unsubscribing to ${wsTopicRequests.length} "${wsKey}" topics in ${subscribeWsMessages.length} batches.`, ); } @@ -633,8 +593,9 @@ export abstract class BaseWebsocketClient< // Private topics will be resubscribed to once reconnected const topics = [...this.wsStore.getTopics(wsKey)]; const publicTopics = topics.filter( - (topic) => !this.isPrivateChannel(topic), + (topic) => !this.isPrivateTopicRequest(topic), ); + this.requestSubscribeTopics(wsKey, publicTopics); this.logger.trace(`Enabled ping timer`, { ...WS_LOGGER_CATEGORY, wsKey }); @@ -651,7 +612,7 @@ export abstract class BaseWebsocketClient< const topics = [...this.wsStore.getTopics(wsKey)]; const privateTopics = topics.filter((topic) => - this.isPrivateChannel(topic), + this.isPrivateTopicRequest(topic), ); if (privateTopics.length) { diff --git a/src/lib/requestUtils.ts b/src/lib/requestUtils.ts index 59c12a8..1d1480e 100644 --- a/src/lib/requestUtils.ts +++ b/src/lib/requestUtils.ts @@ -20,7 +20,7 @@ export interface RestClientOptions { /** * Optionally override API protocol + domain - * e.g baseUrl: 'https://api.woo.org' + * e.g baseUrl: 'https://api.gate.io' **/ baseUrl?: string; diff --git a/src/lib/websocket/WsStore.ts b/src/lib/websocket/WsStore.ts index ebdfc31..41ae5c5 100644 --- a/src/lib/websocket/WsStore.ts +++ b/src/lib/websocket/WsStore.ts @@ -6,7 +6,7 @@ import { WsConnectionStateEnum, WsStoredState } from './WsStore.types.js'; /** * Simple comparison of two objects, only checks 1-level deep (nested objects won't match) */ -function isDeepObjectMatch(object1: unknown, object2: unknown) { +export function isDeepObjectMatch(object1: unknown, object2: unknown): boolean { if (typeof object1 === 'string' && typeof object2 === 'string') { return object1 === object2; } @@ -150,6 +150,12 @@ export class WsStore< } // Since topics are objects we can't rely on the set to detect duplicates + /** + * Find matching "topic" request from the store + * @param key + * @param topic + * @returns + */ getMatchingTopic(key: WsKey, topic: TWSTopicSubscribeEventArgs) { // if (typeof topic === 'string') { // return this.getMatchingTopic(key, { channel: topic }); diff --git a/src/lib/websocket/websocket-util.ts b/src/lib/websocket/websocket-util.ts index d8ae08c..ab6b428 100644 --- a/src/lib/websocket/websocket-util.ts +++ b/src/lib/websocket/websocket-util.ts @@ -1,11 +1,67 @@ -/** Should be one WS key per unique URL */ +/** + * Should be one WS key per unique URL. Some URLs may need a suffix. + */ export const WS_KEY_MAP = { - publicV1: 'publicV1', - privateV1: 'privateV1', + /** + * Spot & Margin + * https://www.gate.io/docs/developers/apiv4/ws/en/ + */ + spotV4: 'spotV4', + /** + * Perpetual futures (USDT) + * https://www.gate.io/docs/developers/futures/ws/en/#gate-io-futures-websocket-v4 + */ + perpFuturesUSDTV4: 'perpFuturesUSDTV4', + /** + * Perpetual futures (BTC) + * https://www.gate.io/docs/developers/futures/ws/en/#gate-io-futures-websocket-v4 + */ + perpFuturesBTCV4: 'perpFuturesBTCV4', + /** + * Delivery Futures (USDT) + * https://www.gate.io/docs/developers/delivery/ws/en/ + */ + deliveryFuturesUSDTV4: 'deliveryFuturesUSDTV4', + /** + * Delivery Futures (BTC) + * https://www.gate.io/docs/developers/delivery/ws/en/ + */ + deliveryFuturesBTCV4: 'deliveryFuturesBTCV4', + /** + * Options + * https://www.gate.io/docs/developers/options/ws/en/ + */ + optionsV4: 'optionsV4', + /** + * Announcements V4 + * https://www.gate.io/docs/developers/options/ws/en/ + */ + announcementsV4: 'announcementsV4', } as const; /** This is used to differentiate between each of the available websocket streams */ export type WsKey = (typeof WS_KEY_MAP)[keyof typeof WS_KEY_MAP]; +export type WsMarket = 'all'; + +/** + * Normalised internal format for a request (subscribe/unsubscribe/etc) on a topic, with optional parameters. + * + * - WsKey: the WS connection this event is for + * - Topic: the topic this event is for + * - Payload: the parameters to include, optional. E.g. auth requires key + sign. Some topics allow configurable parameters. + */ +export interface WsTopicRequest { + topic: TWSTopic; + params?: TWSPayload; +} + +/** + * Conveniently allow users to request a topic either as string topics or objects (containing string topic + params) + */ +export type WsTopicRequestOrStringTopic< + TWSTopic extends string, + TWSPayload = any, +> = WsTopicRequest | string; /** * Some exchanges have two livenet environments, some have test environments, some dont. This allows easy flexibility for different exchanges. @@ -24,19 +80,35 @@ type NetworkMap< export const WS_BASE_URL_MAP: Record< WsKey, - Record<'all', NetworkMap<'livenet' | 'staging'>> + NetworkMap<'livenet' | 'testnet'> > = { - publicV1: { - all: { - livenet: 'wss://wss.woo.org/ws/stream/', - staging: 'wss://wss.staging.woo.org/ws/stream', - }, + spotV4: { + livenet: 'wss://api.gateio.ws/ws/v4/', + testnet: 'NoTestnetForSpotWebsockets!', + }, + perpFuturesUSDTV4: { + livenet: 'wss://fx-ws.gateio.ws/v4/ws/usdt', + testnet: 'wss://fx-ws-testnet.gateio.ws/v4/ws/usdt', + }, + perpFuturesBTCV4: { + livenet: 'wss://fx-ws.gateio.ws/v4/ws/btc', + testnet: 'wss://fx-ws-testnet.gateio.ws/v4/ws/btc', + }, + deliveryFuturesUSDTV4: { + livenet: 'wss://fx-ws.gateio.ws/v4/ws/delivery/usdt', + testnet: 'wss://fx-ws-testnet.gateio.ws/v4/ws/delivery/usdt', + }, + deliveryFuturesBTCV4: { + livenet: 'wss://fx-ws.gateio.ws/v4/ws/delivery/btc', + testnet: 'wss://fx-ws-testnet.gateio.ws/v4/ws/delivery/btc', + }, + optionsV4: { + livenet: 'wss://op-ws.gateio.live/v4/ws', + testnet: 'wss://op-ws-testnet.gateio.live/v4/ws', }, - privateV1: { - all: { - livenet: 'wss://wss.woo.org/v2/ws/private/stream', - staging: 'wss://wss.staging.woo.org/v2/ws/private/stream', - }, + announcementsV4: { + livenet: 'wss://api.gateio.ws/ws/v4/ann', + testnet: 'NoTestnetForAnnouncementsWebSockets!', }, }; diff --git a/src/types/websockets/client.ts b/src/types/websockets/client.ts index f84fa07..ab861df 100644 --- a/src/types/websockets/client.ts +++ b/src/types/websockets/client.ts @@ -18,8 +18,7 @@ export interface WSClientConfigurableOptions { /** Your API secret */ apiSecret?: string; - /** Your application ID, given to you when creating your API keys */ - apiApplicationId?: string; + useTestnet?: boolean; /** Define a recv window when preparing a private websocket signature. This is in milliseconds, so 5000 == 5 seconds */ recvWindow?: number; @@ -55,5 +54,3 @@ export interface WebsocketClientOptions extends WSClientConfigurableOptions { reconnectTimeout: number; recvWindow: number; } - -export type WsMarket = 'all'; diff --git a/src/types/websockets/requests.ts b/src/types/websockets/requests.ts index a41b310..88c09ed 100644 --- a/src/types/websockets/requests.ts +++ b/src/types/websockets/requests.ts @@ -1,7 +1,24 @@ export type WsOperation = 'subscribe' | 'unsubscribe' | 'auth'; -export type WsRequestOperation = { - id: string; - event: WsOperation; - topic: TWSTopic; -}; +export interface WsRequestAuthGate { + method: 'api_key'; + KEY: string; + SIGN: string; +} + +export interface WsRequestPing { + time: number; + channel: 'spot.ping' | 'futures.ping' | 'options.ping' | 'announcement.ping'; +} + +export interface WsRequestOperationGate< + TWSTopic extends string, + TWSPayload = any, +> { + time: number; + id?: number; + channel: TWSTopic; + auth?: WsRequestAuthGate; + event?: WsOperation; + payload?: TWSPayload; +}