UNPKG

amqp

Version:
897 lines (759 loc) 27.8 kB
'use strict'; var net = require('net'); var tls = require('tls'); var fs = require('fs'); var URL = require('url'); var _ = require('lodash'); var debug = require('./debug'); var EventEmitter = require('events').EventEmitter; var util = require('util'); var serializer = require('./serializer'); var definitions = require('./definitions'); var methods = definitions.methods; var methodTable = definitions.methodTable; var classes = definitions.classes; var Exchange = require('./exchange'); var Queue = require('./queue'); var AMQPParser = require('./parser'); var nodeAMQPVersion = require('../package').version; var maxFrameBuffer = 131072; // 128k, same as rabbitmq (which was // copying qpid) var channelMax = 65535; var defaultPorts = { 'amqp': 5672, 'amqps': 5671 }; var defaultOptions = { host: 'localhost', port: defaultPorts['amqp'], login: 'guest', password: 'guest', authMechanism: 'AMQPLAIN', vhost: '/', connectionTimeout: 10000, ssl: { enabled: false } }; var defaultSslOptions = { port: defaultPorts['amqps'], ssl: { rejectUnauthorized: true } }; var defaultImplOptions = { defaultExchangeName: '', reconnect: true, reconnectBackoffStrategy: 'linear', reconnectExponentialLimit: 120000, reconnectBackoffTime: 1000 }; var defaultClientProperties = { version: nodeAMQPVersion, platform: 'node-' + process.version, product: 'node-amqp' }; var Connection = module.exports = function Connection (connectionArgs, options, readyCallback) { EventEmitter.call(this); this.setOptions(connectionArgs); this.setImplOptions(options); if (typeof readyCallback === 'function') { this._readyCallback = readyCallback; } this.connectionAttemptScheduled = false; this._defaultExchange = null; this.channelCounter = 0; this._sendBuffer = new Buffer(maxFrameBuffer); this._blocked = false; this._blockedReason = null; }; util.inherits(Connection, EventEmitter); Connection.prototype.setOptions = function (options) { var urlo = (options && options.url) ? this._parseURLOptions(options.url) : {}; var sslo = (options && options.ssl && options.ssl.enabled) ? defaultSslOptions : {}; this.options = _.assignIn({}, defaultOptions, sslo, urlo, options || {}); this.options.clientProperties = _.assignIn({}, defaultClientProperties, (options && options.clientProperties) || {}); }; Connection.prototype.setImplOptions = function (options) { this.implOptions = _.assignIn({}, defaultImplOptions, options || {}); }; Connection.prototype.connect = function () { // If this is our first connection, add listeners. if (!this.socket) this.addAllListeners(); this._createSocket(); this._startHandshake(); }; Connection.prototype.reconnect = function () { // Suspend activity on channels for (var channel in this.channels) { this.channels[channel].state = 'closed'; } debug && debug("Connection lost, reconnecting..."); // Terminate socket activity if (this.socket) this.socket.end(); this.connect(); }; Connection.prototype.disconnect = function () { debug && debug("Sending disconnect request to server"); this._sendMethod(0, methods.connectionClose, { 'replyText': 'client disconnect', 'replyCode': 200, 'classId': 0, 'methodId': 0 }); }; Connection.prototype.addAllListeners = function() { var self = this; var connectEvent = this.options.ssl.enabled ? 'secureConnect' : 'connect'; self.addListener(connectEvent, function() { // In the case where this is a reconnection, do not trample on the existing // channels. // For your reference, channel 0 is the control channel. self.channels = self.channels || {0:self}; self.queues = self.queues || {}; self.exchanges = self.exchanges || {}; self.parser = new AMQPParser('0-9-1', 'client'); self.parser.onMethod = function (channel, method, args) { self._onMethod(channel, method, args); }; self.parser.onContent = function (channel, data) { debug && debug(channel + " > content " + data.length); if (self.channels[channel] && self.channels[channel]._onContent) { self.channels[channel]._onContent(channel, data); } else { debug && debug("unhandled content: " + data); } }; self.parser.onContentHeader = function (channel, classInfo, weight, properties, size) { debug && debug(channel + " > content header " + JSON.stringify([classInfo.name, weight, properties, size])); if (self.channels[channel] && self.channels[channel]._onContentHeader) { self.channels[channel]._onContentHeader(channel, classInfo, weight, properties, size); } else { debug && debug("unhandled content header"); } }; self.parser.onHeartBeat = function () { self.emit("heartbeat"); debug && debug("heartbeat"); }; self.parser.onError = function (e) { self.emit("error", e); self.emit("close"); }; // Remove readyEmitted flag so we can detect an auth error. self.readyEmitted = false; }); self.addListener('data', function (data) { if(self.parser != null){ try { self.parser.execute(data); } catch (exception) { self.emit('error', exception); return; } } self._inboundHeartbeatTimerReset(); }); var backoffTime = null; self.addListener('error', function backoff(e) { if (self._inboundHeartbeatTimer !== null) { clearTimeout(self._inboundHeartbeatTimer); self._inboundHeartbeatTimer = null; } if (self._outboundHeartbeatTimer !== null) { clearTimeout(self._outboundHeartbeatTimer); self._outboundHeartbeatTimer = null; } if (!self.connectionAttemptScheduled) { // Set to true, as we are presently in the process of scheduling one. self.connectionAttemptScheduled = true; // Kill the socket, if it hasn't been killed already. self.socket.end(); // Reset parser state self.parser = null; // In order for our reconnection to be seamless, we have to notify the // channels that they are no longer connected so that nobody attempts // to send messages which would be doomed to fail. for (var channel in self.channels) { if (channel !== '0') { self.channels[channel].state = 'closed'; } } // Queues are channels (so we have already marked them as closed), but // queues have special needs, since the subscriptions will no longer // be known to the server when we reconnect. Mark the subscriptions as // closed so that we can resubscribe them once we are reconnected. for (var queue in self.queues) { for (var index in self.queues[queue].consumerTagOptions) { self.queues[queue].consumerTagOptions[index]['state'] = 'closed'; } } // Begin reconnection attempts if (self.implOptions.reconnect) { // Don't thrash, use a backoff strategy. if (backoffTime === null) { // This is the first time we've failed since a successful connection, // so use the configured backoff time without any modification. backoffTime = self.implOptions.reconnectBackoffTime; } else if (self.implOptions.reconnectBackoffStrategy === 'exponential') { // If you've configured exponential backoff, we'll double the // backoff time each subsequent attempt until success. backoffTime *= 2; // limit the maxium timeout, to avoid potentially unlimited stalls if(backoffTime > self.implOptions.reconnectExponentialLimit){ backoffTime = self.implOptions.reconnectExponentialLimit; } } else if (self.implOptions.reconnectBackoffStrategy === 'linear') { // Linear strategy is the default. In this case, we will retry at a // constant interval, so there's no need to change the backoff time // between attempts. } else { // TODO should we warn people if they picked a nonexistent strategy? } setTimeout(function () { // Set to false, so that if we fail in the reconnect attempt, we can // schedule another one. self.connectionAttemptScheduled = false; self.reconnect(); }, backoffTime); } else { self.removeListener('error', backoff); } } }); self.addListener('ready', function () { // Reset the backoff time since we have successfully connected. backoffTime = null; if (self.implOptions.reconnect) { // Reconnect any channels which were open. _.forEach(self.channels, function(channel, index) { if (index !== '0') channel.reconnect(); }); } // Set 'ready' flag for auth failure detection. this.readyEmitted = true; // Restart the heartbeat to the server self._outboundHeartbeatTimerReset(); }); // Apparently, it is not possible to determine if an authentication error // has occurred, but when the connection closes then we can HINT that a // possible authentication error has occured. Although this may be a bug // in the spec, handling it as a possible error is considerably better than // failing silently. self.addListener('end', function (){ if (!this.readyEmitted){ this.emit('error', new Error( 'Connection ended: possibly due to an authentication failure.' )); } }); }; Connection.prototype.heartbeat = function () { if(this.socket.writable) this.write(new Buffer([8,0,0,0,0,0,0,206])); }; // connection.exchange('my-exchange', { type: 'topic' }); // Options // - type 'fanout', 'direct', or 'topic' (default) // - passive (boolean) // - durable (boolean) // - autoDelete (boolean, default true) Connection.prototype.exchange = function (name, options, openCallback) { if (name === undefined) name = this.implOptions.defaultExchangeName; if (!options) options = {}; if (name !== '' && options.type === undefined) options.type = 'topic'; try{ var channel = this.generateChannelId(); }catch(exception){ this.emit("error", exception); return; } var exchange = new Exchange(this, channel, name, options, openCallback); this.channels[channel] = exchange; this.exchanges[name] = exchange; return exchange; }; // remove an exchange when it's closed (called from Exchange) Connection.prototype.exchangeClosed = function (name) { if (this.exchanges[name]) delete this.exchanges[name]; }; // Options // - passive (boolean) // - durable (boolean) // - exclusive (boolean) // - autoDelete (boolean, default true) Connection.prototype.queue = function (name /* options, openCallback */) { var options, callback; if (typeof arguments[1] == 'object') { options = arguments[1]; callback = arguments[2]; } else { callback = arguments[1]; } try{ var channel = this.generateChannelId(); }catch(exception){ this.emit("error", exception); return; } var q = new Queue(this, channel, name, options, callback); this.channels[channel] = q; return q; }; // remove a queue when it's closed (called from Queue) Connection.prototype.queueClosed = function (name) { if (this.queues[name]) delete this.queues[name]; }; // Publishes a message to the default exchange. Connection.prototype.publish = function (routingKey, body, options, callback) { if (!this._defaultExchange) { this._defaultExchange = this.exchange(); } var exchange = this._defaultExchange; if (exchange.state === 'open') { exchange.publish(routingKey, body, options, callback); } else { exchange.once('open', function() { exchange.publish(routingKey, body, options, callback); }); } }; Connection.prototype._bodyToBuffer = function (body) { // Handles 3 cases // - body is utf8 string // - body is instance of Buffer // - body is an object and its JSON representation is sent // Does not handle the case for streaming bodies. // Returns buffer. if (typeof(body) == 'string') { return [null, new Buffer(body, 'utf8')]; } else if (body instanceof Buffer) { return [null, body]; } else { var jsonBody = JSON.stringify(body); debug && debug('sending json: ' + jsonBody); var props = {contentType: 'application/json'}; return [props, new Buffer(jsonBody, 'utf8')]; } }; Connection.prototype._inboundHeartbeatTimerReset = function () { if (this._inboundHeartbeatTimer !== null) { clearTimeout(this._inboundHeartbeatTimer); this._inboundHeartbeatTimer = null; } if (this.options.heartbeat) { var self = this; var gracePeriod = 2 * this.options.heartbeat; this._inboundHeartbeatTimer = setTimeout(function () { if(self.socket.readable || self.options.heartbeatForceReconnect){ self.emit('error', new Error('no heartbeat or data in last ' + gracePeriod + ' seconds')); } }, gracePeriod * 1000); } }; Connection.prototype._outboundHeartbeatTimerReset = function () { if (this._outboundHeartbeatTimer !== null) { clearTimeout(this._outboundHeartbeatTimer); this._outboundHeartbeatTimer = null; } if (this.socket.writable && this.options.heartbeat) { var self = this; this._outboundHeartbeatTimer = setTimeout(function () { self.heartbeat(); self._outboundHeartbeatTimerReset(); }, 1000 * this.options.heartbeat); } }; Connection.prototype._saslResponse = function () { var response; if (this.options.authMechanism == 'AMQPLAIN') response = { LOGIN: this.options.login, PASSWORD: this.options.password }; else if (this.options.authMechanism == 'PLAIN') response = "\0" + this.options.login + "\0" + this.options.password; else if (this.options.authMechanism == 'EXTERNAL') response = "\0"; else if (this.options.authMechanism == 'ANONYMOUS') response = "\0"; else response = this.options.response; return response; } Connection.prototype._onMethod = function (channel, method, args) { debug && debug(channel + " > " + method.name + " " + JSON.stringify(args)); // Channel 0 is the control channel. If not zero then delegate to // one of the channel objects. if (channel > 0) { if (!this.channels[channel]) { debug && debug("Received message on untracked channel."); return; } if (!this.channels[channel]._onChannelMethod) { throw new Error('Channel ' + channel + ' has no _onChannelMethod method.'); } this.channels[channel]._onChannelMethod(channel, method, args); return; } // channel 0 switch (method) { // 2. The server responds, after the version string, with the // 'connectionStart' method (contains various useless information) case methods.connectionStart: // We check that they're serving us AMQP 0-9 if (args.versionMajor !== 0 && args.versionMinor != 9) { this.socket.end(); this.emit('error', new Error("Bad server version")); return; } this.serverProperties = args.serverProperties; // 3. Then we reply with StartOk, containing our useless information. this._sendMethod(0, methods.connectionStartOk, { clientProperties: this.options.clientProperties, mechanism: this.options.authMechanism, response: this._saslResponse(), locale: 'en_US' }); break; // 4. The server responds with a connectionTune request case methods.connectionTune: if (args.frameMax) { debug && debug("tweaking maxFrameBuffer to " + args.frameMax); maxFrameBuffer = args.frameMax; this._sendBuffer = new Buffer(maxFrameBuffer); this.parser.setMaxFrameBuffer(maxFrameBuffer); } if (args.channelMax) { debug && debug("tweaking channelMax to " + args.channelMax); channelMax = args.channelMax; } // 5. We respond with connectionTuneOk this._sendMethod(0, methods.connectionTuneOk, { channelMax: channelMax, frameMax: maxFrameBuffer, heartbeat: this.options.heartbeat || 0 }); // 6. Then we have to send a connectionOpen request this._sendMethod(0, methods.connectionOpen, { virtualHost: this.options.vhost // , capabilities: '' // , insist: true , reserved1: '', reserved2: true }); break; case methods.connectionOpenOk: // 7. Finally they respond with connectionOpenOk // Whew! That's why they call it the Advanced MQP. if (this._readyCallback) { this._readyCallback(this); this._readyCallback = null; } this.emit('ready'); break; case methods.connectionClose: var e = new Error(args.replyText); e.code = args.replyCode; if (!this.listeners('close').length) { console.log('Unhandled connection error: ' + args.replyText); } this.socket.destroy(e); break; case methods.connectionCloseOk: debug && debug("Received close-ok from server, closing socket"); this.socket.end(); this.socket.destroy(); break; case methods.connectionBlocked: debug && debug('Received connection.blocked from server with reason: ' + args.reason); this._blocked = true; this._blockedReason = args.reason; this.emit('blocked'); break; case methods.connectionUnblocked: debug && debug('Received connection.unblocked from server'); this._blocked = false; this._blockedReason = null; this.emit('unblocked'); break; default: throw new Error("Uncaught method '" + method.name + "' with args " + JSON.stringify(args)); } }; // Generate connection options from URI string formatted with amqp scheme. Connection.prototype._parseURLOptions = function(connectionString) { var opts = {}; opts.ssl = {}; var url = URL.parse(connectionString); var scheme = url.protocol.substring(0, url.protocol.lastIndexOf(':')); if (scheme != 'amqp' && scheme != 'amqps') { throw new Error('Connection URI must use amqp or amqps scheme. ' + 'For example, "amqp://bus.megacorp.internal:5766".'); } opts.ssl.enabled = ('amqps' === scheme); opts.host = url.hostname; opts.port = url.port || defaultPorts[scheme]; if (url.auth) { var auth = url.auth.split(':'); auth[0] && (opts.login = auth[0]); auth[1] && (opts.password = auth[1]); } if (url.pathname) { opts.vhost = unescape(url.pathname.substr(1)); } return opts; }; /* * * Connect helpers * */ // If you pass a array of hosts, lets choose a random host or the preferred host number, or then next one. Connection.prototype._chooseHost = function() { if(Array.isArray(this.options.host)){ if(this.hosti == null){ if(typeof this.options.hostPreference == 'number') { this.hosti = (this.options.hostPreference < this.options.host.length) ? this.options.hostPreference : this.options.host.length-1; } else { this.hosti = parseInt(Math.random() * this.options.host.length, 10); } } else { // If this is already set, it looks like we want to choose another one. // Add one to hosti but don't overflow it. this.hosti = (this.hosti + 1) % this.options.host.length; } return this.options.host[this.hosti]; } else { return this.options.host; } }; Connection.prototype._createSocket = function() { var hostName = this._chooseHost(), self = this, port = this.options.port; var parsedHost = URL.parse(hostName); if(parsedHost.port){ hostName = parsedHost.hostname; port = parsedHost.port; } var options = { port: port, host: hostName }; // Disable tcp nagle's algo // Default: true, makes small messages faster var noDelay = this.options.noDelay || true; var resetConnectionTimeout = function () { debug && debug('connected so resetting connection timeout'); this.setTimeout(0); }; // Connect socket if (this.options.ssl.enabled) { debug && debug('making ssl connection'); options = _.assignIn(options, this._getSSLOptions()); this.socket = tls.connect(options, resetConnectionTimeout); } else { debug && debug('making non-ssl connection'); this.socket = net.connect(options, resetConnectionTimeout); } var connTimeout = this.options.connectionTimeout; if (connTimeout) { debug && debug('setting connection timeout to ' + connTimeout); this.socket.setTimeout(connTimeout, function () { debug && debug('connection timeout'); this.destroy(); var e = new Error('connection timeout'); e.name = 'TimeoutError'; self.emit('error', e); }); } this.socket.setNoDelay(noDelay); // Proxy events. // Note that if we don't attach a 'data' event, no data will flow. var events = ['close', 'connect', 'data', 'drain', 'error', 'end', 'secureConnect', 'timeout']; _.forEach(events, function(event){ self.socket.on(event, self.emit.bind(self, event)); }); // Proxy a few methods that we use / previously used. var methods = ['destroy', 'write', 'pause', 'resume', 'setEncoding', 'ref', 'unref', 'address']; _.forEach(methods, function(method){ self[method] = function(){ self.socket[method].apply(self.socket, arguments); }; }); }; Connection.prototype.end = function() { if (this.socket) { this.socket.end(); } this.options.heartbeat = false; if (this._inboundHeartbeatTimer !== null) { clearTimeout(this._inboundHeartbeatTimer); this._inboundHeartbeatTimer = null; } if (this._outboundHeartbeatTimer !== null) { clearTimeout(this._outboundHeartbeatTimer); this._outboundHeartbeatTimer = null; } }; Connection.prototype._getSSLOptions = function() { if (this.sslConnectionOptions) return this.sslConnectionOptions; this.sslConnectionOptions = {}; if (this.options.ssl.pfxFile) { this.sslConnectionOptions.pfx = fs.readFileSync(this.options.ssl.pfxFile); } if (this.options.ssl.keyFile) { this.sslConnectionOptions.key = fs.readFileSync(this.options.ssl.keyFile); } if (this.options.ssl.certFile) { this.sslConnectionOptions.cert = fs.readFileSync(this.options.ssl.certFile); } if (this.options.ssl.caFile) { if (Array.isArray(this.options.ssl.caFile)) { this.sslConnectionOptions.ca = this.options.ssl.caFile.map(function(f){ return fs.readFileSync(f); }); } else { this.sslConnectionOptions.ca = fs.readFileSync(this.options.ssl.caFile); } } this.sslConnectionOptions.rejectUnauthorized = this.options.ssl.rejectUnauthorized; this.sslConnectionOptions.passphrase = this.options.ssl.passphrase; if (this.options.ssl.ciphers) { this.sslConnectionOptions.ciphers = this.options.ssl.ciphers; } if (this.options.ssl.secureProtocol) { this.sslConnectionOptions.secureProtocol = this.options.ssl.secureProtocol; } return this.sslConnectionOptions; }; // Time to start the AMQP 7-way connection initialization handshake! // 1. The client sends the server a version string Connection.prototype._startHandshake = function() { debug && debug("Initiating handshake..."); this.write("AMQP" + String.fromCharCode(0,0,9,1)); }; /* * * Parse helpers * */ Connection.prototype._sendBody = function (channel, body, properties) { var r = this._bodyToBuffer(body); var props = r[0], buffer = r[1]; properties = _.assignIn(props || {}, properties); this._sendHeader(channel, buffer.length, properties); var pos = 0, len = buffer.length; var metaSize = 8; // headerBytes = 7, frameEndBytes = 1 var maxBodySize = maxFrameBuffer - metaSize; while (len > 0) { var bodySize = len < maxBodySize ? len : maxBodySize; var frameSize = bodySize + metaSize; var b = new Buffer(frameSize); b.used = 0; b[b.used++] = 3; // constants.frameBody serializer.serializeInt(b, 2, channel); serializer.serializeInt(b, 4, bodySize); buffer.copy(b, b.used, pos, pos+bodySize); b.used += bodySize; b[b.used++] = 206; // constants.frameEnd; this.write(b); len -= bodySize; pos += bodySize; } return; }; // connection: the connection // channel: the channel to send this on // size: size in bytes of the following message // properties: an object containing any of the following: // - contentType (default 'application/octet-stream') // - contentEncoding // - headers // - deliveryMode // - priority (0-9) // - correlationId // - replyTo // - expiration // - messageId // - timestamp // - userId // - appId // - clusterId Connection.prototype._sendHeader = function(channel, size, properties) { var b = new Buffer(maxFrameBuffer); // FIXME allocating too much. // use freelist? b.used = 0; var classInfo = classes[60]; // always basic class. // 7 OCTET FRAME HEADER b[b.used++] = 2; // constants.frameHeader serializer.serializeInt(b, 2, channel); var lengthStart = b.used; serializer.serializeInt(b, 4, 0 /*dummy*/); // length var bodyStart = b.used; // HEADER'S BODY serializer.serializeInt(b, 2, classInfo.index); // class 60 for Basic serializer.serializeInt(b, 2, 0); // weight, always 0 for rabbitmq serializer.serializeInt(b, 8, size); // byte size of body // properties - first propertyFlags properties = _.defaults(properties || {}, {contentType: 'application/octet-stream'}); var propertyFlags = 0; for (var i = 0; i < classInfo.fields.length; i++) { if (properties[classInfo.fields[i].name]) propertyFlags |= 1 << (15-i); } serializer.serializeInt(b, 2, propertyFlags); // now the actual properties. serializer.serializeFields(b, classInfo.fields, properties, false); //serializeTable(b, properties); var bodyEnd = b.used; // Go back to the header and write in the length now that we know it. b.used = lengthStart; serializer.serializeInt(b, 4, bodyEnd - bodyStart); b.used = bodyEnd; // 1 OCTET END b[b.used++] = 206; // constants.frameEnd; var s = new Buffer(b.used); b.copy(s); //debug && debug('header sent: ' + JSON.stringify(s)); this.write(s); }; Connection.prototype._sendMethod = function (channel, method, args) { debug && debug(channel + " < " + method.name + " " + JSON.stringify(args)); var b = this._sendBuffer; b.used = 0; b[b.used++] = 1; // constants.frameMethod serializer.serializeInt(b, 2, channel); var lengthIndex = b.used; serializer.serializeInt(b, 4, 42); // replace with actual length. var startIndex = b.used; serializer.serializeInt(b, 2, method.classIndex); // short, classId serializer.serializeInt(b, 2, method.methodIndex); // short, methodId serializer.serializeFields(b, method.fields, args, true); var endIndex = b.used; // write in the frame length now that we know it. b.used = lengthIndex; serializer.serializeInt(b, 4, endIndex - startIndex); b.used = endIndex; b[b.used++] = 206; // constants.frameEnd; var c = new Buffer(b.used); b.copy(c); debug && debug("sending frame: " + c.toJSON()); this.write(c); this._outboundHeartbeatTimerReset(); }; // tries to find the next available id slot for a channel Connection.prototype.generateChannelId = function () { // start from the last used slot id var channelId = this.channelCounter; while(true){ // use values in range of 1..65535 channelId = channelId % channelMax + 1; if(!this.channels[channelId]){ break; } // after a full loop throw an Error if(channelId == this.channelCounter){ throw new Error("No valid Channel Id values available"); } } this.channelCounter = channelId; return this.channelCounter; };