feat(): upgrade WebSocket layer to extend BaseWS abstraction. feat(): add promisified WS workflows, feat(): add WS API integration

This commit is contained in:
tiagosiebler
2025-01-16 16:47:09 +00:00
parent b613fd956d
commit 8a7c8ea274
9 changed files with 2512 additions and 1200 deletions

1305
src/util/BaseWSClient.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,22 +4,13 @@
export type LogParams = null | any;
export const DefaultLogger = {
/** Ping/pong events and other raw messages that might be noisy */
silly: (...params: LogParams): void => {
// console.log(params);
},
debug: (...params: LogParams): void => {
console.log(params);
},
notice: (...params: LogParams): void => {
console.log(params);
/** Ping/pong events and other raw messages that might be noisy. Enable this while troubleshooting. */
trace: (..._params: LogParams): void => {
// console.log(_params);
},
info: (...params: LogParams): void => {
console.info(params);
},
warning: (...params: LogParams): void => {
console.error(params);
},
error: (...params: LogParams): void => {
console.error(params);
},

View File

@@ -4,6 +4,7 @@ import {
WebsocketSucceededTopicSubscriptionConfirmationEvent,
WebsocketTopicSubscriptionConfirmationEvent,
} from '../types/websockets/ws-confirmations';
import { WSAPIResponse, WS_API_Operations } from '../types/websockets/ws-api';
export interface RestClientOptions {
/** Your API key */
@@ -199,6 +200,20 @@ export function isTopicSubscriptionConfirmation(
return true;
}
export function isWSAPIResponse(
msg: unknown,
): msg is Omit<WSAPIResponse, 'wsKey'> {
if (typeof msg !== 'object' || !msg) {
return false;
}
if (typeof msg['op'] !== 'string') {
return false;
}
return (WS_API_Operations as string[]).includes(msg['op']);
}
export function isTopicSubscriptionSuccess(
msg: unknown,
): msg is WebsocketSucceededTopicSubscriptionConfirmationEvent {

View File

@@ -32,8 +32,9 @@ export function isDeepObjectMatch(object1: unknown, object2: unknown): boolean {
return true;
}
const DEFERRED_PROMISE_REF = {
export const DEFERRED_PROMISE_REF = {
CONNECTION_IN_PROGRESS: 'CONNECTION_IN_PROGRESS',
AUTHENTICATION_IN_PROGRESS: 'AUTHENTICATION_IN_PROGRESS',
} as const;
type DeferredPromiseRef =
@@ -266,6 +267,15 @@ export class WsStore<
);
}
getAuthenticationInProgressPromise(
wsKey: WsKey,
): DeferredPromise<WSConnectedResult & { event: any }> | undefined {
return this.getDeferredPromise(
wsKey,
DEFERRED_PROMISE_REF.AUTHENTICATION_IN_PROGRESS,
);
}
/**
* Create a deferred promise designed to track a connection attempt in progress.
*
@@ -282,6 +292,17 @@ export class WsStore<
);
}
createAuthenticationInProgressPromise(
wsKey: WsKey,
throwIfExists: boolean,
): DeferredPromise<WSConnectedResult & { event: any }> {
return this.createDeferredPromise(
wsKey,
DEFERRED_PROMISE_REF.AUTHENTICATION_IN_PROGRESS,
throwIfExists,
);
}
/** Remove promise designed to track a connection attempt in progress */
removeConnectingInProgressPromise(wsKey: WsKey): void {
return this.removeDeferredPromise(
@@ -290,6 +311,13 @@ export class WsStore<
);
}
removeAuthenticationInProgressPromise(wsKey: WsKey): void {
return this.removeDeferredPromise(
wsKey,
DEFERRED_PROMISE_REF.AUTHENTICATION_IN_PROGRESS,
);
}
/* connection state */
isWsOpen(key: WsKey): boolean {

View File

@@ -1,7 +1,141 @@
import WebSocket from 'isomorphic-ws';
import {
APIMarket,
CategoryV5,
WebsocketClientOptions,
WsKey,
} from '../../types';
import { APIMarket, CategoryV5, WebsocketClientOptions, WsKey } from '../types';
import { DefaultLogger } from './logger';
import { DefaultLogger } from '../logger';
import { WSAPIRequest } from '../../types/websockets/ws-api';
export const WS_LOGGER_CATEGORY = { category: 'bybit-ws' };
export const WS_KEY_MAP = {
inverse: 'inverse',
linearPrivate: 'linearPrivate',
linearPublic: 'linearPublic',
spotPrivate: 'spotPrivate',
spotPublic: 'spotPublic',
spotV3Private: 'spotV3Private',
spotV3Public: 'spotV3Public',
usdcOptionPrivate: 'usdcOptionPrivate',
usdcOptionPublic: 'usdcOptionPublic',
usdcPerpPrivate: 'usdcPerpPrivate',
usdcPerpPublic: 'usdcPerpPublic',
unifiedPrivate: 'unifiedPrivate',
unifiedOptionPublic: 'unifiedOptionPublic',
unifiedPerpUSDTPublic: 'unifiedPerpUSDTPublic',
unifiedPerpUSDCPublic: 'unifiedPerpUSDCPublic',
contractUSDTPublic: 'contractUSDTPublic',
contractUSDTPrivate: 'contractUSDTPrivate',
contractInversePublic: 'contractInversePublic',
contractInversePrivate: 'contractInversePrivate',
v5SpotPublic: 'v5SpotPublic',
v5LinearPublic: 'v5LinearPublic',
v5InversePublic: 'v5InversePublic',
v5OptionPublic: 'v5OptionPublic',
v5Private: 'v5Private',
/**
* The V5 Websocket API (for sending orders over WS)
*/
v5PrivateTrade: 'v5PrivateTrade',
} as const;
export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [
WS_KEY_MAP.spotV3Private,
WS_KEY_MAP.usdcOptionPrivate,
WS_KEY_MAP.usdcPerpPrivate,
WS_KEY_MAP.unifiedPrivate,
WS_KEY_MAP.contractUSDTPrivate,
WS_KEY_MAP.contractInversePrivate,
WS_KEY_MAP.v5Private,
WS_KEY_MAP.v5PrivateTrade,
];
export const PUBLIC_WS_KEYS = [
WS_KEY_MAP.linearPublic,
WS_KEY_MAP.spotPublic,
WS_KEY_MAP.spotV3Public,
WS_KEY_MAP.usdcOptionPublic,
WS_KEY_MAP.usdcPerpPublic,
WS_KEY_MAP.unifiedOptionPublic,
WS_KEY_MAP.unifiedPerpUSDTPublic,
WS_KEY_MAP.unifiedPerpUSDCPublic,
WS_KEY_MAP.contractUSDTPublic,
WS_KEY_MAP.contractInversePublic,
WS_KEY_MAP.v5SpotPublic,
WS_KEY_MAP.v5LinearPublic,
WS_KEY_MAP.v5InversePublic,
WS_KEY_MAP.v5OptionPublic,
] as string[];
/** Used to automatically determine if a sub request should be to the public or private ws (when there's two) */
const PRIVATE_TOPICS = [
'stop_order',
'outboundAccountInfo',
'executionReport',
'ticketInfo',
// copy trading apis
'copyTradePosition',
'copyTradeOrder',
'copyTradeExecution',
'copyTradeWallet',
// usdc options
'user.openapi.option.position',
'user.openapi.option.trade',
'user.order',
'user.openapi.option.order',
'user.service',
'user.openapi.greeks',
'user.mmp.event',
// usdc perps
'user.openapi.perp.position',
'user.openapi.perp.trade',
'user.openapi.perp.order',
'user.service',
// unified margin
'user.position.unifiedAccount',
'user.execution.unifiedAccount',
'user.order.unifiedAccount',
'user.wallet.unifiedAccount',
'user.greeks.unifiedAccount',
// contract v3
'user.position.contractAccount',
'user.execution.contractAccount',
'user.order.contractAccount',
'user.wallet.contractAccount',
// v5
'position',
'execution',
'order',
'wallet',
'greeks',
];
/**
* Normalised internal format for a request (subscribe/unsubscribe/etc) on a topic, with optional parameters.
*
* - Topic: the topic this event is for
* - Payload: the parameters to include, optional. E.g. auth requires key + sign. Some topics allow configurable parameters.
* - Category: required for bybit, since different categories have different public endpoints
*/
export interface WsTopicRequest<
TWSTopic extends string = string,
TWSPayload = unknown,
> {
topic: TWSTopic;
payload?: TWSPayload;
category?: CategoryV5;
}
/**
* Conveniently allow users to request a topic either as string topics or objects (containing string topic + params)
*/
export type WsTopicRequestOrStringTopic<
TWSTopic extends string,
TWSPayload = unknown,
> = WsTopicRequest<TWSTopic, TWSPayload> | string;
interface NetworkMapV3 {
livenet: string;
@@ -33,7 +167,55 @@ export const WS_BASE_URL_MAP: Record<
APIMarket,
Record<PublicPrivateNetwork, NetworkMapV3>
> &
Record<PublicOnlyWsKeys, Record<'public', NetworkMapV3>> = {
Record<PublicOnlyWsKeys, Record<'public', NetworkMapV3>> &
Record<
typeof WS_KEY_MAP.v5PrivateTrade,
Record<PublicPrivateNetwork, NetworkMapV3>
> = {
v5: {
public: {
livenet: 'public topics are routed internally via the public wskeys',
testnet: 'public topics are routed internally via the public wskeys',
},
private: {
livenet: 'wss://stream.bybit.com/v5/private',
testnet: 'wss://stream-testnet.bybit.com/v5/private',
},
},
v5PrivateTrade: {
public: {
livenet: 'public topics are routed internally via the public wskeys',
testnet: 'public topics are routed internally via the public wskeys',
},
private: {
livenet: 'wss://stream.bybit.com/v5/trade',
testnet: 'wss://stream-testnet.bybit.com/v5/trade',
},
},
v5SpotPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/spot',
testnet: 'wss://stream-testnet.bybit.com/v5/public/spot',
},
},
v5LinearPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/linear',
testnet: 'wss://stream-testnet.bybit.com/v5/public/linear',
},
},
v5InversePublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/inverse',
testnet: 'wss://stream-testnet.bybit.com/v5/public/inverse',
},
},
v5OptionPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/option',
testnet: 'wss://stream-testnet.bybit.com/v5/public/option',
},
},
inverse: {
public: {
livenet: 'wss://stream.bybit.com/realtime',
@@ -154,139 +336,8 @@ export const WS_BASE_URL_MAP: Record<
testnet: 'wss://stream-testnet.bybit.com/contract/private/v3',
},
},
v5: {
public: {
livenet: 'public topics are routed internally via the public wskeys',
testnet: 'public topics are routed internally via the public wskeys',
},
private: {
livenet: 'wss://stream.bybit.com/v5/private',
testnet: 'wss://stream-testnet.bybit.com/v5/private',
},
},
v5SpotPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/spot',
testnet: 'wss://stream-testnet.bybit.com/v5/public/spot',
},
},
v5LinearPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/linear',
testnet: 'wss://stream-testnet.bybit.com/v5/public/linear',
},
},
v5InversePublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/inverse',
testnet: 'wss://stream-testnet.bybit.com/v5/public/inverse',
},
},
v5OptionPublic: {
public: {
livenet: 'wss://stream.bybit.com/v5/public/option',
testnet: 'wss://stream-testnet.bybit.com/v5/public/option',
},
},
};
export const WS_KEY_MAP = {
inverse: 'inverse',
linearPrivate: 'linearPrivate',
linearPublic: 'linearPublic',
spotPrivate: 'spotPrivate',
spotPublic: 'spotPublic',
spotV3Private: 'spotV3Private',
spotV3Public: 'spotV3Public',
usdcOptionPrivate: 'usdcOptionPrivate',
usdcOptionPublic: 'usdcOptionPublic',
usdcPerpPrivate: 'usdcPerpPrivate',
usdcPerpPublic: 'usdcPerpPublic',
unifiedPrivate: 'unifiedPrivate',
unifiedOptionPublic: 'unifiedOptionPublic',
unifiedPerpUSDTPublic: 'unifiedPerpUSDTPublic',
unifiedPerpUSDCPublic: 'unifiedPerpUSDCPublic',
contractUSDTPublic: 'contractUSDTPublic',
contractUSDTPrivate: 'contractUSDTPrivate',
contractInversePublic: 'contractInversePublic',
contractInversePrivate: 'contractInversePrivate',
v5SpotPublic: 'v5SpotPublic',
v5LinearPublic: 'v5LinearPublic',
v5InversePublic: 'v5InversePublic',
v5OptionPublic: 'v5OptionPublic',
v5Private: 'v5Private',
} as const;
export const WS_AUTH_ON_CONNECT_KEYS: WsKey[] = [
WS_KEY_MAP.spotV3Private,
WS_KEY_MAP.usdcOptionPrivate,
WS_KEY_MAP.usdcPerpPrivate,
WS_KEY_MAP.unifiedPrivate,
WS_KEY_MAP.contractUSDTPrivate,
WS_KEY_MAP.contractInversePrivate,
WS_KEY_MAP.v5Private,
];
export const PUBLIC_WS_KEYS = [
WS_KEY_MAP.linearPublic,
WS_KEY_MAP.spotPublic,
WS_KEY_MAP.spotV3Public,
WS_KEY_MAP.usdcOptionPublic,
WS_KEY_MAP.usdcPerpPublic,
WS_KEY_MAP.unifiedOptionPublic,
WS_KEY_MAP.unifiedPerpUSDTPublic,
WS_KEY_MAP.unifiedPerpUSDCPublic,
WS_KEY_MAP.contractUSDTPublic,
WS_KEY_MAP.contractInversePublic,
WS_KEY_MAP.v5SpotPublic,
WS_KEY_MAP.v5LinearPublic,
WS_KEY_MAP.v5InversePublic,
WS_KEY_MAP.v5OptionPublic,
] as string[];
/** Used to automatically determine if a sub request should be to the public or private ws (when there's two) */
const PRIVATE_TOPICS = [
'stop_order',
'outboundAccountInfo',
'executionReport',
'ticketInfo',
// copy trading apis
'copyTradePosition',
'copyTradeOrder',
'copyTradeExecution',
'copyTradeWallet',
// usdc options
'user.openapi.option.position',
'user.openapi.option.trade',
'user.order',
'user.openapi.option.order',
'user.service',
'user.openapi.greeks',
'user.mmp.event',
// usdc perps
'user.openapi.perp.position',
'user.openapi.perp.trade',
'user.openapi.perp.order',
'user.service',
// unified margin
'user.position.unifiedAccount',
'user.execution.unifiedAccount',
'user.order.unifiedAccount',
'user.wallet.unifiedAccount',
'user.greeks.unifiedAccount',
// contract v3
'user.position.contractAccount',
'user.execution.contractAccount',
'user.order.contractAccount',
'user.wallet.contractAccount',
// v5
'position',
'execution',
'order',
'wallet',
'greeks',
];
export function isPrivateWsTopic(topic: string): boolean {
return PRIVATE_TOPICS.includes(topic);
}
@@ -416,6 +467,24 @@ export function getWsUrl(
const networkKey = isTestnet ? 'testnet' : 'livenet';
switch (wsKey) {
case WS_KEY_MAP.v5Private: {
return WS_BASE_URL_MAP.v5.private[networkKey];
}
case WS_KEY_MAP.v5PrivateTrade: {
return WS_BASE_URL_MAP[wsKey].private[networkKey];
}
case WS_KEY_MAP.v5SpotPublic: {
return WS_BASE_URL_MAP.v5SpotPublic.public[networkKey];
}
case WS_KEY_MAP.v5LinearPublic: {
return WS_BASE_URL_MAP.v5LinearPublic.public[networkKey];
}
case WS_KEY_MAP.v5InversePublic: {
return WS_BASE_URL_MAP.v5InversePublic.public[networkKey];
}
case WS_KEY_MAP.v5OptionPublic: {
return WS_BASE_URL_MAP.v5OptionPublic.public[networkKey];
}
case WS_KEY_MAP.linearPublic: {
return WS_BASE_URL_MAP.linear.public[networkKey];
}
@@ -474,21 +543,6 @@ export function getWsUrl(
case WS_KEY_MAP.contractUSDTPublic: {
return WS_BASE_URL_MAP.contractUSDT.public[networkKey];
}
case WS_KEY_MAP.v5Private: {
return WS_BASE_URL_MAP.v5.private[networkKey];
}
case WS_KEY_MAP.v5SpotPublic: {
return WS_BASE_URL_MAP.v5SpotPublic.public[networkKey];
}
case WS_KEY_MAP.v5LinearPublic: {
return WS_BASE_URL_MAP.v5LinearPublic.public[networkKey];
}
case WS_KEY_MAP.v5InversePublic: {
return WS_BASE_URL_MAP.v5InversePublic.public[networkKey];
}
case WS_KEY_MAP.v5OptionPublic: {
return WS_BASE_URL_MAP.v5OptionPublic.public[networkKey];
}
default: {
logger.error('getWsUrl(): Unhandled wsKey: ', {
category: 'bybit-ws',
@@ -569,3 +623,13 @@ export function safeTerminateWs(ws?: WebSocket | unknown) {
ws.terminate();
}
}
/**
* WS API promises are stored using a primary key. This key is constructed using
* properties found in every request & reply.
*/
export function getPromiseRefForWSAPIRequest(
requestEvent: WSAPIRequest<unknown>,
): string {
const promiseRef = [requestEvent.op, requestEvent.reqId].join('_');
return promiseRef;
}