UNPKG

imt-gateway

Version:
344 lines (280 loc) 9.43 kB
'use strict' const fs = require('fs'); const debug = require('debug')('imt:gateway:client'); const EventEmitter = require('events'); const TOKEN = require('./protocol.js').TOKEN; const Protocol = require('./protocol.js').Protocol; const Action = require('./action.js'); const Event = require('./event.js'); const queue = require('async/queue'); const uuid = require('uuid'); const RECONNECT_TIMEOUT = 1000; const TOKEN_TO_STRING = {}; for (let name in TOKEN) TOKEN_TO_STRING[TOKEN[name]] = name.toLowerCase(); const defer = (cb, err, klass) => { klass = klass || Error; if (err && !(err instanceof Error)) err = new klass(err); if ('function' != typeof cb) throw err; return setImmediate(cb, err); } const isValid = (type, value) => { return new RegExp(`^-----BEGIN ${type}-----[A-Za-z0-9+\/=\n]+-----END ${type}-----\n?$`).test(value); } /** * Gateway Client. * * @event error Emitted on error. * @event connect Emitted once the client is connected to the server. * @event disconnect Emitted once client is disconnected from the server. * @event drain Emitted once server notifies client that its queue is drained. * @event action Emitted once action is received from the server. */ class Client extends EventEmitter { /** * @param {Object} options Options object. * * **Options:** * - `host` - Host. * - `port` - Port. * - `key` - Path to private key file. * - `cert` - Path to public certificate file. * - `autoReconnect` - Auto reconnect. Optional, default: `true`. * - `maxReconnectAttempts` - Max number of reconnect attempts. If the connection was successfuly established before the disconnect happened, `disconnect` event will be emitted after consuming all reconnect attempts. If the connection was not connected, `error` event with error message will be emitted. Unlimited by default. **/ constructor(options) { super(); if (options == null || 'object' !== typeof options) throw new Error('Options expected.'); if (options.host !== 'localhost') this._secure = true; if (this._secure && !isValid('RSA PRIVATE KEY', options.key)) throw new Error('Invalid private key.'); if (this._secure && !isValid('CERTIFICATE', options.cert)) throw new Error('Invalid certificate.'); this._options = Object.assign({}, options); this._options.host = this._options.host || 'gateway.integromat.com'; this._options.port = this._options.port || 7777; this._manuallyDisconnected = false; this._attempt = 0; this._close = this._close.bind(this); this._connect = this._connect.bind(this); this._data = this._data.bind(this); this._error = this._error.bind(this); this._queue = queue((event, next) => { let packet = { id: event.id, type: event.type, bundle: event.bundle }; this._protocol.send(TOKEN.EVENT, packet); this._waitingForAcknowledgement = { packet, callback: (err) => { if ('function' === typeof event.callback) event.callback(err); next(); } }; debug(`outgoing, token: 'event', id: '${packet.id}', data:`, event.bundle); }, 1); this._queue.pause(); } /** * Close handler. * * @private */ _close() { let wasAuthorized = this._protocol.authorized; this._protocol = null; this._queue.pause(); debug('connection closed'); if (this._options.autoReconnect !== false && !this._manuallyDisconnected) { if (this._options.maxReconnectAttempts > 0 && this._attempt >= this._options.maxReconnectAttempts) { debug('max reconnect attempts reached'); if (this._wasConected) { return this.emit('disconnect'); } else { return this.emit('error', this._lastError); } } debug(`reconnect attempt no. ${this._attempt + 1} in ${RECONNECT_TIMEOUT / 1000}s`); if (this._wasConected == null) this._wasConected = wasAuthorized; this._reconnectTimeout = setTimeout(() => { debug('attempting reconnect'); this.emit('reconnect'); this._attempt++; this._reconnectTimeout = null; this._reconnect(); }, RECONNECT_TIMEOUT); } else { if (wasAuthorized) this.emit('disconnect'); } } /** * Connect handler. This is just a TLS connection notification, authorization is still in progress. * * @private */ _connect() { debug('connection established'); } /** * Data handler. * * @param {Number} token Token ID. * @param {Object} data Data sent along with token. * * @private */ _data(token, data) { debug(`incoming, token: '${TOKEN_TO_STRING[token]}', data:`, data); switch (token) { case TOKEN.WELCOME: debug('connection authorized'); this._attempt = 0; this._protocol.authorized = true; if (!this._wasConected) this.emit('connect'); break; case TOKEN.DRAINED: this._queue.resume(); this.emit('drain'); break; case TOKEN.ACTION: this.emit('action', new Action(data.id, data.type, data.parameters), (err) => { if (!this._protocol) return; let res = { correlid: data.id, status: 0 }; if (err) { res.status = 1; res.error = {message: err.message}; } debug(`outgoing, token: 'acknowledgement', correlid: '${data.id}'`); this._protocol.send(TOKEN.ACKNOWLEDGEMENT, res); }) break; case TOKEN.ACKNOWLEDGEMENT: if (!this._waitingForAcknowledgement) { this.emit('error', new Error("Received acknowledgement even if there is no event in the queue.")); return this._protocol.socket.destroy(); // Interrupt connection immediately. } if (this._waitingForAcknowledgement.packet.id !== data.correlid) { this.emit('error', new Error("Received acknowledgement for different event.")); return this._protocol.socket.destroy(); // Interrupt connection immediately. } this.emit('acknowledgement', data); let cb = this._waitingForAcknowledgement.callback; this._waitingForAcknowledgement = null; if (cb != null) { if (data.status !== 0) { let err = new Error(data.error.message); err.code = data.error.code; cb(err); } else { cb(); } } break; case TOKEN.BYE: if (data && data.error != null) { let err = new Error(data.error.message); err.code = data.error.code; this.emit('error', err); } break; } } /** * Error handler. * * @param {Error} error Error object. * * @private */ _error(err) { debug('connection error', err.message); this._lastError = err; if (this._options.autoReconnect === false) { this.emit('error', err); } } /** * Method used to connect to the server. * * @private */ _reconnect() { debug(`connecting to ${this._options.host}:${this._options.port}`); if (this._protocol) return defer(() => this.emit('error', new Error('Client is not in valid state.'))); let config = { host: this._options.host, port: this._options.port } if (this._secure) { config.key = this._options.key; config.cert = this._options.cert; const host = new URL(`https://${config.host}`); config.ca = [ (this._options.ca && this._options.ca.root) || fs.readFileSync(`${__dirname}/../certs/${host.hostname}.root.ca.pem`), (this._options.ca && this._options.ca.intermediate) || fs.readFileSync(`${__dirname}/../certs/${host.hostname}.intermediate.ca.pem`) ]; } let socket = require(this._secure ? 'tls' : 'net').connect(config); this._protocol = new Protocol(socket); this._protocol.on('error', this._error); this._protocol.on('data', this._data); this._protocol.on('close', this._close); this._protocol.on('connect', this._connect); } /** * Connect to the Gateway server. * * returns {Client} */ connect() { if (this._protocol) return; // Already connected. this._attempt = 0; this._manuallyDisconnected = false; this._wasAuthorized = false; if (this._reconnectTimeout) { clearTimeout(this._reconnectTimeout); this._reconnectTimeout = null; } this._reconnect(); return this; } /** * Disconnect from the Gateway server. * * @returns {Client} */ close() { if (!this._protocol) return; // Already disconnected. this._manuallyDisconnected = true; if (this._reconnectTimeout) { clearTimeout(this._reconnectTimeout); if (this._wasConected) defer(() => this.emit('disconnect')); } else if (this._protocol && this._protocol.closed === false) { this._protocol.end(); } this._waitingForAcknowledgement = null; this._wasConected = null; return this; } /** * Send event to the server. Message is automatically queued when connection is not yet established or when another event was not yet processed. * * @param {Event} event Event. * @param {Function} [callback] Callback to call when acknowledgment is received. * @returns {Client} */ send(event, callback) { if (callback != null && 'function' !== typeof callback) throw new Error('Invalid callback.'); if (!(event instanceof Event)) return defer(callback, 'Event expected.', TypeError); if ('string' !== typeof event.type) return defer(callback, 'Invalid event type.'); if (event.type === '') return defer(callback, 'Event type not specified.'); if (event.bundle != null && 'object' !== typeof event.bundle) return defer(callback, 'Invalid event bundle.'); event.callback = callback; if (event.id == null) event.id = uuid.v4().replace(/-/g,''); this._queue.push(event); return this; } } module.exports = Client;