Files
trezor-suite/packages/integration-tests/websocket-client.js
2022-07-19 11:30:05 +02:00

238 lines
6.3 KiB
JavaScript

/* eslint-disable no-await-in-loop,no-async-promise-executor */
const WebSocket = require('ws');
const { EventEmitter } = require('events');
const { createDeferred } = require('@trezor/utils');
const NOT_INITIALIZED = new Error('websocket_not_initialized');
// Making the timeout high because the controller in trezor-user-env
// must synchronously run actions on emulator and they may take a long time
// (for example in case of Shamir backup)
const DEFAULT_TIMEOUT = 5 * 60 * 1000;
const DEFAULT_PING_TIMEOUT = 50 * 1000;
class Controller extends EventEmitter {
constructor(options = {}) {
super();
this.messageID = 0;
this.messages = [];
this.subscriptions = [];
this.setMaxListeners(Infinity);
this.options = {
...options,
url: options.url || 'ws://localhost:9001/',
};
}
setConnectionTimeout() {
this.clearConnectionTimeout();
this.connectionTimeout = setTimeout(
this.onTimeout.bind(this),
this.options.timeout || DEFAULT_TIMEOUT,
);
}
clearConnectionTimeout() {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
this.connectionTimeout = undefined;
}
}
setPingTimeout() {
if (this.pingTimeout) {
clearTimeout(this.pingTimeout);
}
this.pingTimeout = setTimeout(
this.onPing.bind(this),
this.options.pingTimeout || DEFAULT_PING_TIMEOUT,
);
}
onTimeout() {
const { ws } = this;
if (!ws) return;
if (ws.listenerCount('open') > 0) {
ws.emit('error', 'Websocket timeout');
try {
ws.close();
} catch (error) {
// empty
}
} else {
this.messages.forEach(m => m.reject(new Error('websocket_timeout')));
ws.close();
}
}
async onPing() {
// make sure that connection is alive if there are subscriptions
if (this.ws && this.isConnected()) {
if (this.subscriptions.length > 0 || this.options.keepAlive) {
await this.getBlockHash(0);
} else {
try {
this.ws.close();
} catch (error) {
// empty
}
}
}
}
onError() {
this.dispose();
}
send(params) {
const { ws } = this;
if (!ws) throw NOT_INITIALIZED;
const id = this.messageID;
const dfd = createDeferred(id);
const req = {
id,
...params,
};
this.messageID++;
this.messages.push(dfd);
this.setConnectionTimeout();
this.setPingTimeout();
ws.send(JSON.stringify(req));
return dfd.promise;
}
onmessage(message) {
try {
const resp = JSON.parse(message);
const { id, success } = resp;
const dfd = this.messages.find(m => m.id === id);
if (resp.type === 'client') {
const { firmwares } = resp;
if (!firmwares['1'] || !firmwares['2'] || !firmwares.R) {
throw new Error('unexpected response in firmwares event');
}
this.emit('firmwares', firmwares);
}
if (dfd) {
if (!success) {
dfd.reject(
new Error(`websocket_error_message: ${resp.error.message || resp.error}`),
);
} else {
dfd.resolve(resp);
}
this.messages.splice(this.messages.indexOf(dfd), 1);
}
} catch (error) {
// empty
}
if (this.messages.length === 0) {
this.clearConnectionTimeout();
}
this.setPingTimeout();
}
connect() {
if (this.isConnected()) return Promise.resolve();
// url validation
let { url } = this.options;
if (typeof url !== 'string') {
throw new Error('websocket_no_url');
}
if (url.startsWith('https')) {
url = url.replace('https', 'wss');
}
if (url.startsWith('http')) {
url = url.replace('http', 'ws');
}
// set connection timeout before WebSocket initialization
// it will be be cancelled by this.init or this.dispose after the error
this.setConnectionTimeout();
const firmwaresEventPromise = createDeferred();
// initialize connection
const ws = new WebSocket(url);
ws.once('error', error => {
this.dispose();
firmwaresEventPromise.reject(new Error('websocket_runtime_error: ', error.message));
});
ws.on('open', () => {
this.init();
});
this.on('firmwares', firmwares => {
firmwaresEventPromise.resolve(firmwares);
});
this.ws = ws;
// wait for onopen event
return firmwaresEventPromise.promise;
}
init() {
const { ws } = this;
if (!ws || !this.isConnected()) {
throw Error('Websocket init cannot be called');
}
// clear timeout from this.connect
this.clearConnectionTimeout();
// remove previous listeners and add new listeners
ws.removeAllListeners();
ws.on('error', this.onError.bind(this));
ws.on('message', this.onmessage.bind(this));
ws.on('close', () => {
this.emit('disconnected');
this.dispose();
});
}
disconnect() {
if (this.ws) {
this.ws.close();
}
// this.dispose();
}
isConnected() {
const { ws } = this;
return ws && ws.readyState === WebSocket.OPEN;
}
dispose() {
if (this.pingTimeout) {
clearTimeout(this.pingTimeout);
}
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
const { ws } = this;
if (this.isConnected()) {
this.disconnect();
}
if (ws) {
ws.removeAllListeners();
}
this.removeAllListeners();
}
}
module.exports = {
Controller,
};