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
JavaScript
;
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