UNPKG

okx-v5-ws

Version:

This is a non-official OKX V5 websocket SDK for nodejs.

585 lines (523 loc) 19.5 kB
import hmacSHA256 from 'crypto-js/hmac-sha256' import Base64 from 'crypto-js/enc-base64' import { WSConnector } from './WSConnector' import { ErrorCodes } from './ErrorCodes' import { v4 as uuidv4 } from 'uuid' import { normalizeSubscriptionTopic } from './util' import EventEmitter from 'events' /** * Provide service level function to user */ class OkxV5Ws { #serverBaseUrl: string #profileConfig?: { apiKey: string secretKey: string passPhrase: string } #options: { autoLogin: boolean logLoginMessage: boolean logSubscriptionMessage: boolean logChannelTopicMessage: boolean logTradeMessage: boolean } // ws connection handling instance #wsConnector: WSConnector // events connector #eventEmitter = new EventEmitter() /** * Queues for piping operation one by one */ #operationQueue: OperationQueueItem[] = [] /** * Queues for handling response for event types */ #loginReqs: HandleResponse<LoginResponse>[] = [] #subChannelReqs: (HandleResponse<SubscriptionResponse> | HandleResponse<UnsubscriptionResponse>)[] = [] #tradeReqsMap = new Map<string, Map<string, HandleResponse<TradeResponse>[]>>() #channelTopicMessageHandlersMap = new Map<string, ChannelMessageHandler[]>() /** * map of Trade OP codes */ static #tradeOps = { order: true, 'batch-orders': true, 'cancel-order': true, 'batch-cancel-orders': true, 'amend-order': true, 'batch-amend-orders': true, } static PUBLIC_ENDPOINT = 'wss://wsaws.okx.com:8443/ws/v5/public' static PRIVATE_ENDPOINT = 'wss://wsaws.okx.com:8443/ws/v5/private' static DEMO_PUBLIC_ENDPOINT = 'wss://wspap.okx.com:8443/ws/v5/public?brokerId=9999' static DEMO_PRIVATE_ENDPOINT = 'wss://wspap.okx.com:8443/ws/v5/private?brokerId=9999' /** * constructor */ constructor({ serverBaseUrl, profileConfig, options, messageHandler, }: { serverBaseUrl: string profileConfig?: { apiKey: string secretKey: string passPhrase: string } options?: { autoLogin?: boolean logLoginMessage?: boolean logSubscriptionMessage?: boolean logChannelTopicMessage?: boolean logTradeMessage?: boolean } messageHandler?: (message: string) => any }) { this.#serverBaseUrl = serverBaseUrl this.#profileConfig = profileConfig this.#options = { autoLogin: options?.autoLogin ?? true, logLoginMessage: options?.logLoginMessage ?? true, logSubscriptionMessage: options?.logSubscriptionMessage ?? true, logChannelTopicMessage: options?.logChannelTopicMessage ?? false, logTradeMessage: options?.logTradeMessage ?? true, } /** * initialize WSConnector */ this.#wsConnector = new WSConnector({ serverBaseUrl: this.#serverBaseUrl, afterConnected: this.#afterConnected, }) /** * connecting the events */ this.#wsConnector.event.on('message', this.#onMessage) this.#wsConnector.event.on('connect', (code: number, desc: string) => { this.#eventEmitter.emit('connect', code, desc) }) this.#wsConnector.event.on('reconnecting', () => { this.#eventEmitter.emit('reconnecting') }) this.#wsConnector.event.on('close', (code: number, desc: string) => { this.#eventEmitter.emit('close', code, desc) }) this.#wsConnector.event.on('closed', (code: number, desc: string) => { this.#eventEmitter.emit('closed', code, desc) }) this.#wsConnector.event.on('error', (error) => this.#eventEmitter.emit('error', error)) if (messageHandler) { console.warn('messageHandler is deprecated. Please use okxV5Ws.event on message event') this.#eventEmitter.on('message', messageHandler) } } /** * Get the event emitter */ get event() { return this.#eventEmitter } /** * start connecting to server */ async connect() { await this.#wsConnector.connect() } /** * sending message payload to server * * @param payload */ async send(payload: object) { this.#checkConnection() await this.#wsConnector.send(JSON.stringify(payload)) } /** * subscribe channel topic * * @param subscriptionTopic * @returns */ async subscribeChannel(subscriptionTopic: SubscriptionTopic): Promise<SubscriptionResponse> { return this.#waitOperationQueue<SubscriptionResponse>(this.#operationQueue, 'subscribe', async () => { const topic = normalizeSubscriptionTopic(subscriptionTopic) console.log(`subscribe channel ${JSON.stringify(topic)}`) this.#checkConnection() return new Promise<SubscriptionResponse>((resolve, reject) => { this.send({ op: 'subscribe', args: [topic], }) this.#subChannelReqs.push({ resolve, reject }) }) }).catch((e) => { console.error(e) throw e }) } /** * unsubscribe channel topic * * @param subscriptionTopic * @returns */ async unsubscribeChannel(subscriptionTopic: SubscriptionTopic): Promise<UnsubscriptionResponse> { return this.#waitOperationQueue<UnsubscriptionResponse>(this.#operationQueue, 'unsubscribe', async () => { const topic = normalizeSubscriptionTopic(subscriptionTopic) console.log(`unsubscribe channel ${JSON.stringify(topic)}`) this.#checkConnection() return new Promise<UnsubscriptionResponse>((resolve, reject) => { this.send({ op: 'unsubscribe', args: [topic], }) this.#subChannelReqs.push({ resolve, reject }) }) }).catch((e) => { console.error(e) throw e }) } /** * Add channel topic message handler * * @param subscriptionTopic * @param channelMessageHandler */ addChannelMessageHandler(subscriptionTopic: SubscriptionTopic, channelMessageHandler: ChannelMessageHandler) { const topic = normalizeSubscriptionTopic(subscriptionTopic) console.log(`add ChannelMessageHandler for ${JSON.stringify(topic)}`) const key = JSON.stringify(topic) let messageHandlers = this.#channelTopicMessageHandlersMap.get(key) if (!messageHandlers) { messageHandlers = [channelMessageHandler] this.#channelTopicMessageHandlersMap.set(key, messageHandlers) } else { messageHandlers.push(channelMessageHandler) } } /** * Remove channel topic message handler * * @param subscriptionTopic * @param channelMessageHandler */ removeChannelMessageHandler(subscriptionTopic: SubscriptionTopic, channelMessageHandler: ChannelMessageHandler) { const topic = normalizeSubscriptionTopic(subscriptionTopic) console.log(`remove ChannelMessageHandler for ${JSON.stringify(topic)}`) const key = JSON.stringify(topic) let messageHandlers = this.#channelTopicMessageHandlersMap.get(key) if (messageHandlers) { messageHandlers = messageHandlers.filter((handler) => handler !== channelMessageHandler) if (messageHandlers.length > 0) { this.#channelTopicMessageHandlersMap.set(key, messageHandlers) } else { this.#channelTopicMessageHandlersMap.delete(key) } } } /** * Remove that channel topic's ALL message handler * * @param subscriptionTopic */ removeAllChannelMessageHandler(subscriptionTopic: SubscriptionTopic) { const topic = normalizeSubscriptionTopic(subscriptionTopic) console.log(`removeAll ChannelMessageHandler for ${JSON.stringify(topic)}`) const key = JSON.stringify(topic) this.#channelTopicMessageHandlersMap.delete(key) } /** * Send trade op message * * @param payload * @returns */ async trade(payload: TradePayload): Promise<TradeResponse> { const op = payload.op if (!OkxV5Ws.#tradeOps[op]) { throw new Error('Unknown OP') } // add id if omitted if (payload.id === undefined) { payload = { id: uuidv4().replaceAll('-', ''), ...payload } } // append req queue and wait for reply let opReqsMap = this.#tradeReqsMap.get(op) if (opReqsMap === undefined) { opReqsMap = new Map<string, HandleResponse<TradeResponse>[]>() this.#tradeReqsMap.set(op, opReqsMap) } let opReqs = opReqsMap.get(payload.id!) if (opReqs === undefined) { opReqs = new Array<HandleResponse<TradeResponse>>() } opReqsMap.set(payload.id!, opReqs) return new Promise<TradeResponse>((resolve, reject) => { this.send(payload) opReqs!.push({ resolve, reject }) }).catch((e) => { console.error(e) throw e }) } /** * Waiting the operation in a single processing queue * @param operationQueue * @param op * @param process * @returns */ async #waitOperationQueue<T>(operationQueue: OperationQueueItem[], op: string, process: () => Promise<T>): Promise<T> { return new Promise<T>((resolve, reject) => { const isEmptyQueue = operationQueue.length === 0 operationQueue.push({ op, execute: async () => { return process() .then((data: T) => { resolve(data) }) .catch((e) => { reject(e) }) .finally(() => { return this.#pullNextOperation(operationQueue) }) }, }) if (isEmptyQueue) { operationQueue[0].execute() } }) } /** * Remove the first item in queue. And pull next operation to run. * @param operationQueue * @returns */ async #pullNextOperation(operationQueue: OperationQueueItem[]): Promise<void> { if (operationQueue.length === 0) { return } operationQueue.shift() // remove the first one as it is finished if (operationQueue.length > 0) { await operationQueue[0].execute() // trigger next return } return } /** * check connection connected */ #checkConnection() { if (!this.#wsConnector.connected) { throw new Error('Connection not available') } } /** * Do login authentication handshake */ async #authentication(): Promise<LoginResponse> { return this.#waitOperationQueue<LoginResponse>(this.#operationQueue, 'login', async () => { this.#checkConnection() const timestamp = ('' + Date.now()).slice(0, -3) const payload = `${timestamp}GET/users/self/verify` const sign = Base64.stringify(hmacSHA256(payload, this.#profileConfig?.secretKey ?? '')) return new Promise<LoginResponse>((resolve, reject) => { this.send({ op: 'login', args: [ { apiKey: this.#profileConfig?.apiKey ?? '', passphrase: this.#profileConfig?.passPhrase ?? '', timestamp: timestamp, sign: sign, }, ], }) this.#loginReqs.push({ resolve, reject, }) }) }) } /** * After-connected-logic, probably do auto login */ #afterConnected = async () => { // reset queues this.#operationQueue = [] this.#loginReqs = [] this.#subChannelReqs = [] this.#tradeReqsMap = new Map<string, Map<string, HandleResponse<TradeResponse>[]>>() this.#channelTopicMessageHandlersMap = new Map<string, ChannelMessageHandler[]>() if (this.#options.autoLogin && this.#profileConfig?.apiKey) { await this.#authentication() } } /** * Handle when receiving server side messages * @param message * @returns */ #onMessage = (message: string) => { this.#eventEmitter.emit('message', message) if (message === 'pong') { return } let messageObj: any = null try { messageObj = JSON.parse(message) let eventType = messageObj?.event // channel messages if (!eventType && messageObj?.arg?.channel) { const topicKey = JSON.stringify(messageObj.arg) const handlers = this.#channelTopicMessageHandlersMap.get(topicKey) if (this.#options.logChannelTopicMessage) { console.debug(`Received Topic Msg: '${message}'`) } if (handlers) { for (const handler of handlers) { handler(messageObj) } } return } const code: string = (messageObj?.code as string) || '' let isError = false if (typeof messageObj?.code === 'string') { isError = messageObj?.code !== '0' } else if (messageObj?.event === 'error') { isError = true } const msg: string = (messageObj?.msg as string) ?? '' const op = messageObj?.op ?? '' if (isError && code === ErrorCodes.INVALID_REQUEST) { const match = messageObj.msg?.match(/Invalid request: {"op": "([a-zA-Z0-9\\-]+)"/) if (match && match[1]) { eventType = match[1] } } /** * Some common error for login operation */ if ( isError && (code === ErrorCodes.LOGIN_FAILED || code === ErrorCodes.BULK_LOGIN_PARTIALLY_SUCCEEDED || code === ErrorCodes.INVALID_SIGN || code === ErrorCodes.INVALID_OK_ACCESS_KEY) ) { eventType = 'login' } // channel subscribe -> does not exist if (isError && code === ErrorCodes.DOES_NOT_EXIST && msg.startsWith('channel:')) { eventType = 'subscribe' } // channel not supported for public/private error if (isError && code === ErrorCodes.ENDPOINT_NOT_SUPPORT_SUBSCRIBE_CHANNEL) { eventType = 'subscribe' } // cannot interpret as error actual event type if (isError && eventType === 'error') { if (this.#operationQueue.length > 0) { eventType = this.#operationQueue[0].op } } // op code for trade operations if (op && OkxV5Ws.#tradeOps[op]) { eventType = 'trade' } /** * Handle for "login" message response */ if (eventType === 'login') { if (this.#options.logLoginMessage) { console.debug(`Received Login response: '${message}'`) } const response = this.#loginReqs.shift() if (response) { if (isError) { response.reject(messageObj) } else { response.resolve(messageObj) } } return } /** * Handle for "subscribe" message response */ if (eventType === 'subscribe') { if (this.#options.logLoginMessage) { console.debug(`Received Sub response: '${message}'`) } const response = this.#subChannelReqs.shift() if (response) { if (isError) { response.reject(messageObj) } else { response.resolve(messageObj) } } return } /** * Handle for "unsubscribe" message response */ if (eventType === 'unsubscribe') { if (this.#options.logLoginMessage) { console.debug(`Received Unsub response: '${message}'`) } const response = this.#subChannelReqs.shift() if (response) { if (isError) { response.reject(messageObj) } else { response.resolve(messageObj) } } return } /** * Handle for "trade" message response */ if (eventType === 'trade') { const id = messageObj.id if (this.#options.logTradeMessage) { console.debug(`Received Trade response for op ${op}, id ${id}: '${message}'`) } if (id) { const opReqsMap = this.#tradeReqsMap.get(op) const responses = opReqsMap?.get(id) const response = responses?.shift() if (responses?.length === 0) { opReqsMap?.delete(id) } if (response) { if (isError) { response.reject(messageObj) } else { response.resolve(messageObj) } } } return } } catch (e) { console.error(e) } } /** * close connection */ close() { this.#wsConnector.close() } } export { OkxV5Ws }