UNPKG

metaapi.cloud-sdk

Version:

SDK for MetaApi, a professional cloud forex API which includes MetaTrader REST API and MetaTrader websocket API. Supports both MetaTrader 5 (MT5) and MetaTrader 4 (MT4). CopyFactory copy trading API included. (https://metaapi.cloud)

345 lines (320 loc) 12.4 kB
import * as lrap from '../long-running-async-process'; import ioClient from 'socket.io-client'; import ClientStickySocket from './clientStickySocket'; import socketWildcard from '../socket.io-wildcard'; import { BINARY_EVENT, ClientSocketDisconnectReason, EVENT, EmitHistoryPacket, EmitPacket, RESTORE_CONNECTION_EVENT, RestoreConnectionRequest, RestoreConnectionResponse, SocketOptions } from './common.types'; import {getClientSocketIoTransportName, sendHistory} from './common.utils'; import EventEmitter from '../../tools/eventEmitter'; import LoggerManager from '../../logger'; import Queue from '../../tools/queue'; /** * A socket connection */ class StickySocketConnection extends lrap.RootProcess { private _options?: StickySocketConnection.Options; private _socket: SocketIOClient.Socket; private _client: ClientStickySocket; private _sharedState: StickySocketConnection.SharedState; private _internalEvents: EventEmitter<StickySocketConnection.SharedEvents>; private _disconnectReason?: ClientSocketDisconnectReason; private _logger = LoggerManager.getLogger(StickySocketConnection.name); private _prevStageListeners: {[event: string]: Set<(...args: any[]) => void>} = {}; private _label: string; /** * Returns native socket * @returns socket.io socket */ get socket(): SocketIOClient.Socket { return this._socket; } /** * Returns current transport name, using socket.io internals * @returns transport name, e.g. "websocket" */ get transportName(): string { return getClientSocketIoTransportName(this._socket); } /** * Returns first present packet index in last emit history * @returns first history index * @internal intended for tests only */ get firstHistoryIndex(): number | undefined { return this._sharedState.emitHistory.front()?.index; } /** * @inheritdoc * @param client parent client * @param internalEvents internal event emitter */ inject(client: ClientStickySocket, internalEvents: EventEmitter<StickySocketConnection.SharedEvents>): void { this._client = client; this._internalEvents = internalEvents; } /** * @inheritdoc * @param url url to connect to * @param sessionId session ID * @param sharedState shared state * @param options additiona options */ initialize( url: string, sessionId: string, sharedState: StickySocketConnection.SharedState, options?: StickySocketConnection.Options ) { this._options = options; this._label = options?.label || 'default'; this._sharedState = sharedState; this._sharedState.startCount++; let query = { ...options?.connection?.query, stickySocketConnectionId: sessionId } as Record<string, any>; if (sharedState.startCount > 1) { let request: RestoreConnectionRequest = { lastReceivedIndex: sharedState.lastReceivedIndex, lastSentIndex: sharedState.lastSentIndex, firstHistoryIndex: this._sharedState.emitHistory.front()?.index, sessionId }; this._logger.debug(`${this._label}: restoring session`, JSON.stringify(request)); query.restoreStickyConnection = encodeURIComponent(JSON.stringify(request)); } this._socket = ioClient(url, { ...options?.connection, reconnection: false, autoConnect: false, query }); socketWildcard(ioClient.Manager)(this._socket); this._socket.on('*', packet => { if (packet.type === EVENT || packet.type === BINARY_EVENT) { let payload = packet.data[1]; if (this._options?.useNativeSocketIoServer) { this._client.emit(packet.data[0], ...packet.data.slice(1)); return; } if ('index' in payload) { this._logger.trace(() => `${this._label}: received packet ` + JSON.stringify({index: payload.index})); this._sharedState.lastReceivedIndex = Math.max(this._sharedState.lastReceivedIndex ?? -1, payload.index); this._client.emit(packet.data[0], ...payload.data); } } }); } /** * Emits a data event. All events are buffered if no connection * @param event The event that we're emitting * @param args Optional arguments to send with the event */ send(event: string, ...args: any[]) { if (this._options?.useNativeSocketIoServer) { this._socket.emit(event, ...args); return; } let packet: EmitHistoryPacket = { index: ++this._sharedState.lastSentIndex, event, data: args, time: new Date() }; this._sharedState.emitHistory.push(packet); this._socket.emit(event, { index: packet.index, data: packet.data } satisfies EmitPacket); } /** * @inheritdoc */ async start(stopPromise: lrap.HandlePromise<void>) { try { let connectPromise = this._connect(stopPromise); let restorePromise = this._restoreConnectionIfNeeded(stopPromise); let [_, restored] = await Promise.all([connectPromise, restorePromise]); if (restored) { this._logger.info(`${this._label}: restored connection session`); } } catch (err) { this._logger.warn(`${this._label}: failed to connect`, err); if (this._sharedState.startCount === 1 || err instanceof RestoreRejectError) { this._client.disconnect(err); } throw new lrap.ControlSignal({action: 'failover', severity: 'info'}); } } private _connect(stopPromise: lrap.HandlePromise<void>) { this._socket.connect(); return new Promise<void>((resolve, reject) => { stopPromise.then(() => reject(new Error('Stopped during connection'))); this._setSocketStageListener('connect', () => { // Emitting connect immediately to guarantee no data events will be received until connect event emitted this._logger.debug(`${this._label}: internal socket connected`); this._internalEvents.emit('connect'); resolve(); }); this._setSocketStageListener('disconnect', (reason: ClientSocketDisconnectReason) => { this._disconnectReason = reason; reject(new Error(`Disconnected when connecting due to ${reason}`)); }); this._setSocketStageListener('error', err => reject(err)); this._setSocketStageListener('connect_error', err => reject(err)); this._setSocketStageListener('connect_timeout', err => reject(err)); }); } private async _restoreConnectionIfNeeded(stopPromise: lrap.HandlePromise<void>): Promise<boolean> { if (this._sharedState.startCount === 1) { return false; } let sendSinceIndex = await new Promise<number>((resolve, reject) => { stopPromise.then(() => reject(new Error('Stopped during restoring connection'))); this._setSocketStageListener(RESTORE_CONNECTION_EVENT, (event: RestoreConnectionResponse) => { event.restored ? resolve(event.sendSinceIndex) : reject(new RestoreRejectError('Cannot restore connection session')); }); this._setSocketStageListener('disconnect', (reason: string) => { reject(new Error(`Disconnected when restoring connection due to ${reason}`)); }); this._setSocketStageListener('error', err => reject(err)); this._setSocketStageListener('connect_error', err => reject(err)); this._setSocketStageListener('connect_timeout', err => reject(err)); }); this._logger.debug(`${this._label}: sending history since ${sendSinceIndex} packet index`); sendHistory(this._socket, this._sharedState.emitHistory, sendSinceIndex); return true; } /** * @inheritdoc */ async run(stopPromise: lrap.HandlePromise<void>): Promise<void> { const historyBufferTtlInMs = 1000 * (this._options?.emitHistoryTtlInSeconds ?? 10); let clearOldHistoryInterval = setInterval(() => this.removeExpiredEmitHistory(), historyBufferTtlInMs); try { if (this._disconnectReason) { if (!this._tryDisconnectClientGracefully(this._disconnectReason)) { throw new Error(`Disconnected due to ${this._disconnectReason}`); } return; } this._removePrevStageListeners(); await Promise.race([stopPromise, new Promise<void>((resolve, reject) => { this._setSocketStageListener('disconnect', (reason: ClientSocketDisconnectReason) => { if (!this._tryDisconnectClientGracefully(reason)) { reject(new Error(`Disconnected due to ${reason}`)); } }); this._setSocketStageListener('error', reject); })]); } catch (err) { this._logger.warn(`${this._label}: lost connection`, err); this._internalEvents.emit('fail', err); throw new lrap.ControlSignal({action: 'failover', severity: 'info'}); } finally { this._removePrevStageListeners(); clearInterval(clearOldHistoryInterval); } } /** * Removes expired emit history */ removeExpiredEmitHistory() { const historyBufferTtlInMs = 1000 * (this._options?.emitHistoryTtlInSeconds ?? 10); while (this._sharedState.emitHistory.length) { if (Date.now() - this._sharedState.emitHistory.front().time.getTime() > historyBufferTtlInMs) { this._sharedState.emitHistory.shift(); } else { break; } } } private _tryDisconnectClientGracefully(reason: ClientSocketDisconnectReason): boolean { if (reason === 'io client disconnect' || reason === 'io server disconnect') { this._logger.info(`${this._label}: disconnecting client due to ${reason}`); this._client.disconnect(); return true; } return false; } /** * @inheritdoc */ async stop() { this._socket.disconnect(); } private _setSocketStageListener<T extends (...args: any[]) => void>(event: string, listener: T): T { this._prevStageListeners[event] ||= new Set(); this._prevStageListeners[event].add(listener); this._socket.on(event, listener); return listener; } private _removePrevStageListeners() { for (let [event, listeners] of Object.entries(this._prevStageListeners)) { for (let listener of listeners) { this._socket.off(event, listener); } } } } namespace StickySocketConnection { /** Options */ export type Options = SocketOptions & { /** Logging label to identify this instance. Defaults to `default` */ label?: string; /** Native socket IO options */ connection?: Pick< SocketIOClient.ConnectOpts, 'path' | 'timeout' | 'query' > & { /** * Headers that will be passed for each request to the server (via xhr-polling and via websockets). These values * then can be used during handshake or for special proxies */ extraHeaders?: Record<string, string>; }; /** * Adjusts compatibility to work in mode when a native socket.io server is used. In this mode the socket will not * try to restore connection and will send packets as is. Intended for tests where native socket.io server is used */ useNativeSocketIoServer?: boolean; }; /** Shared state */ export type SharedState = { /** Count of times a connection is started */ startCount: number; /** Emit */ emitHistory: Queue<EmitHistoryPacket>; /** Last sent index. Starts from `0`. If no packets were sent yet, defaults to `-1` */ lastSentIndex: number; /** Last received packet index. Starts from `0`. If no packets were received, defaults to `-1` */ lastReceivedIndex: number; }; /** Shared events for intermediate state */ export type SharedEvents = { /** Connected event */ connect: () => void; /** Lost connection event */ fail: (err: Error) => void; }; /** Connection events */ export const CONNECTION_EVENTS = ['connect', 'disconnect']; /** Sticky connection protocol events */ export const PROTOCOL_EVENTS = [RESTORE_CONNECTION_EVENT]; /** Internal events */ export const INTERNAL_EVENTS = [ 'error', 'connect_error', 'connect_timeout', 'reconnect', 'reconnect_attempt', 'reconnecting', 'reconnect_error', 'reconnect_failed', 'ping', 'pong', ...PROTOCOL_EVENTS ]; } export default StickySocketConnection; /** * Error, meaning that the server rejected the restore request. E.g. server may have already * removed old emit history, required by the client, so the restoring cannot be done */ class RestoreRejectError extends Error {}