diff --git a/package.json b/package.json index 05dd979..d9955ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "2.0.8", + "version": "2.1.0", "description": "Node.js connector for Bybit's REST APIs and WebSockets, with TypeScript & integration tests.", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/src/util/requestUtils.ts b/src/util/requestUtils.ts index 00dbe51..d75a0a5 100644 --- a/src/util/requestUtils.ts +++ b/src/util/requestUtils.ts @@ -61,6 +61,9 @@ export function isPublicEndpoint (endpoint: string): boolean { } export function isWsPong(response: any) { + if (response.pong) { + return true; + } return ( response.request && response.request.op === 'ping' && diff --git a/src/websocket-client.ts b/src/websocket-client.ts index fb195aa..20c460a 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -56,14 +56,77 @@ export enum WsConnectionState { READY_STATE_RECONNECTING }; +export type APIMarket = 'inverse' | 'linear' | 'spot'; + +// Same as inverse futures +export type WsPublicInverseTopic = 'orderBookL2_25' + | 'orderBookL2_200' + | 'trade' + | 'insurance' + | 'instrument_info' + | 'klineV2'; + +export type WsPublicUSDTPerpTopic = 'orderBookL2_25' + | 'orderBookL2_200' + | 'trade' + | 'insurance' + | 'instrument_info' + | 'kline'; + +export type WsPublicSpotV1Topic = 'trade' + | 'realtimes' + | 'kline' + | 'depth' + | 'mergedDepth' + | 'diffDepth'; + +export type WsPublicSpotV2Topic = 'depth' + | 'kline' + | 'trade' + | 'bookTicker' + | 'realtimes'; + +export type WsPublicTopics = WsPublicInverseTopic + | WsPublicUSDTPerpTopic + | WsPublicSpotV1Topic + | WsPublicSpotV2Topic + | string; + +// Same as inverse futures +export type WsPrivateInverseTopic = 'position' + | 'execution' + | 'order' + | 'stop_order'; + +export type WsPrivateUSDTPerpTopic = 'position' + | 'execution' + | 'order' + | 'stop_order' + | 'wallet'; + +export type WsPrivateSpotTopic = 'outboundAccountInfo' + | 'executionReport' + | 'ticketInfo'; + +export type WsPrivateTopic = WsPrivateInverseTopic + | WsPrivateUSDTPerpTopic + | WsPrivateSpotTopic + | string; + +export type WsTopic = WsPublicTopics | WsPrivateTopic; + export interface WSClientConfigurableOptions { key?: string; secret?: string; livenet?: boolean; - // defaults to inverse. Only set one at a time (this interface will change in future) + // defaults to inverse. + /** + * @deprecated Use the property { market: 'linear' } instead + */ linear?: boolean; - spot?: boolean; + + market?: APIMarket; pongTimeout?: number; pingInterval?: number; @@ -75,8 +138,11 @@ export interface WSClientConfigurableOptions { export interface WebsocketClientOptions extends WSClientConfigurableOptions { livenet: boolean; - linear: boolean; - spot: boolean; + /** + * @deprecated Use the property { market: 'linear' } instead + */ + linear?: boolean; + market?: APIMarket; pongTimeout: number; pingInterval: number; reconnectTimeout: number; @@ -113,7 +179,14 @@ const getSpotWsKeyForTopic = (topic: string): WsKey => { export declare interface WebsocketClient { on(event: 'open' | 'reconnected', listener: ({ wsKey: WsKey, event: any }) => void): this; on(event: 'response' | 'update' | 'error', listener: (response: any) => void): this; - on(event: 'reconnect' | 'close', listener: () => void): this; + on(event: 'reconnect' | 'close', listener: ({ wsKey: WsKey }) => void): this; +} + +function resolveMarket(options: WSClientConfigurableOptions): APIMarket { + if (options.linear) { + return 'linear'; + } + return 'inverse'; } export class WebsocketClient extends EventEmitter { @@ -130,19 +203,22 @@ export class WebsocketClient extends EventEmitter { this.options = { livenet: false, - linear: false, - spot: false, pongTimeout: 1000, pingInterval: 10000, reconnectTimeout: 500, ...options }; + if (!this.options.market) { + this.options.market = resolveMarket(this.options); + } + if (this.isLinear()) { this.restClient = new LinearClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); } else if (this.isSpot()) { // TODO: spot client this.restClient = new LinearClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); + this.connectPublic(); } else { this.restClient = new InverseClient(undefined, undefined, this.isLivenet(), this.options.restOptions, this.options.requestOptions); } @@ -153,11 +229,11 @@ export class WebsocketClient extends EventEmitter { } public isLinear(): boolean { - return this.options.linear === true; + return this.options.market === 'linear'; } public isSpot(): boolean { - return this.options.spot === true; + return this.options.market === 'spot'; } public isInverse(): boolean { @@ -167,7 +243,7 @@ export class WebsocketClient extends EventEmitter { /** * Add topic/topics to WS subscription list */ - public subscribe(wsTopics: string[] | string) { + public subscribe(wsTopics: WsTopic[] | WsTopic) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach(topic => this.wsStore.addTopic( this.getWsKeyForTopic(topic), @@ -194,7 +270,7 @@ export class WebsocketClient extends EventEmitter { /** * Remove topic/topics from WS subscription list */ - public unsubscribe(wsTopics: string[] | string) { + public unsubscribe(wsTopics: WsTopic[] | WsTopic) { const topics = Array.isArray(wsTopics) ? wsTopics : [wsTopics]; topics.forEach(topic => this.wsStore.deleteTopic( this.getWsKeyForTopic(topic), @@ -234,6 +310,20 @@ export class WebsocketClient extends EventEmitter { } } + public connectPublic(): Promise | undefined { + if (this.isInverse()) { + return this.connect(wsKeyInverse); + } + + if (this.isLinear()) { + return this.connect(wsKeyLinearPublic); + } + + if (this.isSpot()) { + return this.connect(wsKeySpotPublic); + } + } + public connectPrivate(): Promise | undefined { if (this.isInverse()) { return this.connect(wsKeyInverse); @@ -374,6 +464,9 @@ export class WebsocketClient extends EventEmitter { * Send WS message to subscribe to topics. */ private requestSubscribeTopics(wsKey: WsKey, topics: string[]) { + if (!topics.length) { + return; + } const wsMessage = JSON.stringify({ op: 'subscribe', args: topics @@ -386,6 +479,9 @@ export class WebsocketClient extends EventEmitter { * Send WS message to unsubscribe from topics. */ private requestUnsubscribeTopics(wsKey: WsKey, topics: string[]) { + if (!topics.length) { + return; + } const wsMessage = JSON.stringify({ op: 'unsubscribe', args: topics @@ -400,7 +496,11 @@ export class WebsocketClient extends EventEmitter { if (!wsKey) { throw new Error('Cannot send message due to no known websocket for this wsKey'); } - this.getWs(wsKey)?.send(wsMessage); + const ws = this.getWs(wsKey); + if (!ws) { + throw new Error(`${wsKey} socket not connected yet, call "connect(${wsKey}) first then try again when the "open" event arrives`); + } + ws.send(wsMessage); } catch (e) { this.logger.error(`Failed to send WS message`, { ...loggerCategory, wsMessage, wsKey, exception: e }); } @@ -429,7 +529,10 @@ export class WebsocketClient extends EventEmitter { this.setWsState(wsKey, READY_STATE_CONNECTED); - this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]); + // TODO: persistence not working yet for spot topics + if (wsKey !== 'spotPublic' && wsKey !== 'spotPrivate') { + this.requestSubscribeTopics(wsKey, [...this.wsStore.getTopics(wsKey)]); + } this.wsStore.get(wsKey, true)!.activePingTimer = setInterval( () => this.ping(wsKey), @@ -440,7 +543,7 @@ export class WebsocketClient extends EventEmitter { private onWsMessage(event, wsKey: WsKey) { const msg = JSON.parse(event && event.data || event); - if ('success' in msg) { + if ('success' in msg || msg?.pong) { this.onWsMessageResponse(msg, wsKey); } else if (msg.topic) { this.onWsMessageUpdate(msg); @@ -461,10 +564,10 @@ export class WebsocketClient extends EventEmitter { if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CLOSING) { this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); - this.emit('reconnect'); + this.emit('reconnect', { wsKey }); } else { this.setWsState(wsKey, READY_STATE_INITIAL); - this.emit('close'); + this.emit('close', { wsKey }); } } @@ -535,4 +638,25 @@ export class WebsocketClient extends EventEmitter { } return getSpotWsKeyForTopic(topic); } + + // TODO: persistance for subbed topics. Look at ftx-api implementation. + public subscribePublicSpotTrades(symbol: string, binary?: boolean) { + if (!this.isSpot()) { + throw new Error(`This WS client was instanced for the ${this.options.market} market. Make another WebsocketClient instance with "market: 'spot' to listen to spot topics`); + } + + const subscribeMessage = { + topic: 'trade', + event: 'sub', + symbol, + params: {}, + }; + if (binary) { + subscribeMessage.params = { + binary: !!binary, + }; + } + + this.tryWsSend(wsKeySpotPublic, JSON.stringify(subscribeMessage)); + } };