UNPKG

tardis-machine

Version:

Locally runnable server with built-in data caching, providing both tick-level historical and consolidated real-time cryptocurrency market data via HTTP and WebSocket APIs

200 lines 8.62 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.replayWS = void 0; const querystring_1 = __importDefault(require("querystring")); const tardis_dev_1 = require("tardis-dev"); const debug_1 = require("../debug"); const helpers_1 = require("../helpers"); const subscriptionsmappers_1 = require("./subscriptionsmappers"); const replaySessions = {}; let sessionsCounter = 0; function replayWS(ws, req) { const parsedQuery = querystring_1.default.decode(req.getQuery()); const from = parsedQuery['from']; const to = parsedQuery['to']; const exchange = parsedQuery['exchange']; // if there are multiple separate ws connections being made for the same session key // in short time frame (5 seconds) // consolidate them in single replay session that will make sure that messages being send via multiple websockets connections // are synchronized by local timestamp const replaySessionKey = parsedQuery['session'] || exchange + sessionsCounter++; let matchingReplaySessionMeta = replaySessions[replaySessionKey] && replaySessions[replaySessionKey]; if (matchingReplaySessionMeta === undefined) { const newReplaySession = new ReplaySession(); replaySessions[replaySessionKey] = newReplaySession; matchingReplaySessionMeta = newReplaySession; newReplaySession.onFinished(() => { replaySessions[replaySessionKey] = undefined; }); } if (matchingReplaySessionMeta.hasStarted) { const message = 'trying to add new WS connection to replay session that already started'; (0, debug_1.debug)(message); ws.end(1011, message); return; } matchingReplaySessionMeta.addToSession(new WebsocketConnection(ws, exchange, from, to)); } exports.replayWS = replayWS; class ReplaySession { _connections = []; _hasStarted = false; constructor() { const SESSION_START_DELAY_MS = 2000; (0, debug_1.debug)('creating new ReplaySession'); setTimeout(() => { this._start(); }, SESSION_START_DELAY_MS); } addToSession(websocketConnection) { if (this._hasStarted) { throw new Error('Replay session already started'); } this._connections.push(websocketConnection); (0, debug_1.debug)('added new connection to ReplaySession, %s', websocketConnection); } get hasStarted() { return this._hasStarted; } _onFinishedCallback = () => { }; async _start() { try { (0, debug_1.debug)('starting ReplaySession, %s', this._connections.join(', ')); this._hasStarted = true; const connectionsWithoutSubscriptions = this._connections.filter((c) => c.subscriptionsCount === 0); if (connectionsWithoutSubscriptions.length > 0) { throw new Error(`No subscriptions received for websocket connection ${connectionsWithoutSubscriptions[0]}`); } // fast path for case when there is only single WS connection for given replay session if (this._connections.length === 1) { const connection = this._connections[0]; const messages = (0, tardis_dev_1.replay)({ ...connection.replayOptions, skipDecoding: true, withDisconnects: false }); for await (const { message } of messages) { const success = connection.ws.send(message); // handle backpressure in case of slow clients if (!success) { while (connection.ws.getBufferedAmount() > 0) { await (0, helpers_1.wait)(1); } } } } else { // map connections to replay messages streams enhanced with addtional ws field so // when we combine streams by localTimestamp we'll know which ws we should send given message via const messagesWithConnections = this._connections.map(async function* (connection) { const messages = (0, tardis_dev_1.replay)({ ...connection.replayOptions, skipDecoding: true, withDisconnects: false }); for await (const { localTimestamp, message } of messages) { yield { ws: connection.ws, localTimestamp: new Date(localTimestamp.toString()), message }; } }); for await (const { ws, message } of (0, tardis_dev_1.combine)(...messagesWithConnections)) { const success = ws.send(message); // handle backpressure in case of slow clients if (!success) { while (ws.getBufferedAmount() > 0) { await (0, helpers_1.wait)(1); } } } } await this._closeAllConnections(); (0, debug_1.debug)('finished ReplaySession with %d connections, %s', this._connections.length, this._connections.map((c) => c.toString())); } catch (e) { (0, debug_1.debug)('received error in ReplaySession, %o', e); await this._closeAllConnections(e); } finally { this._onFinishedCallback(); } } async _closeAllConnections(error = undefined) { for (let i = 0; i < this._connections.length; i++) { const connection = this._connections[i]; if (connection.ws.closed) { continue; } // let's wait until buffer is empty before closing normal connections while (!error && connection.ws.getBufferedAmount() > 0) { await (0, helpers_1.wait)(100); } connection.close(error); } } onFinished(onFinishedCallback) { this._onFinishedCallback = onFinishedCallback; } } class WebsocketConnection { ws; replayOptions; _subscriptionsMapper; subscriptionsCount = 0; constructor(ws, exchange, from, to) { this.ws = ws; this.replayOptions = { exchange, from, to, filters: [] }; if (!subscriptionsmappers_1.subscriptionsMappers[exchange]) { throw new Error(`Exchange ${exchange} is not supported via /ws-replay Websocket API, please use HTTP streaming API instead.`); } this._subscriptionsMapper = subscriptionsmappers_1.subscriptionsMappers[exchange]; this.ws.onmessage = this._convertSubscribeRequestToFilter.bind(this); } close(error = undefined) { if (this.ws.closed) { return; } if (error) { (0, debug_1.debug)('Closed websocket connection %s, error: %o', this, error); this.ws.end(1011, error.toString()); } else { (0, debug_1.debug)('Closed websocket connection %s', this); this.ws.end(1000, 'WS replay finished'); } } toString() { return `${JSON.stringify(this.replayOptions)}`; } _convertSubscribeRequestToFilter(messageRaw) { const message = Buffer.from(messageRaw).toString(); try { const messageDeserialized = JSON.parse(message); if (this._subscriptionsMapper.canHandle(messageDeserialized)) { // if there is a subscribe message let's map it to filters and add those to replay options const filters = this._subscriptionsMapper.map(messageDeserialized); (0, debug_1.debug)('Received subscribe websocket message: %s, mapped filters: %o', message, filters); this.replayOptions.filters.push(...filters); this.subscriptionsCount++; } else { (0, debug_1.debug)('Ignored websocket message %s', message); } } catch (e) { console.error('convertSubscribeRequestToFilter Error', e); (0, debug_1.debug)('Ignored websocket message %s, error %o', message, e); } } } //# sourceMappingURL=replay.js.map