UNPKG

mqlight-dev

Version:
1,392 lines (1,284 loc) 119 kB
/* %Z% %W% %I% %E% %U% */ /* * <copyright * notice="lm-source-program" * pids="5725-P60" * years="2013,2016" * crc="3568777996" > * Licensed Materials - Property of IBM * * 5725-P60 * * (C) Copyright IBM Corp. 2013, 2016 * * US Government Users Restricted Rights - Use, duplication or * disclosure restricted by GSA ADP Schedule Contract with * IBM Corp. * </copyright> */ /* jslint node: true */ /* jshint -W083,-W097 */ 'use strict'; /** * Set up logging (to stderr by default). The level of output is * configured by the value of the MQLIGHT_NODE_LOG environment * variable. The default is 'ffdc'. */ global.logger = require('./mqlight-log'); /** * The logging level can be set programmatically by calling * logger.setLevel(level) * An ffdc can be generated programmatically by calling * logger.ffdc() */ exports.logger = global.logger; var logger = global.logger; var os = require('os'); var util = require('util'); var EventEmitter = require('events').EventEmitter; var uuid = require('uuid'); var url = require('url'); var fs = require('fs'); var http = require('http'); var https = require('https'); var AMQP = require('mqlight-forked-amqp10'); var linkCache = require('amqp10-link-cache'); AMQP.use(linkCache({ttl: 2147483647})); var invalidClientIdRegex = /[^A-Za-z0-9%/._]+/; var pemCertRegex = new RegExp('-----BEGIN CERTIFICATE-----(.|[\r\n])*?' + '-----END CERTIFICATE-----', 'gm'); /** * List of active clients to prevent duplicates, in the started state, with * the same id existing. */ var activeClientList = { clients: new Map(), add: function(client) { logger.entry('activeClientList.add', client.id); this.clients.set(client.id, client); logger.exit('activeClientList.add', client.id, null); }, remove: function(id) { logger.entry('activeClientList.remove', id); this.clients.delete(id); logger.exit('activeClientList.remove', id, null); }, get: function(id) { logger.entry('activeClientList.get', id); var client = this.clients.get(id); logger.exit('activeClientList.get', id, client); return client; }, has: function(id) { logger.entry('activeClientList.has', id); var found = this.clients.has(id); logger.exit('activeClientList.has', id, found); return found; } }; /** @const {number} */ exports.QOS_AT_MOST_ONCE = 0; /** @const {number} */ exports.QOS_AT_LEAST_ONCE = 1; /** The connection retry interval in milliseconds. */ var CONNECT_RETRY_INTERVAL = 1; if (process.env.NODE_ENV === 'unittest') CONNECT_RETRY_INTERVAL = 0; /** Client state: connectivity with the server re-established */ var STATE_RESTARTED = 'restarted'; /** Client state: trying to re-establish connectivity with the server */ var STATE_RETRYING = 'retrying'; /** Client state: ready to do messaging */ var STATE_STARTED = 'started'; /** Client state: becoming ready to do messaging */ var STATE_STARTING = 'starting'; /** Client state: client disconnected from server, not ready for messaging */ var STATE_STOPPED = 'stopped'; /** Client state: in the process of transitioning to STATE_STOPPED */ var STATE_STOPPING = 'stopping'; /** * Generic helper method to use for Error sub-typing * * @param {Object} * obj - the object upon which to define Error properties * @param {String} * name - the sub-type Error object name * @param {String} * message - Human-readable description of the error */ function setupError(obj, name, message) { if (obj) { Error.call(obj); Object.defineProperty(obj, 'name', { value: name, enumerable: false }); Object.defineProperty(obj, 'message', { value: message, enumerable: false }); } else { logger.ffdc('setupError', 'ffdc001', null, 'Client object not provided'); } } /** * Generic helper method to map a named Error object into the correct * sub-type so that instanceof checking works as expected. * * @param {Object} * obj - the Error object to remap. * @return {Object} a sub-typed Error object. */ function getNamedError(obj) { if (obj && obj instanceof Error && 'name' in obj) { var Constructor = exports[obj.name]; if (typeof Constructor === 'function') { var res = new Constructor(obj.message); if (res) { res.stack = obj.stack; return res; } } } return obj; } /** * A subtype of Error defined by the MQ Light client. It is considered a * programming error. The underlying cause for this error are the parameter * values passed into a method. * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.InvalidArgumentError = function(message) { setupError(this, 'InvalidArgumentError', message); Error.captureStackTrace(this, this.constructor); }; var InvalidArgumentError = exports.InvalidArgumentError; util.inherits(InvalidArgumentError, Error); /** * This is a subtype of Error defined by the MQ Light client. It is considered * an operational error. NetworkError is passed to an application if the client * cannot establish a network connection to the MQ Light server, or if an * established connection is broken. * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.NetworkError = function(message) { setupError(this, 'NetworkError', message); Error.captureStackTrace(this, this.constructor); }; var NetworkError = exports.NetworkError; util.inherits(NetworkError, Error); /** * This is a subtype of Error defined by the MQ Light client. It is considered * an operational error. NotPermittedError is thrown to indicate that a * requested operation has been rejected because the remote end does not * permit it. * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.NotPermittedError = function(message) { setupError(this, 'NotPermittedError', message); Error.captureStackTrace(this, this.constructor); }; var NotPermittedError = exports.NotPermittedError; util.inherits(NotPermittedError, Error); /** * This is a subtype of Error defined by the MQ Light client. It is considered * an operational error. ReplacedError is thrown to signify that an instance of * the client has been replaced by another instance that connected specifying * the exact same client id. * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.ReplacedError = function(message) { setupError(this, 'ReplacedError', message); Error.captureStackTrace(this, this.constructor); }; var ReplacedError = exports.ReplacedError; util.inherits(ReplacedError, Error); /** * Special type of ReplacedError thrown when an active Client instance is * replaced by the application starting another Client instance with the same * id. * * @param {String} * id - Client id * * @constructor */ var LocalReplacedError = function(id) { ReplacedError.apply(this, ['Client Replaced. Application has started a ' + 'second Client instance with id ' + id]); }; util.inherits(LocalReplacedError, ReplacedError); /** * This is a subtype of Error defined by the MQ Light client. It is considered * an operational error. SecurityError is thrown when an operation fails due to * a security related problem. * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.SecurityError = function(message) { setupError(this, 'SecurityError', message); Error.captureStackTrace(this, this.constructor); }; var SecurityError = exports.SecurityError; util.inherits(SecurityError, Error); /** * This is a subtype of Error defined by the MQ Light client. It is * considered a programming error - but is unusual in that, in some * circumstances, a client may reasonably expect to receive StoppedError as a * result of its actions and would typically not be altered to avoid this * condition occurring. StoppedError is thrown by methods which require * connectivity to the server (e.g. send, subscribe) when they are invoked * while the client is in the stopping or stopped states * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.StoppedError = function(message) { setupError(this, 'StoppedError', message); Error.captureStackTrace(this, this.constructor); }; var StoppedError = exports.StoppedError; util.inherits(StoppedError, Error); /** * This is a subtype of Error defined by the MQ Light client. It is considered * a programming error. SubscribedError is thrown from the * client.subscribe(...) method call when a request is made to subscribe to a * destination that the client is already subscribed to. * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.SubscribedError = function(message) { setupError(this, 'SubscribedError', message); Error.captureStackTrace(this, this.constructor); }; var SubscribedError = exports.SubscribedError; util.inherits(SubscribedError, Error); /** * This is a subtype of Error defined by the MQ Light client. It is considered * a programming error. UnsubscribedError is thrown from the * client.unsubscribe(...) method call when a request is made to unsubscribe * from a destination that the client is not subscribed to. * * @param {String} * message - Human-readable description of the error * * @constructor */ exports.UnsubscribedError = function(message) { setupError(this, 'UnsubscribedError', message); Error.captureStackTrace(this, this.constructor); }; var UnsubscribedError = exports.UnsubscribedError; util.inherits(UnsubscribedError, Error); /** * Generic helper method to determine if we should automatically reconnect * for the given type of error. * * @param {Object} * err - the Error object to check. * @return {Object} true if we should reconnect, false otherwise. */ function shouldReconnect(err) { // exclude all programming errors return (!(err instanceof TypeError) && !(err instanceof InvalidArgumentError) && !(err instanceof NotPermittedError) && !(err instanceof ReplacedError) && !(err instanceof StoppedError) && !(err instanceof SubscribedError) && !(err instanceof UnsubscribedError) ); } /** * Function to take a single FILE URL and using the JSON retrieved from it to * return an array of service URLs. * * @param {String} * fileUrl - Required; a FILE address to retrieve service info * from (e.g., file:///tmp/config.json). * @return {function(callback)} a function which will call the given callback * with a list of AMQP service URLs retrieved from the FILE. * @throws TypeError * If fileUrl is not a string. * @throws Error * if an unsupported or invalid FILE address is specified. */ var getFileServiceFunction = function(fileUrl) { logger.entry('getFileServiceFunction', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'fileUrl:', fileUrl); if (typeof fileUrl !== 'string') { var err = new TypeError('fileUrl must be a string type'); logger.ffdc('getFileServiceFunction', 'ffdc001', null, err); logger.throw('getFileServiceFunction', logger.NO_CLIENT_ID, err); throw err; } var filePath = fileUrl; // special case for Windows drive letters in file URIs, trim the leading / if (os.platform() === 'win32' && filePath.match('^/[a-zA-Z]:/')) { filePath = filePath.substring(1); } var fileServiceFunction = function(callback) { logger.entry('fileServiceFunction', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'filePath:', filePath); fs.readFile(filePath, {encoding: 'utf8'}, function(err, data) { logger.entry('fileServiceFunction.readFile.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'err:', err); logger.log('parms', logger.NO_CLIENT_ID, 'data:', data); if (err) { err.message = 'attempt to read ' + filePath + ' failed with the ' + 'following error: ' + err.message; logger.log('error', logger.NO_CLIENT_ID, err); logger.entry('fileServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'err:', err); callback(err); logger.exit('fileServiceFunction.callback', logger.NO_CLIENT_ID, null); } else { var obj; try { obj = JSON.parse(data); } catch (err) { err.message = 'the content read from ' + filePath + ' contained ' + 'unparseable JSON: ' + err.message; logger.caught('fileServiceFunction.readFile.callback', logger.NO_CLIENT_ID, err); logger.entry('fileServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'err:', err); callback(err); logger.exit('fileServiceFunction.callback', logger.NO_CLIENT_ID, null); } if (obj) { logger.entry('fileServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'service:', obj.service); callback(null, obj.service); logger.exit('fileServiceFunction.callback', logger.NO_CLIENT_ID, null); } } logger.exit('fileServiceFunction.readFile.callback', logger.NO_CLIENT_ID, null); }); logger.exit('fileServiceFunction', logger.NO_CLIENT_ID, null); }; logger.exit('getFileServiceFunction', logger.NO_CLIENT_ID, fileServiceFunction); return fileServiceFunction; }; /** * Function to take a single HTTP URL and using the JSON retrieved from it to * return an array of service URLs. * * @param {String} * serviceUrl - Required; an HTTP address to retrieve service info * from. * @return {function(callback)} a function which will call the given callback * with a list of AMQP service URLs retrieved from the URL. * @throws TypeError * If serviceUrl is not a string. * @throws Error * if an unsupported or invalid URL specified. */ var getHttpServiceFunction = function(serviceUrl) { logger.entry('getHttpServiceFunction', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'serviceUrl:', serviceUrl); var serviceHref = serviceUrl.href; if (typeof serviceHref !== 'string') { var err = new TypeError('serviceUrl must be a string type'); logger.ffdc('getHttpServiceFunction', 'ffdc001', null, err); logger.throw('getHttpServiceFunction', logger.NO_CLIENT_ID, err); throw err; } var httpServiceFunction = function(callback) { logger.entry('httpServiceFunction', logger.NO_CLIENT_ID); var request = (serviceUrl.protocol === 'https:') ? https.request : http.request; var req = request(serviceHref, function(res) { logger.entry('httpServiceFunction.req.callback', logger.NO_CLIENT_ID); var data = ''; res.setEncoding('utf8'); res.on('data', function(chunk) { data += chunk; }); res.on('end', function() { logger.entry('httpServiceFunction.req.on.end.callback', logger.NO_CLIENT_ID); if (res.statusCode === 200) { var obj; try { obj = JSON.parse(data); } catch (err) { err.message = 'http request to ' + serviceHref + ' returned ' + 'unparseable JSON: ' + err.message; logger.caught('httpServiceFunction.req.on.end.callback', logger.NO_CLIENT_ID, err); logger.entry('httpServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'err:', err); callback(err); logger.exit('httpServiceFunction.callback', logger.NO_CLIENT_ID, null); } if (obj) { logger.entry('httpServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'service:', obj.service); callback(null, obj.service); logger.exit('httpServiceFunction.callback', logger.NO_CLIENT_ID, null); } } else { var message = 'http request to ' + serviceHref + ' failed with a ' + 'status code of ' + res.statusCode; if (data) message += ': ' + data; var err = new NetworkError(message); logger.log('error', logger.NO_CLIENT_ID, err); logger.entry('httpServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'err:', err); callback(err); logger.exit('httpServiceFunction.callback', logger.NO_CLIENT_ID, null); } logger.exit('httpServiceFunction.req.on.end.callback', logger.NO_CLIENT_ID, null); }); logger.exit('httpServiceFunction.req.callback', logger.NO_CLIENT_ID, null); }).on('error', function(err) { err.message = 'http request to ' + serviceHref + ' failed ' + 'with an error: ' + err.message; err.name = 'NetworkError'; err = getNamedError(err); logger.log('error', logger.NO_CLIENT_ID, err); logger.entry('httpServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'err:', err); callback(err); logger.exit('httpServiceFunction.callback', logger.NO_CLIENT_ID, null); }); req.setTimeout(5000, function() { var message = 'http request to ' + serviceHref + ' timed out ' + 'after 5000 milliseconds'; var err = new NetworkError(message); logger.log('error', logger.NO_CLIENT_ID, err); logger.entry('httpServiceFunction.callback', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'err:', err); callback(err); logger.exit('httpServiceFunction.callback', logger.NO_CLIENT_ID, null); }); req.end(); logger.exit('httpServiceFunction', logger.NO_CLIENT_ID, null); }; logger.exit('getHttpServiceFunction', logger.NO_CLIENT_ID, httpServiceFunction); return httpServiceFunction; }; /** * @param {function(object)} * stopProcessingCallback - callback to perform post stop processing. * @param {client} client - the client object to stop the messenger for. * @param {callback} * callback - passed an error object if something goes wrong. */ /** * Stops the messenger. The messenger stop function merely requests it to stop, * so we need to keep checking until it is actually stopped. Then any post * stop processing can be performed. * * @param {client} client - the client object to stop the messenger for. * @param {stopProcessingCallback} * stopProcessingCallback - Function to perform the required post stop * processing. * @param {callback} * callback - (optional) The application callback to be notified of * errors and completion (passed to the stopProcessingCallback). */ var stopMessenger = function(client, stopProcessingCallback, callback) { logger.entry('stopMessenger', client.id); logger.log('parms', client.id, 'stopProcessingCallback:', stopProcessingCallback); logger.log('parms', client.id, 'callback:', callback); var cb = function() { if (stopProcessingCallback) { stopProcessingCallback(client, callback); } }; // If messenger available then request it to stop // (otherwise it must have already been stopped) try { if (client._messenger) { logger.log('debug', client.id, 'disconnecting messenger'); client._messenger.disconnect().then(function() { logger.log('debug', client.id, 'messenger disconnected'); process.nextTick(cb); }).catch(function(err) { logger.caught('stopMessenger', client.id, err); callback(err); }).error(function(err) { logger.caught('stopMessenger', client.id, err); callback(err); }); } else { logger.log('debug', client.id, 'no active messenger to stop'); cb(); } } catch (err) { console.error(util.inspect(err, true)); } logger.exit('stopMessenger', client.id); }; /** * Called on reconnect or first connect to process any actions that may have * been queued. * * @param {Error} err if an error occurred in the performConnect function that * calls this callback. */ var processQueuedActions = function(err) { // this set to the appropriate client via apply call in performConnect var client = this; if (typeof client === 'undefined'/* || client.constructor !== Client*/) { logger.entry('processQueuedActions', 'client was not set'); logger.exit('processQueuedActions', 'client not set returning', null); return; } logger.entry('processQueuedActions', client.id); logger.log('parms', client.id, 'err:', err); logger.log('data', client.id, 'client.state:', client.state); if (!err) { var performSend = function() { logger.entry('performSend', client.id); logger.log('data', client.id, 'client._queuedSends', client._queuedSends); while (client._queuedSends.length > 0 && client.state === STATE_STARTED) { var remaining = client._queuedSends.length; var msg = client._queuedSends.shift(); client.send(msg.topic, msg.data, msg.options, msg.callback); if (client._queuedSends.length >= remaining) { // Calling client.send can cause messages to be added back into // _queuedSends, if the network connection is broken. Check that the // size of the array is decreasing to avoid looping forever... break; } } logger.exit('performSend', client.id, null); }; var performUnsub = function() { logger.entry('performUnsub', client.id); if (client._queuedUnsubscribes.length > 0 && client.state === STATE_STARTED) { var rm = client._queuedUnsubscribes.shift(); logger.log('data', client.id, 'rm:', rm); if (rm.noop) { // no-op, so just trigger the callback without actually unsubscribing if (rm.callback) { logger.entry('performUnsub.callback', client.id); rm.callback.apply(client, [null, rm.topicPattern, rm.share]); logger.exit('performUnsub.callback', client.id, null); } setImmediate(function() { performUnsub.apply(client); }); } else { client.unsubscribe(rm.topicPattern, rm.share, rm.options, function(err, topicPattern, share) { if (rm.callback) { logger.entry('performUnsub.callback', client.id); rm.callback.apply(client, [err, topicPattern, share]); logger.exit('performUnsub.callback', client.id, null); } setImmediate(function() { performUnsub.apply(client); }); } ); } } else { performSend.apply(client); } logger.exit('performUnsub', client.id, null); }; var performSub = function() { logger.entry('performSub', client.id); if (client._queuedSubscriptions.length > 0 && client.state === STATE_STARTED) { var sub = client._queuedSubscriptions.shift(); logger.log('data', client.id, 'sub:', sub); if (sub.noop) { // no-op, so just trigger the callback without actually subscribing if (sub.callback) { process.nextTick(function() { logger.entry('performSub.callback', client.id); logger.log('parms', client.id, 'err:', err, ', topicPattern:', sub.topicPattern, ', originalShareValue:', sub.share); sub.callback.apply(client, [err, sub.topicPattern, sub.originalShareValue]); logger.exit('performSub.callback', client.id, null); }); } setImmediate(function() { performSub.apply(client); }); } else { client.subscribe(sub.topicPattern, sub.share, sub.options, function(err, topicPattern, share) { if (sub.callback) { process.nextTick(function() { logger.entry('performSub.callback', client.id); logger.log('parms', client.id, 'err:', err, ', topicPattern:', topicPattern, ', share:', share); sub.callback.apply(client, [err, topicPattern, share]); logger.exit('performSub.callback', client.id, null); }); } setImmediate(function() { performSub.apply(client); }); } ); } } else { performUnsub.apply(client); } logger.exit('performSub', client.id, null); }; performSub(); } logger.exit('processQueuedActions', client.id, null); }; /** * Reconnects the client to the MQ Light service, implicitly closing any * subscriptions that the client has open. The 'restarted' event will be * emitted once the client has reconnected. * * @param {client} client - the client object to reconnect * @return {Object} The instance of client that it is invoked on - allowing * for chaining of other method calls on the client object. */ var reconnect = function(client) { if (typeof client === 'undefined'/* || client.constructor !== Client*/) { logger.entry('Client.reconnect', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'client:', client); logger.exit('Client.reconnect', logger.NO_CLIENT_ID, undefined); return; } logger.entry('Client.reconnect', client.id); if (client.state !== STATE_STARTED) { if (client.isStopped()) { logger.exit('Client.reconnect', client.id, null); return; } else if (client.state === STATE_RETRYING) { logger.exit('Client.reconnect', client.id, client); return client; } } client._setState(STATE_RETRYING); setImmediate(function() { // stop the messenger to free the object then attempt a reconnect stopMessenger(client, function(client) { logger.entry('Client.reconnect.stopProcessing', client.id); // clear the subscriptions list, if the cause of the reconnect happens // during check for messages we need a 0 length so it will check once // reconnected. logger.log('data', client.id, 'client._subscriptions:', client._subscriptions); while (client._subscriptions.length > 0) { client._queuedSubscriptions.push(client._subscriptions.shift()); } // also clear any left over outstanding sends while (client._outstandingSends.length > 0) { client._outstandingSends.shift(); } client._queuedStartCallbacks.push({ callback: processQueuedActions, create: false }); process.nextTick(function() { client._performConnect(false, true); }); logger.exit('Client.reconnect.stopProcessing', client.id, null); }); }); logger.exit('Client.reconnect', client.id, client); return client; }; var processMessage = function(client, receiver, protonMsg) { logger.entryLevel('entry_often', 'processMessage', client.id); Object.defineProperty(protonMsg, 'connectionId', { value: client._connectionId }); // if body is a JSON'ified object, try to parse it back to a js obj var data; var err; if (protonMsg.properties.contentType === 'application/json') { try { data = JSON.parse(protonMsg.body); } catch (_) { logger.caughtLevel('entry_often', 'processMessage', client.id, _); console.warn(_); } } else { data = protonMsg.body; } var topic = protonMsg.properties.to; var prefixMatch = topic.match('^amqp[s]?://'); if (prefixMatch) { topic = topic.slice(prefixMatch[0].length); topic = topic.slice(topic.indexOf('/') + 1); } var autoConfirm = true; var qos = exports.QOS_AT_MOST_ONCE; var matchedSubs = client._subscriptions.filter(function(el) { // 1 added to length to account for the / we add var addressNoService = el.address.slice(client._getService().length + 1); // possible to have 2 matches work out whether this is // for a share or private topic var linkAddress; if (receiver.name.indexOf('private:') === 0 && !el.share) { // slice off 'private:' prefix linkAddress = receiver.name.slice(8); } else if (receiver.name.indexOf('share:') === 0 && el.share) { // starting after the share: look for the next : denoting the end // of the share name and get everything past that linkAddress = receiver.name.slice(receiver.name.indexOf(':', 7) + 1); } return (addressNoService === linkAddress); }); // should only ever be one entry in matchedSubs if (matchedSubs.length > 1) { err = new Error('received message matched more than one ' + 'subscription'); logger.ffdc('processMessage', 'ffdc003', client, err); } var subscription = matchedSubs[0]; if (typeof subscription === 'undefined') { // ideally we shouldn't get here, but it can happen in a timing // window if we had received a message from a subscription we've // subsequently unsubscribed from logger.log('debug', client.id, 'No subscription matched message: ' + data + ' going to address: ' + protonMsg.properties.to); protonMsg = null; return; } qos = subscription.qos; if (qos === exports.QOS_AT_LEAST_ONCE) { autoConfirm = subscription.autoConfirm; } ++subscription.unconfirmed; var delivery = { message: { topic: topic } }; if (qos >= exports.QOS_AT_LEAST_ONCE && !autoConfirm) { var deliveryConfirmed = false; delivery.message.confirmDelivery = function(callback) { logger.entry('message.confirmDelivery', client.id); logger.log('data', client.id, 'delivery:', delivery); var err; if (client.isStopped()) { err = new NetworkError('not started'); logger.throw('message.confirmDelivery', client.id, err); throw err; } if (callback && (typeof callback !== 'function')) { err = new TypeError('Callback must be a function'); logger.throw('message.confirmDelivery', client.id, err); throw err; } logger.log('data', client.id, 'deliveryConfirmed:', deliveryConfirmed); if (!deliveryConfirmed && protonMsg) { // also throw NetworkError if the client has disconnected at some point // since this particular message was received if (protonMsg.connectionId !== client._connectionId) { err = new NetworkError('client has reconnected since this ' + 'message was received'); logger.throw('message.confirmDelivery', client.id, err); throw err; } deliveryConfirmed = true; receiver.accept(protonMsg); if (callback) { // FIXME: we shouldn't really have a callback at all here... // and if we do, it should at least track the 'sending' of the // settlement process.nextTick(function() { logger.entry('message.confirmDelivery.callback', client.id); callback.apply(client); logger.exit('message.confirmDelivery.callback', client.id, null); }); } } logger.exit('message.confirmDelivery', client.id, null); }; } var linkAddress = receiver.name; if (linkAddress) { delivery.destination = {}; var link = linkAddress; if (link.indexOf('share:') === 0) { // remove 'share:' prefix from link name link = link.substring(6, linkAddress.length); // extract share name and add to delivery information delivery.destination.share = link.substring(0, link.indexOf(':')); } // extract topicPattern and add to delivery information delivery.destination.topicPattern = link.substring(link.indexOf(':') + 1, link.length); } if (protonMsg.header.ttl > 0) { delivery.message.ttl = protonMsg.header.ttl; } if (protonMsg.applicationProperties) { delivery.message.properties = protonMsg.applicationProperties; } var da = protonMsg.deliveryAnnotations; var malformed = {}; if (da && 'x-opt-message-malformed-condition' in da) { malformed = { condition: da['x-opt-message-malformed-condition'], description: da['x-opt-message-malformed-description'], MQMD: { CodedCharSetId: Number(da['x-opt-message-malformed-MQMD.CodedCharSetId']), Format: da['x-opt-message-malformed-MQMD.Format'] } }; } if (malformed.condition) { if (client.listeners('malformed').length > 0) { delivery.malformed = malformed; logger.log('emit', client.id, 'malformed', protonMsg.body, delivery); client.emit('malformed', protonMsg.body, delivery); } else { err = new Error('No listener for "malformed" event.'); logger.throwLevel('exit_often', 'processMessage', client.id, err); throw err; } } else { logger.log('emit', client.id, 'message', delivery); try { client.emit('message', data, delivery); } catch (err) { logger.caughtLevel('entry_often', 'processMessage', client.id, err); logger.log('emit', client.id, 'error', err); client.emit('error', err); } } if (client.isStopped()) { logger.log('debug', client.id, 'client is stopped so not accepting or settling message'); } else { if (qos === exports.QOS_AT_MOST_ONCE) { // XXX: is this needed/correct any more? receiver.accept(protonMsg); } if (qos === exports.QOS_AT_MOST_ONCE || autoConfirm) { receiver.accept(protonMsg); } } logger.exitLevel('exit_often', 'processMessage', client.id, null); }; var lookupError = function(err) { if (/ECONNREFUSED/.test(err.code) || err instanceof AMQP.Errors.DisconnectedError) { var msg = 'CONNECTION ERROR: The remote computer refused ' + 'the network connection. '; if (err && err.message) { msg += err.message; } err = new NetworkError(msg); } else if (/DEPTH_ZERO_SELF_SIGNED_CERT/.test(err.code) || /SELF_SIGNED_CERT_IN_CHAIN/.test(err.code) || /UNABLE_TO_GET_ISSUER_CERT_LOCALLY/.test(err.code) ) { // Convert DEPTH_ZERO_SELF_SIGNED_CERT or SELF_SIGNED_CERT_IN_CHAIN // into a clearer error message. err = new SecurityError('SSL Failure: certificate verify failed'); } else if (/CERT_HAS_EXPIRED/.test(err.code)) { // Convert CERT_HAS_EXPIRED into a clearer error message. err = new SecurityError('SSL Failure: certificate verify failed ' + '- certificate has expired'); } else if (/Hostname\/IP doesn't match certificate's altnames/.test( err)) { err = new SecurityError(err); } else if (/mac verify failure/.test(err)) { err = new SecurityError('SSL Failure: ' + err + ' (likely due to a keystore access failure)'); } else if (/wrong tag/.test(err)) { err = new SecurityError('SSL Failure: ' + err + ' (likely due to the specified keystore being invalid)'); } else if (/bad decrypt/.test(err)) { err = new SecurityError('SSL Failure: ' + err + ' (likely due to the specified passphrase being wrong)'); } else if (/no start line/.test(err)) { err = new SecurityError('SSL Failure: ' + err + ' (likely due to an invalid certificate PEM file being ' + 'specified)'); } else if (err instanceof AMQP.Errors.AuthenticationError) { err = new SecurityError('sasl authentication failed'); } else if (err.condition === 'amqp:precondition-failed' || err.condition === 'amqp:resource-limit-exceeded' || err.condition === 'amqp:not-allowed' || err.condition === 'amqp:link:detach-forced' || err.condition === 'amqp:link:message-size-exceeded' || err.condition === 'amqp:not-implemented') { if (/to a different adapter/.test(err.description)) { err = new NetworkError(err.description); } else { err = new NotPermittedError(err.description); } } else if (err.condition === 'amqp:unauthorized-access') { err = new SecurityError(err.description); } else if (err.condition === 'amqp:link:stolen') { err = new ReplacedError(err.description); } return err; }; if (process.env.NODE_ENV === 'unittest') { /** * Export for unittest purposes. */ exports.processMessage = processMessage; exports.reconnect = reconnect; } /** * Represents an MQ Light client instance. * * @param {String|Array|Function} * service - Required; One or more URLs representing the TCP/IP * endpoints to which the client will attempt to connect, in turn. * When a function is specified, it is invoked each time an endpoint * is required and is supplied a callback, in the form * function(err, service), as its only argument. The function should * invoke the callback supplying a URL String (or an Array of URL * strings) as the second argument. * @param {String} * id - Optional; an identifier that is used to identify this client. * If omitted - a probabilistically unique ID will be generated. * @param {Object} * securityOptions - Any required security options for * user name/password authentication and SSL. * @throws {TypeError} * If one of the specified parameters in of the wrong type. * @throws {RangeError} * If the specified id is too long. * @throws {Error} * If service is not specified or one of the parameters is * incorrectly formatted. * @constructor */ var Client = function(service, id, securityOptions) { /** The current service being used */ var _service; /** The current state */ var _state; /** The client identifier */ var _id; /* * Internal helper function for public methods, to return the current value of * _service, regardless of state. */ this._getService = function() { return _service; }; /* * Internal helper function for public methods, to be able to change the state * TODO - Ideally we should not have this (i.e. methods that change state * should be defined in the constructor. */ this._setState = function(value) { if (_state !== value) { logger.log('data', _id, 'Client.state', value); _state = value; } }; /** * @return {String} The URL of the service to which the client is currently * connected (when the client is in 'started' state) - otherwise (for * all other client states) undefined is returned. */ Object.defineProperty(this, 'service', { get: function() { return _state === STATE_STARTED ? _service : undefined; }, set: function(value) { if (process.env.NODE_ENV === 'unittest') { _service = value; } } }); /** * @return {String} The identifier associated with the client. This will * either be: a) the identifier supplied as the id property of the * options object supplied to the mqlight.createClient() method, or b) * an automatically generated identifier if the id property was not * specified when the client was created. */ Object.defineProperty(this, 'id', { get: function() { return _id; } }); /** * @return {String} The current state of the client - can will be one of the * following string values: 'started', 'starting', 'stopped', * 'stopping', or 'retrying'. */ Object.defineProperty(this, 'state', { get: function() { return _state; } }); logger.entry('Client.constructor', logger.NO_CLIENT_ID); logger.log('parms', logger.NO_CLIENT_ID, 'service:', String(service).replace(/:[^/:]+@/g, ':********@')); logger.log('parms', logger.NO_CLIENT_ID, 'id:', id); logger.log('parms', logger.NO_CLIENT_ID, 'securityOptions:', securityOptions.toString()); EventEmitter.call(this); var msg; var err; // Ensure the service is an Array or Function var serviceFunction; if (service instanceof Function) { serviceFunction = service; } else if (typeof service === 'string') { var serviceUrl = url.parse(service); if (serviceUrl.protocol === 'http:' || serviceUrl.protocol === 'https:') { serviceFunction = getHttpServiceFunction(serviceUrl); } else if (serviceUrl.protocol === 'file:') { if (serviceUrl.host.length > 0 && serviceUrl.host !== 'localhost') { msg = 'service contains unsupported file URI of ' + service + ', only file:///path or file://localhost/path are supported.'; err = new InvalidArgumentError(msg); logger.throw('Client.constructor', logger.NO_CLIENT_ID, err); throw err; } serviceFunction = getFileServiceFunction(serviceUrl.path); } } // Add generateServiceList function to client with embedded securityOptions this._generateServiceList = function(service) { logger.entry('_generateServiceList', _id); logger.log('parms', _id, 'service:', String(service).replace(/:[^/:]+@/g, ':********@')); logger.log('parms', _id, 'securityOptions:', securityOptions.toString()); var err; // Ensure the service is an Array var inputServiceList = []; if (!service) { err = new TypeError('service is undefined'); logger.throw('_generateServiceList', _id, err); throw err; } else if (service instanceof Function) { err = new TypeError('service cannot be a function'); logger.throw('_generateServiceList', _id, err); throw err; } else if (service instanceof Array) { if (service.length === 0) { err = new TypeError('service array is empty'); logger.throw('_generateServiceList', _id, err); throw err; } inputServiceList = service; } else if (typeof service === 'string') { inputServiceList[0] = service; } else { err = new TypeError('service must be a string or array type'); logger.throw('_generateServiceList', _id, err); throw err; } /* * Validate the list of URLs for the service, inserting default values as * necessary Expected format for each URL is: amqp://host:port or * amqps://host:port (port is optional, defaulting to 5672 or 5671 as * appropriate) */ var serviceList = []; var authUser; var authPassword; var msg; for (var i = 0; i < inputServiceList.length; i++) { var serviceUrl = url.parse(inputServiceList[i]); var protocol = serviceUrl.protocol; // check for auth details var auth = serviceUrl.auth; authUser = undefined; authPassword = undefined; if (auth) { if (auth.indexOf(':') >= 0) { authUser = String(auth).slice(0, auth.indexOf(':')); authPassword = String(auth).slice(auth.indexOf(':') + 1); } else { msg = 'URLs supplied via the \'service\' property must specify ' + 'both a user name and a password value, or omit both values'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } if (securityOptions.propertyUser && authUser && (securityOptions.propertyUser !== authUser)) { msg = 'User name supplied as \'user\' property (' + securityOptions.propertyUser + ') does not match user name ' + 'supplied via a URL passed via the \'service\' property (' + authUser + ')'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } if (securityOptions.propertyPassword && authPassword && (securityOptions.propertyPassword !== authPassword)) { msg = 'Password supplied as \'password\' property does not match a ' + 'password supplied via a URL passed via the \'service\' ' + 'property'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } if (i === 0) { securityOptions.urlUser = authUser; securityOptions.urlPassword = authPassword; } } // Check whatever URL user names / passwords are present this time // through the loop - match the ones set on securityOptions by the first // pass through the loop. if (i > 0) { if (securityOptions.urlUser !== authUser) { msg = 'URLs supplied via the \'service\' property contain ' + 'inconsistent user names'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } else if (securityOptions.urlPassword !== authPassword) { msg = 'URLs supplied via the \'service\' property contain ' + 'inconsistent password values'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } } // Check we are trying to use the amqp protocol if (!protocol || (protocol !== 'amqp:' && protocol !== 'amqps:')) { msg = 'Unsupported URL \'' + inputServiceList[i] + '\' specified for service. Only the amqp or amqps protocol are ' + 'supported.'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } // Check we have a hostname var host = serviceUrl.host; if (!host || !serviceUrl.hostname) { msg = 'Unsupported URL \' ' + inputServiceList[i] + '\' specified ' + 'for service. Must supply a hostname.'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } // Set default port if not supplied var port = serviceUrl.port; if (!port) { port = (protocol === 'amqp:') ? '5672' : '5671'; } // Check for no path var path = serviceUrl.path; if (path && path !== '/') { msg = 'Unsupported URL \'' + inputServiceList[i] + '\' paths (' + path + ' ) can\'t be part of a service URL.'; err = new InvalidArgumentError(msg); logger.throw('_generateServiceList', _id, err); throw err; } serviceList[i] = protocol + '//' + host; if (!serviceUrl.port) { serviceList[i] += ':' + port; } } logger.exit('_generateServiceList', _id, [ 'serviceList:', String(serviceList).replace(/:[^/:]+@/g, ':********@'), 'securityOptions:', securityOptions.toString() ]); return serviceList; }; /** * Function to invoke all callbacks waiting on a started event. * * @param {Error} err Set if an error occurred while starting. */ this._invokeStartedCallbacks = function(err) { var client = this; logger.entry('Client._invokeStartedCallbacks', _id); var callbacks = client._queuedStartCallbacks.length; logger.log('debug', _id, 'callbacks:', callbacks); for (var i = 0; i < callbacks; i++) { var invocation = client._queuedStartCallbacks.shift(); if (invocation.callback) { logger.entry('Client._invokeStartedCallbacks.callback', client.id, invocation.create); if (invocation.create) { invocation.callback.apply(client, [err ? err : null, client]); } else { invocation.callback.apply(client, [err]); } logger.exit('Client._invokeStartedCallbacks.callback', client.id, null); } else { logger.ffdc('Client._invokeStartedCallbacks', 'ffdc001', client, 'No callback provided'); } } logger.exit('Client._invokeStartedCallbacks', _id, null); }; // performs the connect this._performConnect = function(newClient, retrying) { var client = this; logger.entry('Client._performConnect', _id, newClient); // If there is no active client (i.e. we've been stopped) then add // ourselves back to the active list. Otherwise if there is another // active client (that's replaced us) then exit function now var activeClient = activeClientList.get(_id); if (activeClient === undefined) { logger.log('debug', _id, 'Adding client to active list, as there' + ' is no currently active client'); activeClientList.add(_id); } else if (client !== activeClient) { logger.log('debug', _id, 'Not connecting because client has been replaced'); if (!client.isStopped()) { logger.ffdc('Client._performConnect', 'ffdc005', client, 'Replaced client not in stopped state'); } client._i