@hackape/tardis-dev
Version:
Convenient access to tick-level historical and real-time cryptocurrency market data via Node.js
379 lines (304 loc) • 11 kB
text/typescript
import dbg from 'debug'
import WebSocket from 'ws'
import { PassThrough, Writable } from 'stream'
import { once } from 'events'
import { ONE_SEC_IN_MS, optimizeFilters, wait } from '../handy'
import { Exchange, Filter } from '../types'
export type RealTimeFeed = {
new (
exchange: Exchange,
filters: Filter<string>[],
timeoutIntervalMS: number | undefined,
onError?: (error: Error) => void
): RealTimeFeedIterable
}
let connectionCounter = 1
export type RealTimeFeedIterable = AsyncIterable<any>
export abstract class RealTimeFeedBase implements RealTimeFeedIterable {
[Symbol.asyncIterator]() {
return this._stream()
}
protected readonly debug: dbg.Debugger
protected abstract readonly wssURL: string
protected readonly throttleSubscribeMS: number = 0
protected readonly manualSnapshotsBuffer: any[] = []
private readonly _filters: Filter<string>[]
private _receivedMessagesCount = 0
private _ws?: WebSocket
private _connectionId = connectionCounter++
constructor(
protected readonly _exchange: string,
filters: Filter<string>[],
private readonly _timeoutIntervalMS: number | undefined,
private readonly _onError?: (error: Error) => void
) {
this._filters = optimizeFilters(filters)
this.debug = dbg(`tardis-dev:realtime:${_exchange}`)
}
private async *_stream() {
let staleConnectionTimerId
let pingTimerId
let retries = 0
while (true) {
try {
const subscribeMessages = this.mapToSubscribeMessages(this._filters)
this.debug('(connection id: %d) estabilishing connection to %s', this._connectionId, this.wssURL)
this.debug(
'(connection id: %d) provided filters: %o mapped to subscribe messages: %o',
this._connectionId,
this._filters,
subscribeMessages
)
this._ws = new WebSocket(this.wssURL, { perMessageDeflate: false, handshakeTimeout: 10 * ONE_SEC_IN_MS })
this._ws.onopen = this._onConnectionEstabilished
this._ws.onclose = this._onConnectionClosed
staleConnectionTimerId = this._monitorConnectionIfStale()
pingTimerId = this._sendPeriodicPing()
const realtimeMessagesStream = (WebSocket as any).createWebSocketStream(this._ws, {
readableObjectMode: true, // othwerwise we may end up with multiple messages returned by stream in single iteration
readableHighWaterMark: 8096 // since we're in object mode, let's increase hwm a little from default of 16 messages buffered
}) as AsyncIterableIterator<Buffer>
for await (let message of realtimeMessagesStream) {
if (this.decompress !== undefined) {
message = this.decompress(message)
}
const messageDeserialized = JSON.parse(message as any)
if (this.messageIsError(messageDeserialized)) {
throw new Error(`Received error message:${message.toString()}`)
}
// exclude heaartbeat messages from received messages counter
// connection could still be stale even if only heartbeats are provided without any data
if (this.messageIsHeartbeat(messageDeserialized) === false) {
this._receivedMessagesCount++
}
this.onMessage(messageDeserialized)
yield messageDeserialized
if (retries > 0) {
// reset retries counter as we've received correct message from the connection
retries = 0
}
if (this.manualSnapshotsBuffer.length > 0) {
for (let snapshot of this.manualSnapshotsBuffer) {
yield snapshot
}
this.manualSnapshotsBuffer.length = 0
}
}
// clear monitoring connection timer and notify about disconnect
if (staleConnectionTimerId !== undefined) {
clearInterval(staleConnectionTimerId)
}
yield undefined
} catch (error) {
if (this._onError !== undefined) {
this._onError(error)
}
retries++
const MAX_DELAY = 16 * 1000
const isRateLimited = error.message.includes('429')
let delay
if (isRateLimited) {
delay = MAX_DELAY * retries
} else {
delay = Math.pow(2, retries - 1) * 1000
if (delay > MAX_DELAY) {
delay = MAX_DELAY
}
}
this.debug(
'(connection id: %d) %s real-time feed connection error, retries count: %d, next retry delay: %dms, rate limited: %s error message: %o',
this._connectionId,
this._exchange,
retries,
delay,
isRateLimited,
error
)
// clear monitoring connection timer and notify about disconnect
if (staleConnectionTimerId !== undefined) {
clearInterval(staleConnectionTimerId)
}
yield undefined
await wait(delay)
} finally {
// stop timers
if (staleConnectionTimerId !== undefined) {
clearInterval(staleConnectionTimerId)
}
if (pingTimerId !== undefined) {
clearInterval(pingTimerId)
}
}
}
}
protected send(msg: any) {
if (this._ws === undefined) {
return
}
if (this._ws.readyState !== WebSocket.OPEN) {
return
}
this._ws.send(JSON.stringify(msg))
}
protected abstract mapToSubscribeMessages(filters: Filter<string>[]): any[]
protected abstract messageIsError(message: any): boolean
protected messageIsHeartbeat(_msg: any) {
return false
}
protected async provideManualSnapshots(_filters: Filter<string>[], _shouldCancel: () => boolean) {}
protected onMessage(_msg: any) {}
protected onConnected() {}
protected decompress?: (msg: any) => Buffer
private _monitorConnectionIfStale() {
if (this._timeoutIntervalMS === undefined || this._timeoutIntervalMS === 0) {
return
}
// set up timer that checks against open, but stale connections that do not return any data
return setInterval(() => {
if (this._ws === undefined) {
return
}
if (this._receivedMessagesCount === 0) {
this.debug(
'(connection id: %d) did not received any messages within %d ms timeout, terminating connection...',
this._connectionId,
this._timeoutIntervalMS
)
this._ws!.terminate()
}
this._receivedMessagesCount = 0
}, this._timeoutIntervalMS)
}
private _sendPeriodicPing() {
return setInterval(() => {
if (this._ws === undefined || this._ws.readyState !== WebSocket.OPEN) {
return
}
this._ws.ping()
}, 5 * ONE_SEC_IN_MS)
}
private _onConnectionEstabilished = async () => {
try {
const subscribeMessages = this.mapToSubscribeMessages(this._filters)
let symbolsCount = this._filters.reduce((prev, curr) => {
if (curr.symbols !== undefined) {
for (const symbol of curr.symbols) {
prev.add(symbol)
}
}
return prev
}, new Set<string>()).size
for (const message of subscribeMessages) {
this.send(message)
if (this.throttleSubscribeMS > 0) {
await wait(this.throttleSubscribeMS)
}
}
this.debug('(connection id: %d) estabilished connection', this._connectionId)
this.onConnected()
//wait before fetching snapshots until we're sure we've got proper connection estabilished (received some messages)
while (this._receivedMessagesCount < symbolsCount * 2) {
await wait(100)
}
// wait a second just in case before starting fetching the snapshots
await wait(1 * ONE_SEC_IN_MS)
if (this._ws!.readyState === WebSocket.CLOSED) {
return
}
await this.provideManualSnapshots(this._filters, () => this._ws!.readyState === WebSocket.CLOSED)
} catch (e) {
this.debug('(connection id: %d) providing manual snapshots error: %o', this._connectionId, e)
this._ws!.emit('error', e)
}
}
private _onConnectionClosed = () => {
this.debug('(connection id: %d) connection closed', this._connectionId)
}
}
export abstract class MultiConnectionRealTimeFeedBase implements RealTimeFeedIterable {
constructor(
private readonly _exchange: string,
private readonly _filters: Filter<string>[],
private readonly _timeoutIntervalMS: number | undefined,
private readonly _onError?: (error: Error) => void
) {}
[Symbol.asyncIterator]() {
return this._stream()
}
private async *_stream() {
const combinedStream = new PassThrough({
objectMode: true,
highWaterMark: 8096
})
const realTimeFeeds = this._getRealTimeFeeds(this._exchange, this._filters, this._timeoutIntervalMS, this._onError)
for (const realTimeFeed of realTimeFeeds) {
// iterate over separate real-time feeds and write their messages into combined stream
;(async function writeMessagesToCombinedStream() {
for await (const message of realTimeFeed) {
if (combinedStream.destroyed) {
return
}
if (!combinedStream.write(message))
// Handle backpressure on write
await once(combinedStream, 'drain')
}
})()
}
for await (const message of combinedStream) {
yield message
}
}
protected abstract _getRealTimeFeeds(
exchange: string,
filters: Filter<string>[],
timeoutIntervalMS?: number,
onError?: (error: Error) => void
): IterableIterator<RealTimeFeedIterable>
}
export abstract class PoolingClientBase implements RealTimeFeedIterable {
protected readonly debug: dbg.Debugger
private _tid: NodeJS.Timeout | undefined = undefined
constructor(exchange: string, private readonly _poolingIntervalSeconds: number) {
this.debug = dbg(`tardis-dev:pooling-client:${exchange}`)
}
[Symbol.asyncIterator]() {
return this._stream()
}
protected abstract poolDataToStream(outputStream: Writable): Promise<void>
private async _startPooling(outputStream: Writable) {
const timeoutInterval = this._poolingIntervalSeconds * ONE_SEC_IN_MS
const pool = async () => {
try {
await this.poolDataToStream(outputStream)
} catch (e) {
this.debug('pooling error %o', e)
}
}
const poolAndSchedule = () => {
pool().then(() => {
if (!outputStream.destroyed) {
this._tid = setTimeout(poolAndSchedule, timeoutInterval)
}
})
}
poolAndSchedule()
}
private async *_stream() {
const stream = new PassThrough({
objectMode: true,
highWaterMark: 1024
})
this._startPooling(stream)
this.debug('pooling started')
try {
for await (const message of stream) {
yield message
}
} finally {
if (this._tid !== undefined) {
clearInterval(this._tid)
}
this.debug('pooling finished')
}
}
}