diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index b0a8e2f..0000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,31 +0,0 @@ -version: 2.1 - -jobs: - test: - docker: - - image: cimg/node:15.1 - steps: - - checkout - - restore_cache: - # See the configuration reference documentation for more details on using restore_cache and save_cache steps - # https://circleci.com/docs/2.0/configuration-reference/?section=reference#save_cache - keys: - - node-deps-v1-{{ .Branch }}-{{checksum "package-lock.json"}} - - run: - name: install packages - command: npm ci --ignore-scripts - - save_cache: - key: node-deps-v1-{{ .Branch }}-{{checksum "package-lock.json"}} - paths: - - ~/.npm - - run: - name: Run Build - command: npm run build - - run: - name: Run Tests - command: npm run test - -workflows: - integrationtests: - jobs: - - test diff --git a/package.json b/package.json index d150478..0d051f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bybit-api", - "version": "2.3.2", + "version": "2.4.0-beta.1", "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/types/index.ts b/src/types/index.ts index 145ac98..82b2b91 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,3 +1,4 @@ export * from './response'; export * from './request'; export * from './shared'; +export * from './websockets'; diff --git a/src/types/shared.ts b/src/types/shared.ts index 88f465a..2f8dd65 100644 --- a/src/types/shared.ts +++ b/src/types/shared.ts @@ -1,3 +1,14 @@ +import { InverseClient } from '../inverse-client'; +import { LinearClient } from '../linear-client'; +import { SpotClient } from '../spot-client'; +import { SpotClientV3 } from '../spot-client-v3'; + +export type RESTClient = + | InverseClient + | LinearClient + | SpotClient + | SpotClientV3; + export type numberInString = string; export type OrderSide = 'Buy' | 'Sell'; diff --git a/src/types/websockets.ts b/src/types/websockets.ts new file mode 100644 index 0000000..963375f --- /dev/null +++ b/src/types/websockets.ts @@ -0,0 +1,107 @@ +import { RestClientOptions } from '../util'; + +export type APIMarket = 'inverse' | 'linear' | 'spot' | 'v3'; + +// 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; + +/** This is used to differentiate between each of the available websocket streams (as bybit has multiple websockets) */ +export type WsKey = + | 'inverse' + | 'linearPrivate' + | 'linearPublic' + | 'spotPrivate' + | 'spotPublic'; + +export interface WSClientConfigurableOptions { + key?: string; + secret?: string; + livenet?: boolean; + + /** + * The API group this client should connect to. + * + * For the V3 APIs use `v3` as the market (spot/unified margin/usdc/account asset/copy trading) + */ + market: APIMarket; + + pongTimeout?: number; + pingInterval?: number; + reconnectTimeout?: number; + restOptions?: RestClientOptions; + requestOptions?: any; + wsUrl?: string; + /** If true, fetch server time before trying to authenticate (disabled by default) */ + fetchTimeOffsetBeforeAuth?: boolean; +} + +export interface WebsocketClientOptions extends WSClientConfigurableOptions { + livenet: boolean; + market: APIMarket; + pongTimeout: number; + pingInterval: number; + reconnectTimeout: number; +} diff --git a/src/util/WsStore.ts b/src/util/WsStore.ts index dd7360e..f27cb56 100644 --- a/src/util/WsStore.ts +++ b/src/util/WsStore.ts @@ -1,16 +1,35 @@ import WebSocket from 'isomorphic-ws'; -import { WsConnectionState } from '../websocket-client'; import { DefaultLogger } from './logger'; +export enum WsConnectionStateEnum { + INITIAL = 0, + CONNECTING = 1, + CONNECTED = 2, + CLOSING = 3, + RECONNECTING = 4, +} +/** A "topic" is always a string */ type WsTopic = string; + +/** + * A "Set" is used to ensure we only subscribe to a topic once (tracking a list of unique topics we're expected to be connected to) + * TODO: do any WS topics allow parameters? If so, we need a way to track those (see FTX implementation) + */ type WsTopicList = Set; interface WsStoredState { + /** The currently active websocket connection */ ws?: WebSocket; - connectionState?: WsConnectionState; + /** The current lifecycle state of the connection (enum) */ + connectionState?: WsConnectionStateEnum; + /** A timer that will send an upstream heartbeat (ping) when it expires */ activePingTimer?: ReturnType | undefined; + /** A timer tracking that an upstream heartbeat was sent, expecting a reply before it expires */ activePongTimer?: ReturnType | undefined; + /** + * All the topics we are expected to be subscribed to (and we automatically resubscribe to if the connection drops) + */ subscribedTopics: WsTopicList; } @@ -23,6 +42,9 @@ export default class WsStore { this.wsState = {}; } + /** Get WS stored state for key, optionally create if missing */ + get(key: string, createIfMissing?: true): WsStoredState; + get(key: string, createIfMissing?: false): WsStoredState | undefined; get(key: string, createIfMissing?: boolean): WsStoredState | undefined { if (this.wsState[key]) { return this.wsState[key]; @@ -46,7 +68,7 @@ export default class WsStore { } this.wsState[key] = { subscribedTopics: new Set(), - connectionState: WsConnectionState.READY_STATE_INITIAL, + connectionState: WsConnectionStateEnum.INITIAL, }; return this.get(key); } @@ -94,22 +116,22 @@ export default class WsStore { ); } - getConnectionState(key: string): WsConnectionState { + getConnectionState(key: string): WsConnectionStateEnum { return this.get(key, true)!.connectionState!; } - setConnectionState(key: string, state: WsConnectionState) { + setConnectionState(key: string, state: WsConnectionStateEnum) { this.get(key, true)!.connectionState = state; } - isConnectionState(key: string, state: WsConnectionState): boolean { + isConnectionState(key: string, state: WsConnectionStateEnum): boolean { return this.getConnectionState(key) === state; } /* subscribed topics */ getTopics(key: string): WsTopicList { - return this.get(key, true)!.subscribedTopics; + return this.get(key, true).subscribedTopics; } getTopicsByKey(): Record { diff --git a/src/util/index.ts b/src/util/index.ts index e25bf42..000f888 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -2,3 +2,4 @@ export * from './BaseRestClient'; export * from './requestUtils'; export * from './WsStore'; export * from './logger'; +export * from './websocket-util'; diff --git a/src/util/websocket-util.ts b/src/util/websocket-util.ts new file mode 100644 index 0000000..1724dff --- /dev/null +++ b/src/util/websocket-util.ts @@ -0,0 +1,40 @@ +import { WsKey } from '../types'; + +export const wsKeyInverse = 'inverse'; +export const wsKeyLinearPrivate = 'linearPrivate'; +export const wsKeyLinearPublic = 'linearPublic'; +export const wsKeySpotPrivate = 'spotPrivate'; +export const wsKeySpotPublic = 'spotPublic'; + +export function getLinearWsKeyForTopic(topic: string): WsKey { + const privateLinearTopics = [ + 'position', + 'execution', + 'order', + 'stop_order', + 'wallet', + ]; + if (privateLinearTopics.includes(topic)) { + return wsKeyLinearPrivate; + } + + return wsKeyLinearPublic; +} + +export function getSpotWsKeyForTopic(topic: string): WsKey { + const privateLinearTopics = [ + 'position', + 'execution', + 'order', + 'stop_order', + 'outboundAccountInfo', + 'executionReport', + 'ticketInfo', + ]; + + if (privateLinearTopics.includes(topic)) { + return wsKeySpotPrivate; + } + + return wsKeySpotPublic; +} diff --git a/src/websocket-client.ts b/src/websocket-client.ts index c8dd784..6fc0bdd 100644 --- a/src/websocket-client.ts +++ b/src/websocket-client.ts @@ -3,17 +3,35 @@ import WebSocket from 'isomorphic-ws'; import { InverseClient } from './inverse-client'; import { LinearClient } from './linear-client'; -import { DefaultLogger } from './util/logger'; +import { SpotClientV3 } from './spot-client-v3'; import { SpotClient } from './spot-client'; -import { KlineInterval } from './types/shared'; + +import { DefaultLogger } from './util/logger'; +import { + APIMarket, + KlineInterval, + RESTClient, + WebsocketClientOptions, + WSClientConfigurableOptions, + WsKey, + WsTopic, +} from './types'; + import { signMessage } from './util/node-support'; + +import WsStore from './util/WsStore'; import { serializeParams, isWsPong, - RestClientOptions, -} from './util/requestUtils'; - -import WsStore from './util/WsStore'; + getLinearWsKeyForTopic, + getSpotWsKeyForTopic, + wsKeyInverse, + wsKeyLinearPrivate, + wsKeyLinearPublic, + wsKeySpotPrivate, + wsKeySpotPublic, + WsConnectionStateEnum, +} from './util'; const inverseEndpoints = { livenet: 'wss://stream.bybit.com/realtime', @@ -48,169 +66,6 @@ const spotEndpoints = { const loggerCategory = { category: 'bybit-ws' }; -const READY_STATE_INITIAL = 0; -const READY_STATE_CONNECTING = 1; -const READY_STATE_CONNECTED = 2; -const READY_STATE_CLOSING = 3; -const READY_STATE_RECONNECTING = 4; - -export enum WsConnectionState { - READY_STATE_INITIAL, - READY_STATE_CONNECTING, - READY_STATE_CONNECTED, - READY_STATE_CLOSING, - 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. - /** - * @deprecated Use the property { market: 'linear' } instead - */ - linear?: boolean; - - market?: APIMarket; - - pongTimeout?: number; - pingInterval?: number; - reconnectTimeout?: number; - restOptions?: RestClientOptions; - requestOptions?: any; - wsUrl?: string; - /** If true, fetch server time before trying to authenticate (disabled by default) */ - fetchTimeOffsetBeforeAuth?: boolean; -} - -export interface WebsocketClientOptions extends WSClientConfigurableOptions { - livenet: boolean; - /** - * @deprecated Use the property { market: 'linear' } instead - */ - linear?: boolean; - market?: APIMarket; - pongTimeout: number; - pingInterval: number; - reconnectTimeout: number; -} - -export const wsKeyInverse = 'inverse'; -export const wsKeyLinearPrivate = 'linearPrivate'; -export const wsKeyLinearPublic = 'linearPublic'; -export const wsKeySpotPrivate = 'spotPrivate'; -export const wsKeySpotPublic = 'spotPublic'; - -// This is used to differentiate between each of the available websocket streams (as bybit has multiple websockets) -export type WsKey = - | 'inverse' - | 'linearPrivate' - | 'linearPublic' - | 'spotPrivate' - | 'spotPublic'; - -const getLinearWsKeyForTopic = (topic: string): WsKey => { - const privateLinearTopics = [ - 'position', - 'execution', - 'order', - 'stop_order', - 'wallet', - ]; - if (privateLinearTopics.includes(topic)) { - return wsKeyLinearPrivate; - } - - return wsKeyLinearPublic; -}; -const getSpotWsKeyForTopic = (topic: string): WsKey => { - const privateLinearTopics = [ - 'position', - 'execution', - 'order', - 'stop_order', - 'outboundAccountInfo', - 'executionReport', - 'ticketInfo', - ]; - - if (privateLinearTopics.includes(topic)) { - return wsKeySpotPrivate; - } - - return wsKeySpotPublic; -}; - export declare interface WebsocketClient { on( event: 'open' | 'reconnected', @@ -223,16 +78,10 @@ export declare interface WebsocketClient { 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 { private logger: typeof DefaultLogger; - private restClient: InverseClient | LinearClient | SpotClient; + /** Purely used */ + private restClient: RESTClient; private options: WebsocketClientOptions; private wsStore: WsStore; @@ -254,11 +103,15 @@ export class WebsocketClient extends EventEmitter { ...options, }; - if (!this.options.market) { - this.options.market = resolveMarket(this.options); - } - - if (this.isLinear()) { + if (this.isV3()) { + this.restClient = new SpotClientV3( + undefined, + undefined, + this.isLivenet(), + this.options.restOptions, + this.options.requestOptions + ); + } else if (this.isLinear()) { this.restClient = new LinearClient( undefined, undefined, @@ -299,7 +152,12 @@ export class WebsocketClient extends EventEmitter { } public isInverse(): boolean { - return !this.isLinear() && !this.isSpot(); + return this.options.market === 'inverse'; + } + + /** USDC, spot v3, unified margin, account asset */ + public isV3(): boolean { + return this.options.market === 'v3'; } /** @@ -314,14 +172,22 @@ export class WebsocketClient extends EventEmitter { // attempt to send subscription topic per websocket this.wsStore.getKeys().forEach((wsKey: WsKey) => { // if connected, send subscription request - if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTED)) { + if ( + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) + ) { return this.requestSubscribeTopics(wsKey, topics); } // start connection process if it hasn't yet begun. Topics are automatically subscribed to on-connect if ( - !this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING) && - !this.wsStore.isConnectionState(wsKey, READY_STATE_RECONNECTING) + !this.wsStore.isConnectionState( + wsKey, + WsConnectionStateEnum.CONNECTING + ) && + !this.wsStore.isConnectionState( + wsKey, + WsConnectionStateEnum.RECONNECTING + ) ) { return this.connect(wsKey); } @@ -339,7 +205,9 @@ export class WebsocketClient extends EventEmitter { this.wsStore.getKeys().forEach((wsKey: WsKey) => { // unsubscribe request only necessary if active connection exists - if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTED)) { + if ( + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) + ) { this.requestUnsubscribeTopics(wsKey, topics); } }); @@ -347,14 +215,14 @@ export class WebsocketClient extends EventEmitter { public close(wsKey: WsKey) { this.logger.info('Closing connection', { ...loggerCategory, wsKey }); - this.setWsState(wsKey, READY_STATE_CLOSING); + this.setWsState(wsKey, WsConnectionStateEnum.CLOSING); this.clearTimers(wsKey); this.getWs(wsKey)?.close(); } /** - * Request connection of all dependent websockets, instead of waiting for automatic connection by library + * Request connection of all dependent (public & private) websockets, instead of waiting for automatic connection by library */ public connectAll(): Promise[] | undefined { if (this.isInverse()) { @@ -411,7 +279,9 @@ export class WebsocketClient extends EventEmitter { return this.wsStore.getWs(wsKey); } - if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING)) { + if ( + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) + ) { this.logger.error( 'Refused to connect to ws, connection attempt already active', { ...loggerCategory, wsKey } @@ -421,9 +291,9 @@ export class WebsocketClient extends EventEmitter { if ( !this.wsStore.getConnectionState(wsKey) || - this.wsStore.isConnectionState(wsKey, READY_STATE_INITIAL) + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.INITIAL) ) { - this.setWsState(wsKey, READY_STATE_CONNECTING); + this.setWsState(wsKey, WsConnectionStateEnum.CONNECTING); } const authParams = await this.getAuthParams(wsKey); @@ -481,19 +351,23 @@ export class WebsocketClient extends EventEmitter { ? await this.restClient.fetchTimeOffset() : 0; - const params: any = { - api_key: this.options.key, - expires: Date.now() + timeOffset + 5000, - }; + const signatureExpires = Date.now() + timeOffset + 5000; - params.signature = await signMessage( - 'GET/realtime' + params.expires, + const signature = await signMessage( + 'GET/realtime' + signatureExpires, secret ); - return '?' + serializeParams(params); + + const authParams = { + api_key: this.options.key, + expires: signatureExpires, + signature, + }; + + return '?' + serializeParams(authParams); } else if (!key || !secret) { this.logger.warning( - 'Connot authenticate websocket, either api or private keys missing.', + 'Cannot authenticate websocket, either api or private keys missing.', { ...loggerCategory, wsKey } ); } else { @@ -508,8 +382,11 @@ export class WebsocketClient extends EventEmitter { private reconnectWithDelay(wsKey: WsKey, connectionDelayMs: number) { this.clearTimers(wsKey); - if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CONNECTING) { - this.setWsState(wsKey, READY_STATE_RECONNECTING); + if ( + this.wsStore.getConnectionState(wsKey) !== + WsConnectionStateEnum.CONNECTING + ) { + this.setWsState(wsKey, WsConnectionStateEnum.RECONNECTING); } setTimeout(() => { @@ -635,7 +512,9 @@ export class WebsocketClient extends EventEmitter { } private onWsOpen(event, wsKey: WsKey) { - if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTING)) { + if ( + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTING) + ) { this.logger.info('Websocket connected', { ...loggerCategory, wsKey, @@ -645,13 +524,13 @@ export class WebsocketClient extends EventEmitter { }); this.emit('open', { wsKey, event }); } else if ( - this.wsStore.isConnectionState(wsKey, READY_STATE_RECONNECTING) + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.RECONNECTING) ) { this.logger.info('Websocket reconnected', { ...loggerCategory, wsKey }); this.emit('reconnected', { wsKey, event }); } - this.setWsState(wsKey, READY_STATE_CONNECTED); + this.setWsState(wsKey, WsConnectionStateEnum.CONNECTED); // TODO: persistence not working yet for spot topics if (wsKey !== 'spotPublic' && wsKey !== 'spotPrivate') { @@ -670,18 +549,20 @@ export class WebsocketClient extends EventEmitter { this.clearPongTimer(wsKey); const msg = JSON.parse((event && event.data) || event); - if ('success' in msg || msg?.pong) { - this.onWsMessageResponse(msg, wsKey); - } else if (msg.topic) { - this.onWsMessageUpdate(msg); - } else { - this.logger.warning('Got unhandled ws message', { - ...loggerCategory, - message: msg, - event, - wsKey, - }); + if (msg['success'] || msg?.pong) { + return this.onWsMessageResponse(msg, wsKey); } + + if (msg.topic) { + return this.emit('update', msg); + } + + this.logger.warning('Got unhandled ws message', { + ...loggerCategory, + message: msg, + event, + wsKey, + }); } catch (e) { this.logger.error('Failed to parse ws event message', { ...loggerCategory, @@ -694,7 +575,9 @@ export class WebsocketClient extends EventEmitter { private onWsError(error: any, wsKey: WsKey) { this.parseWsError('Websocket error', error, wsKey); - if (this.wsStore.isConnectionState(wsKey, READY_STATE_CONNECTED)) { + if ( + this.wsStore.isConnectionState(wsKey, WsConnectionStateEnum.CONNECTED) + ) { this.emit('error', error); } } @@ -705,11 +588,13 @@ export class WebsocketClient extends EventEmitter { wsKey, }); - if (this.wsStore.getConnectionState(wsKey) !== READY_STATE_CLOSING) { + if ( + this.wsStore.getConnectionState(wsKey) !== WsConnectionStateEnum.CLOSING + ) { this.reconnectWithDelay(wsKey, this.options.reconnectTimeout!); this.emit('reconnect', { wsKey }); } else { - this.setWsState(wsKey, READY_STATE_INITIAL); + this.setWsState(wsKey, WsConnectionStateEnum.INITIAL); this.emit('close', { wsKey }); } } @@ -722,15 +607,11 @@ export class WebsocketClient extends EventEmitter { } } - private onWsMessageUpdate(message: any) { - this.emit('update', message); - } - private getWs(wsKey: string) { return this.wsStore.getWs(wsKey); } - private setWsState(wsKey: WsKey, state: WsConnectionState) { + private setWsState(wsKey: WsKey, state: WsConnectionStateEnum) { this.wsStore.setConnectionState(wsKey, state); } @@ -740,7 +621,7 @@ export class WebsocketClient extends EventEmitter { } const networkKey = this.isLivenet() ? 'livenet' : 'testnet'; - // TODO: reptitive + // TODO: repetitive if (this.isLinear() || wsKey.startsWith('linear')) { if (wsKey === wsKeyLinearPublic) { return linearEndpoints.public[networkKey];