@iexec/iapp
Version:
A CLI to guide you through the process of building an iExec iApp
206 lines (190 loc) • 5.07 kB
text/typescript
import WebSocket, { RawData } from 'ws';
import { pack, unpack } from 'msgpackr';
import { debug } from './debug.js';
import { sleep } from './sleep.js';
import {
WS_RECONNECTION_DELAY,
WS_RECONNECTION_MAX_ATTEMPTS,
WS_SERVER_HEARTBEAT_INTERVAL,
} from '../config/config.js';
export type WsMessage = {
type:
| 'NEW_SESSION'
| 'RECOVERED_SESSION'
| 'REQUEST'
| 'RESPONSE'
| 'INFO'
| 'ACK';
/**
* message reception acknowledge code
*/
ack?: number;
/**
* sent with type REQUEST and RESPONSE
*/
target?: string;
/**
* sent with type RESPONSE
*/
success?: boolean;
/**
* sent with type RESPONSE when success: false
*/
error?: string;
/**
* sent with type RESPONSE when success: false
*/
code?: number;
/**
* sent with type RESPONSE when success: true
*/
result?: object;
};
/**
* serialize data to send through a websocket
*/
export function serializeData<T extends WsMessage>(data: T) {
try {
return pack(data);
} catch {
throw Error('Failed to serialize WS data');
}
}
/**
* deserialize data received through a websocket
*/
export function deserializeData<T extends WsMessage>(data: RawData): T {
try {
return unpack(data as Buffer);
} catch {
throw Error('Failed to deserialize WS data');
}
}
export function createReconnectingWs(
host: string,
options: {
/**
* use to register event listeners
*/
connectCallback?: (ws: WebSocket) => void;
/**
* use to perform operation that must be done only once when the session is established
*/
initCallback?: (ws: WebSocket) => void;
/**
* called when session with server is definitely broken
*/
errorCallback?: (err: Error) => void;
/**
* connection headers
*/
headers?: Record<string, string>;
} = {}
) {
const createWs = (sid?: string, failedReconnectCount: number = 0) => {
const {
connectCallback = () => {},
initCallback = () => {},
errorCallback = () => {},
} = options;
const ws: WebSocket & { pingTimeout?: NodeJS.Timeout } = new WebSocket(
host,
sid,
{ handshakeTimeout: 10_000, headers: options.headers }
);
/**
* clean close ws procedure
*/
const teardown = () => {
debug('ws teardown');
ws.removeAllListeners();
ws.terminate();
if (ws.pingTimeout) {
clearTimeout(ws.pingTimeout);
}
};
// setup acknowledge messages mechanism
ws.on('message', (data) => {
try {
const { ack, type } = deserializeData<WsMessage & { ack?: unknown }>(
data
);
if (ack !== undefined && type !== 'ACK') {
ws.send(serializeData({ type: 'ACK', ack }));
debug(`acknowledge ws message ${ack}`);
}
} catch {
// noop
}
});
// create or recover WS session
ws.once('message', (data) => {
const message = deserializeData<WsMessage & { sid?: string }>(data);
debug(`ws message once: ${JSON.stringify(message, undefined, 2)}`);
if (message.type === 'RECOVERED_SESSION') {
connectCallback(ws);
} else if (message.type === 'NEW_SESSION' && message.sid) {
if (sid) {
teardown();
return errorCallback(Error('Failed to recover session with server'));
}
sid = message.sid;
connectCallback(ws);
initCallback(ws);
} else {
teardown();
return errorCallback(Error('Failed to establish session with server'));
}
});
// ensure ws liveness (reset heartbeat on ping)
const heartbeat = () => {
clearTimeout(ws.pingTimeout);
ws.pingTimeout = setTimeout(() => {
debug('ws heartbeat fail');
teardown();
reconnect();
}, 1.5 * WS_SERVER_HEARTBEAT_INTERVAL);
};
const ping = () => {
debug('ws ping');
heartbeat();
};
ws.on('ping', ping)
.on('open', ping)
.on('close', () => {
clearTimeout(ws.pingTimeout);
});
/**
* try reconnecting to ws session
*/
const reconnect = async () => {
debug(`ws try reconnect (count: ${failedReconnectCount})`);
if (failedReconnectCount >= WS_RECONNECTION_MAX_ATTEMPTS) {
return errorCallback(Error('Reconnection to server failed'));
}
// first reconnect attempt occurs immediately
if (failedReconnectCount > 0) {
await sleep(WS_RECONNECTION_DELAY);
}
createWs(sid, failedReconnectCount + 1);
};
// reset retry count when connection is established properly
ws.on('open', () => {
failedReconnectCount = 0;
});
// clean ws and recreate connection on error
ws.on('error', (err) => {
debug(`ws error: ${err}`);
teardown();
reconnect();
});
// clean ws and recreate connection on unexpected close
ws.on('close', (code) => {
debug(`ws close: ${code}`);
if (code !== 1000) {
reconnect();
}
});
};
createWs();
}