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
text/typescript
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 {}