divvy-lib
Version:
A JavaScript API for interacting with Divvy in Node.js and the browser
415 lines • 16.3 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const _ = require("lodash");
const events_1 = require("events");
const url_1 = require("url");
const WebSocket = require("ws");
const rangeset_1 = require("./rangeset");
const errors_1 = require("./errors");
function isStreamMessageType(type) {
return type === 'ledgerClosed' ||
type === 'transaction' ||
type === 'path_find';
}
class Connection extends events_1.EventEmitter {
constructor(url, options = {}) {
super();
this._isReady = false;
this._ws = null;
this._ledgerVersion = null;
this._availableLedgerVersions = new rangeset_1.default();
this._nextRequestID = 1;
this._retry = 0;
this._retryTimer = null;
this._onOpenErrorBound = null;
this._onUnexpectedCloseBound = null;
this._fee_base = null;
this._fee_ref = null;
this.setMaxListeners(Infinity);
this._url = url;
this._trace = options.trace || false;
if (this._trace) {
// for easier unit testing
this._console = console;
}
this._proxyURL = options.proxy;
this._proxyAuthorization = options.proxyAuthorization;
this._authorization = options.authorization;
this._trustedCertificates = options.trustedCertificates;
this._key = options.key;
this._passphrase = options.passphrase;
this._certificate = options.certificate;
this._timeout = options.timeout || (20 * 1000);
}
_updateLedgerVersions(data) {
this._ledgerVersion = Number(data.ledger_index);
if (data.validated_ledgers) {
this._availableLedgerVersions.reset();
this._availableLedgerVersions.parseAndAddRanges(data.validated_ledgers);
}
else {
this._availableLedgerVersions.addValue(this._ledgerVersion);
}
}
_updateFees(data) {
this._fee_base = Number(data.fee_base);
this._fee_ref = Number(data.fee_ref);
}
// return value is array of arguments to Connection.emit
_parseMessage(message) {
const data = JSON.parse(message);
if (data.type === 'response') {
if (!(Number.isInteger(data.id) && data.id >= 0)) {
throw new errors_1.ResponseFormatError('valid id not found in response');
}
return [data.id.toString(), data];
}
else if (isStreamMessageType(data.type)) {
if (data.type === 'ledgerClosed') {
this._updateLedgerVersions(data);
this._updateFees(data);
}
return [data.type, data];
}
else if (data.type === undefined && data.error) {
return ['error', data.error, data.error_message, data]; // e.g. slowDown
}
throw new errors_1.ResponseFormatError('unrecognized message type: ' + data.type);
}
_onMessage(message) {
if (this._trace) {
this._console.log(message);
}
let parameters;
try {
parameters = this._parseMessage(message);
}
catch (error) {
this.emit('error', 'badMessage', error.message, message);
return;
}
// we don't want this inside the try/catch or exceptions in listener
// will be caught
this.emit.apply(this, parameters);
}
get _state() {
return this._ws ? this._ws.readyState : WebSocket.CLOSED;
}
get _shouldBeConnected() {
return this._ws !== null;
}
isConnected() {
return this._state === WebSocket.OPEN && this._isReady;
}
_onUnexpectedClose(beforeOpen, resolve, reject, code) {
if (this._onOpenErrorBound) {
this._ws.removeListener('error', this._onOpenErrorBound);
this._onOpenErrorBound = null;
}
// just in case
this._ws.removeAllListeners('open');
this._ws = null;
this._isReady = false;
if (beforeOpen) {
// connection was closed before it was properly opened, so we must return
// error to connect's caller
this.connect().then(resolve, reject);
}
else {
// if first parameter ws lib sends close code,
// but sometimes it forgots about it, so default to 1006 - CLOSE_ABNORMAL
this.emit('disconnected', code || 1006);
this._retryConnect();
}
}
_calculateTimeout(retriesCount) {
return (retriesCount < 40)
// First, for 2 seconds: 20 times per second
? (1000 / 20)
: (retriesCount < 40 + 60)
// Then, for 1 minute: once per second
? (1000)
: (retriesCount < 40 + 60 + 60)
// Then, for 10 minutes: once every 10 seconds
? (10 * 1000)
// Then: once every 30 seconds
: (30 * 1000);
}
_retryConnect() {
this._retry += 1;
const retryTimeout = this._calculateTimeout(this._retry);
this._retryTimer = setTimeout(() => {
this.emit('reconnecting', this._retry);
this.connect().catch(this._retryConnect.bind(this));
}, retryTimeout);
}
_clearReconnectTimer() {
if (this._retryTimer !== null) {
clearTimeout(this._retryTimer);
this._retryTimer = null;
}
}
_onOpen() {
if (!this._ws) {
return Promise.reject(new errors_1.DisconnectedError());
}
if (this._onOpenErrorBound) {
this._ws.removeListener('error', this._onOpenErrorBound);
this._onOpenErrorBound = null;
}
const request = {
command: 'subscribe',
streams: ['ledger']
};
return this.request(request).then((data) => {
if (_.isEmpty(data) || !data.ledger_index) {
// divvyd instance doesn't have validated ledgers
return this._disconnect(false).then(() => {
throw new errors_1.DivvydNotInitializedError('Divvyd not initialized');
});
}
this._updateLedgerVersions(data);
this._updateFees(data);
this._rebindOnUnxpectedClose();
this._retry = 0;
this._ws.on('error', error => {
// TODO: "type" does not exist on official error type, safe to remove?
if (process.browser && error && error.type === 'error') {
// we are in browser, ignore error - `close` event will be fired
// after error
return;
}
this.emit('error', 'websocket', error.message, error);
});
this._isReady = true;
this.emit('connected');
return undefined;
});
}
_rebindOnUnxpectedClose() {
if (this._onUnexpectedCloseBound) {
this._ws.removeListener('close', this._onUnexpectedCloseBound);
}
this._onUnexpectedCloseBound =
this._onUnexpectedClose.bind(this, false, null, null);
this._ws.once('close', this._onUnexpectedCloseBound);
}
_unbindOnUnxpectedClose() {
if (this._onUnexpectedCloseBound) {
this._ws.removeListener('close', this._onUnexpectedCloseBound);
}
this._onUnexpectedCloseBound = null;
}
_onOpenError(reject, error) {
this._onOpenErrorBound = null;
this._unbindOnUnxpectedClose();
reject(new errors_1.NotConnectedError(error && error.message));
}
_createWebSocket() {
const options = {};
if (this._proxyURL !== undefined) {
const parsedURL = url_1.parse(this._url);
const parsedProxyURL = url_1.parse(this._proxyURL);
const proxyOverrides = _.omitBy({
secureEndpoint: (parsedURL.protocol === 'wss:'),
secureProxy: (parsedProxyURL.protocol === 'https:'),
auth: this._proxyAuthorization,
ca: this._trustedCertificates,
key: this._key,
passphrase: this._passphrase,
cert: this._certificate
}, _.isUndefined);
const proxyOptions = _.assign({}, parsedProxyURL, proxyOverrides);
let HttpsProxyAgent;
try {
HttpsProxyAgent = require('https-proxy-agent');
}
catch (error) {
throw new Error('"proxy" option is not supported in the browser');
}
options.agent = new HttpsProxyAgent(proxyOptions);
}
if (this._authorization !== undefined) {
const base64 = new Buffer(this._authorization).toString('base64');
options.headers = { Authorization: `Basic ${base64}` };
}
const optionsOverrides = _.omitBy({
ca: this._trustedCertificates,
key: this._key,
passphrase: this._passphrase,
cert: this._certificate
}, _.isUndefined);
const websocketOptions = _.assign({}, options, optionsOverrides);
const websocket = new WebSocket(this._url, null, websocketOptions);
// we will have a listener for each outstanding request,
// so we have to raise the limit (the default is 10)
if (typeof websocket.setMaxListeners === 'function') {
websocket.setMaxListeners(Infinity);
}
return websocket;
}
connect() {
this._clearReconnectTimer();
return new Promise((resolve, reject) => {
if (!this._url) {
reject(new errors_1.ConnectionError('Cannot connect because no server was specified'));
}
if (this._state === WebSocket.OPEN) {
resolve();
}
else if (this._state === WebSocket.CONNECTING) {
this._ws.once('open', resolve);
}
else {
this._ws = this._createWebSocket();
// when an error causes the connection to close, the close event
// should still be emitted; the "ws" documentation says: "The close
// event is also emitted when then underlying net.Socket closes the
// connection (end or close)."
// In case if there is connection error (say, server is not responding)
// we must return this error to connection's caller. After successful
// opening, we will forward all errors to main api object.
this._onOpenErrorBound = this._onOpenError.bind(this, reject);
this._ws.once('error', this._onOpenErrorBound);
this._ws.on('message', this._onMessage.bind(this));
// in browser close event can came before open event, so we must
// resolve connect's promise after reconnect in that case.
// after open event we will rebound _onUnexpectedCloseBound
// without resolve and reject functions
this._onUnexpectedCloseBound = this._onUnexpectedClose.bind(this, true, resolve, reject);
this._ws.once('close', this._onUnexpectedCloseBound);
this._ws.once('open', () => this._onOpen().then(resolve, reject));
}
});
}
disconnect() {
return this._disconnect(true);
}
_disconnect(calledByUser) {
if (calledByUser) {
this._clearReconnectTimer();
this._retry = 0;
}
return new Promise(resolve => {
if (this._state === WebSocket.CLOSED) {
resolve();
}
else if (this._state === WebSocket.CLOSING) {
this._ws.once('close', resolve);
}
else {
if (this._onUnexpectedCloseBound) {
this._ws.removeListener('close', this._onUnexpectedCloseBound);
this._onUnexpectedCloseBound = null;
}
this._ws.once('close', code => {
this._ws = null;
this._isReady = false;
if (calledByUser) {
this.emit('disconnected', code || 1000); // 1000 - CLOSE_NORMAL
}
resolve();
});
this._ws.close();
}
});
}
reconnect() {
return this.disconnect().then(() => this.connect());
}
_whenReady(promise) {
return new Promise((resolve, reject) => {
if (!this._shouldBeConnected) {
reject(new errors_1.NotConnectedError());
}
else if (this._state === WebSocket.OPEN && this._isReady) {
promise.then(resolve, reject);
}
else {
this.once('connected', () => promise.then(resolve, reject));
}
});
}
getLedgerVersion() {
return this._whenReady(Promise.resolve(this._ledgerVersion));
}
hasLedgerVersions(lowLedgerVersion, highLedgerVersion) {
return this._whenReady(Promise.resolve(this._availableLedgerVersions.containsRange(lowLedgerVersion, highLedgerVersion || this._ledgerVersion)));
}
hasLedgerVersion(ledgerVersion) {
return this.hasLedgerVersions(ledgerVersion, ledgerVersion);
}
getFeeBase() {
return this._whenReady(Promise.resolve(Number(this._fee_base)));
}
getFeeRef() {
return this._whenReady(Promise.resolve(Number(this._fee_ref)));
}
_send(message) {
if (this._trace) {
this._console.log(message);
}
return new Promise((resolve, reject) => {
this._ws.send(message, undefined, error => {
if (error) {
reject(new errors_1.DisconnectedError(error.message));
}
else {
resolve();
}
});
});
}
request(request, timeout) {
return new Promise((resolve, reject) => {
if (!this._shouldBeConnected) {
reject(new errors_1.NotConnectedError());
}
let timer = null;
const self = this;
const id = this._nextRequestID;
this._nextRequestID += 1;
const eventName = id.toString();
function onDisconnect() {
clearTimeout(timer);
self.removeAllListeners(eventName);
reject(new errors_1.DisconnectedError());
}
function cleanup() {
clearTimeout(timer);
self.removeAllListeners(eventName);
if (self._ws !== null) {
self._ws.removeListener('close', onDisconnect);
}
}
function _resolve(response) {
cleanup();
resolve(response);
}
function _reject(error) {
cleanup();
reject(error);
}
this.once(eventName, response => {
if (response.status === 'error') {
_reject(new errors_1.DivvydError(response.error));
}
else if (response.status === 'success') {
_resolve(response.result);
}
else {
_reject(new errors_1.ResponseFormatError('unrecognized status: ' + response.status));
}
});
this._ws.once('close', onDisconnect);
// JSON.stringify automatically removes keys with value of 'undefined'
const message = JSON.stringify(Object.assign({}, request, { id }));
this._whenReady(this._send(message)).then(() => {
const delay = timeout || this._timeout;
timer = setTimeout(() => _reject(new errors_1.TimeoutError()), delay);
}).catch(_reject);
});
}
}
exports.default = Connection;
//# sourceMappingURL=connection.js.map