UNPKG

crypto-exchanges-ws-client

Version:

Node.js implementation of websocket protocol used by Crypto Exchange Gateway

340 lines (312 loc) 10.5 kB
"use strict"; const retry = require('retry'); const WebSocket = require('ws'); const EventEmitter = require('events'); const debug = require('debug')('CEWSC:WebSocketConnection'); // how long should we wait before trying to reconnect upon connection failure const RETRY_DELAY = 10 * 1000; // infinite retry const RETRY_COUNT = -1; // connection will be closed if we don't receive pong after timeout const PING_TIMEOUT = 30000; // how long do we want to wait for handshake const HANDSHAKE_TIMEOUT = 10 * 1000; // default user agent const USER_AGENT = 'CEWSC 1.0'; // connection states const STATE_NEW = 0; const STATE_CONNECTING = 1; const STATE_CONNECTED = 2; const STATE_DISCONNECTING = 3; const STATE_DISCONNECTED = 3; /* Following events can be emitted 1) message, when a message is received Data will contain the message received 2) connectionError, when a connection error occurs (ie: WS cannot be connnected) Data will be an object {attempts:integer,retry:boolean,error:err} - attempts : number of attempts to connect - retry : whether or not there are retry left - error : the connection error which occurred 3) disconnected, when websocket connection has been disconnected This is a final event. Websocket won't be reconnected. A new WebsocketConnection object should be used Event will not be emitted in case connection is disconnected by client or on connection failure Event will not be emitted for connection/reconnection error (event connectionError will be emitted instead) Data will be an object {code:integer,reason:string} 4) connected, when websocket connection is ready to receive message Event will only be emitted once in the lifetime of the object */ class WebSocketConnection extends EventEmitter { constructor(uri, options) { super(); this._uri = uri; this._retryCount = RETRY_COUNT; this._retryDelay = RETRY_DELAY; this._pingTimeout = PING_TIMEOUT; this._apiKey = ''; if (undefined !== options) { // retry count if (undefined !== options.retryCount) { this._retryCount = options.retryCount; } if (undefined !== options.retryDelay) { this._retryDelay = options.retryDelay; } if (undefined !== options.pingTimeout) { this._pingTimeout = options.pingTimeout; } if (undefined !== options.apiKey) { this._apiKey = options.apiKey; } } this._ws = null; // when WS successfully connected this._timestamp = null; this._connectionState = STATE_NEW; this._ignoreCloseEvent = true; } isConnected() { return STATE_CONNECTED == this._connectionState; } send(data) { if (STATE_CONNECTED != this._connectionState) { return false; } this._ws.send(data); } disconnect() { if (STATE_DISCONNECTED == this._connectionState || STATE_DISCONNECTING == this._connectionState) { return; } this._connectionState = STATE_DISCONNECTING; this._finalize(false, STATE_DISCONNECTED); return; } connect() { if (STATE_NEW !== this._connectionState) { return false; } let attempt = 1; this._connectionState = STATE_CONNECTING; let self = this; try { let retryOptions = { minTimeout:this._retryDelay, // do not use any exponential factor factor:1, randomize:false }; if (-1 == this._retryCount) { retryOptions.forever = true; } else { retryOptions.retries = this._retryCount; } let wsOptions = { perMessageDeflate: false, handshakeTimeout:HANDSHAKE_TIMEOUT, headers: { 'User-Agent': USER_AGENT } } if ('' != this._apiKey) { wsOptions.headers['ApiKey'] = this._apiKey; } let operation = retry.operation(retryOptions); operation.attempt(function(currentAttempt){ // connection has already been disconnected by client if (STATE_CONNECTING != self._connectionState) { return; } attempt = currentAttempt; let doRetry = true; let ws = new WebSocket(self._uri, wsOptions); let ignoreErrorEvent = false; let skipCloseEvent = false; ws.on('open', function() { // connection has already been disconnected by client if (STATE_CONNECTING != self._connectionState) { return; } self._connectionState = STATE_CONNECTED; if (debug.enabled) { debug("WS is connected"); } self._ignoreCloseEvent = false; skipCloseEvent = false; self._timestamp = new Date().getTime(); self._ws = this; // start ping/pong if (0 != self._pingTimeout) { let _ws = this; _ws.isAlive = false; // initial ping _ws.ping('', true, true); let interval = setInterval(function() { if (WebSocket.OPEN != _ws.readyState) { clearTimeout(interval); return; } if (!_ws.isAlive) { if (debug.enabled) { debug("WS timeout : timeout = %d", self._pingTimeout); } _ws.terminate(); clearTimeout(interval); return; } _ws.isAlive = false; _ws.ping('', true, true); }, self._pingTimeout); } self.emit('connected'); }); ws.on('message', function(message) { self.emit('message', message); }); ws.on('error', function(e) { if (ignoreErrorEvent) { return; } // connection has already been disconnected by client if (STATE_CONNECTING != self._connectionState) { return; } let err = {code:e.code,message:e.message} if (debug.enabled) { debug("WS error (attempt %d/%s) : %s", attempt, -1 === self._retryCount ? 'unlimited' : (1 + self._retryCount), JSON.stringify(err)); } skipCloseEvent = true; self._ws = null; this.terminate(); // ws is not open yet, likely to be a connection error if (null === self._timestamp) { if (doRetry && operation.retry(err)) { self.emit('connectionError', {attempts:attempt,retry:true,error:err}); return; } self.emit('connectionError', {attempts:attempt,retry:false,error:err}); } }); // likely to be an auth error ws.on('unexpected-response', function(request, response){ // connection has already been disconnected by client if (STATE_CONNECTING != self._connectionState) { return; } let err = {code:response.statusCode,message:response.statusMessage}; ignoreErrorEvent = true; skipCloseEvent = true; if (debug.enabled) { debug("WS unexpected-response (attempt %d/%s) : %s", self._uri, attempt, -1 === self._retryCount ? 'unlimited' : (1 + self._retryCount), JSON.stringify(err)); } self._ws = null; if (doRetry && operation.retry(err)) { self.emit('connectionError', {attempts:attempt,retry:true,error:err}); return; } self.emit('connectionError', {attempts:attempt,retry:false,error:err}); }); ws.on('close', function(code, reason){ if (self._ignoreCloseEvent) { return; } // connection has already been disconnected by client if (STATE_CONNECTING != self._connectionState && STATE_CONNECTED != self._connectionState) { return; } if (debug.enabled) { debug("WS closed : code = %d, reason = '%s'", code, reason); } self._ws = null; self._finalize(true, STATE_DISCONNECTED); if (!skipCloseEvent) { self.emit('disconnected', {code:code, reason:reason}); } }); // reply to ping ws.on('ping', function(data){ this.pong('', true, true); }); ws.on('pong', function(data){ this.isAlive = true; }); }); } catch (e) { throw e; } return true; } /** * Used to do a bit of cleaning (close ws, abort ...) * * @param {boolean} terminate indicates whether or not WS should be terminated vs closed * @param {integer} newState new connection state */ _finalize(terminate, newState) { // close ws if (null !== this._ws) { let ws = this._ws; this._ws = null; this._ignoreCloseEvent = true; try { if (terminate) { ws.terminate(); } else { ws.close(); } } catch (e) { // do nothing } } this._connectionState = newState; } } module.exports = WebSocketConnection;