mqlight-dev
Version:
IBM MQ Light Client Module
1,392 lines (1,284 loc) • 119 kB
JavaScript
/* %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