UNPKG

rabbitmq-client

Version:
633 lines (632 loc) 27.6 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Connection = void 0; const node_net_1 = __importDefault(require("node:net")); const node_tls_1 = __importDefault(require("node:tls")); const node_events_1 = __importDefault(require("node:events")); const exception_1 = require("./exception"); const util_1 = require("./util"); const codec_1 = require("./codec"); const Channel_1 = require("./Channel"); const normalize_1 = __importDefault(require("./normalize")); const SortedMap_1 = __importDefault(require("./SortedMap")); const Consumer_1 = require("./Consumer"); const RPCClient_1 = require("./RPCClient"); /** @internal */ function raceWithTimeout(promise, ms, msg) { let timer; return Promise.race([ promise, new Promise((resolve, reject) => timer = setTimeout(() => reject(new exception_1.AMQPError('TIMEOUT', msg)), ms)) ]).finally(() => { clearTimeout(timer); }); } const CLIENT_PROPERTIES = { product: 'rabbitmq-client', version: '5.0.5', platform: `node.js-${process.version}`, capabilities: { 'basic.nack': true, 'connection.blocked': true, publisher_confirms: true, exchange_exchange_bindings: true, // https://www.rabbitmq.com/consumer-cancel.html consumer_cancel_notify: true, // https://www.rabbitmq.com/auth-notification.html authentication_failure_close: true, } }; /** * This represents a single connection to a RabbitMQ server (or cluster). Once * created, it will immediately attempt to establish a connection. When the * connection is lost, for whatever reason, it will reconnect. This implements * the EventEmitter interface and may emit `error` events. Close it with * {@link Connection#close | Connection#close()} * * @example * ``` * const rabbit = new Connection('amqp://guest:guest@localhost:5672') * rabbit.on('error', (err) => { * console.log('RabbitMQ connection error', err) * }) * rabbit.on('connection', () => { * console.log('RabbitMQ (re)connected') * }) * process.on('SIGINT', () => { * rabbit.close() * }) * ``` */ class Connection extends node_events_1.default { /** @internal */ _opt; /** @internal */ _socket; /** @internal */ _state; constructor(propsOrUrl) { super(); this._connect = this._connect.bind(this); this._opt = (0, normalize_1.default)(propsOrUrl); this._state = { channelMax: this._opt.maxChannels, frameMax: this._opt.frameMax, onEmpty: (0, util_1.createDeferred)(), // ignore unhandled rejection e.g. no one is waiting for a channel onConnect: (0, util_1.createDeferred)(true), connectionTimer: undefined, hostIndex: 0, leased: new SortedMap_1.default(), readyState: util_1.READY_STATE.CONNECTING, retryCount: 1, retryTimer: undefined }; this._socket = this._connect(); } /** * Allocate and return a new AMQP Channel. You MUST close the channel * yourself. Will wait for connect/reconnect when necessary. */ async acquire(opt) { if (this._state.readyState >= util_1.READY_STATE.CLOSING) throw new exception_1.AMQPConnectionError('CLOSING', 'channel creation failed; connection is closing'); if (this._state.readyState === util_1.READY_STATE.CONNECTING) { // TODO also wait for connection.unblocked await raceWithTimeout(this._state.onConnect.promise, this._opt.acquireTimeout, 'channel aquisition timed out').catch(util_1.recaptureAndThrow); } // choosing an available channel id from this SortedMap is certainly slower // than incrementing a counter from 1 to MAX_CHANNEL_ID. However // this method allows for safely reclaiming old IDs once MAX_CHANNEL_ID+1 // channels have been created. Also this function runs in O(log n) time // where n <= 0xffff. Which means ~16 tree nodes in the worst case. So it // shouldn't be noticable. And who needs that many Channels anyway!? const id = this._state.leased.pick(); if (id > this._state.channelMax) throw new Error(`maximum number of AMQP Channels already opened (${this._state.channelMax})`); const ch = new Channel_1.Channel(id, this, opt?.emitErrorsFromChannel); this._state.leased.set(id, ch); ch.once('close', () => { this._state.leased.delete(id); this._checkEmpty(); }); await ch._invoke(codec_1.Cmd.ChannelOpen, codec_1.Cmd.ChannelOpenOK, { rsvp1: '' }); return ch; } /** * Wait for channels to close and then end the connection. Will not * automatically close any channels, giving you the chance to ack/nack any * outstanding messages while preventing new channels. */ async close() { if (this._state.readyState === util_1.READY_STATE.CLOSED) return; if (this._state.readyState === util_1.READY_STATE.CLOSING) return new Promise(resolve => this._socket.once('close', resolve)); if (this._state.readyState === util_1.READY_STATE.CONNECTING) { this._state.readyState = util_1.READY_STATE.CLOSING; if (this._state.retryTimer) clearTimeout(this._state.retryTimer); this._state.retryTimer = undefined; this._state.onConnect.reject(new exception_1.AMQPConnectionError('CLOSING', 'channel creation failed; connection is closing')); this._socket.destroy(); return; } this._state.readyState = util_1.READY_STATE.CLOSING; if (this._state.lazyChannel instanceof Promise) { this._state.lazyChannel.then(ch => ch.close()); } else if (this._state.lazyChannel) { this._state.lazyChannel.close(); } this._checkEmpty(); // wait for all channels to close await this._state.onEmpty.promise; clearInterval(this._state.heartbeatTimer); this._state.heartbeatTimer = undefined; // might have transitioned to CLOSED while waiting for channels if (this._socket.writable) { this._writeMethod({ type: codec_1.FrameType.METHOD, channelId: 0, methodId: codec_1.Cmd.ConnectionClose, params: { replyCode: 200, methodId: 0, replyText: '' } }); this._socket.end(); await new Promise(resolve => this._socket.once('close', resolve)); } } /** Immediately destroy the connection. All channels are closed. All pending * actions are rejected. */ unsafeDestroy() { if (this._state.readyState === util_1.READY_STATE.CLOSED) return; // CLOSING, CONNECTING, OPEN this._state.readyState = util_1.READY_STATE.CLOSING; if (this._state.retryTimer) clearTimeout(this._state.retryTimer); this._state.retryTimer = undefined; this._state.onConnect.reject(new exception_1.AMQPConnectionError('CLOSING', 'channel creation failed; connection is closing')); this._socket.destroy(); } /** Create a message consumer that can recover from dropped connections. * @param cb Process an incoming message. */ createConsumer(props, cb) { return new Consumer_1.Consumer(this, props, cb); } /** This will create a single "client" `Channel` on which you may publish * messages and listen for direct responses. This can allow, for example, two * micro-services to communicate with each other using RabbitMQ as the * middleman instead of directly via HTTP. */ createRPCClient(props) { return new RPCClient_1.RPCClient(this, props || {}); } /** * Create a message publisher that can recover from dropped connections. * This will create a dedicated Channel, declare queues, declare exchanges, * and declare bindings. If the connection is reset, then all of this setup * will rerun on a new Channel. This also supports retries. */ createPublisher(props = {}) { let _ch; let pendingSetup; let isClosed = false; const maxAttempts = props.maxAttempts || 1; const emitter = new node_events_1.default(); const setup = async () => { const ch = await this.acquire(); ch.on('basic.return', (msg) => emitter.emit('basic.return', msg)); if (props.queues) for (const params of props.queues) { await ch.queueDeclare(params); } if (props.exchanges) for (const params of props.exchanges) { await ch.exchangeDeclare(params); } if (props.queueBindings) for (const params of props.queueBindings) { await ch.queueBind(params); } if (props.exchangeBindings) for (const params of props.exchangeBindings) { await ch.exchangeBind(params); } if (props.confirm) await ch.confirmSelect(); _ch = ch; return ch; }; const send = async (envelope, body) => { let attempts = 0; while (true) try { if (isClosed) throw new exception_1.AMQPChannelError('CLOSED', 'publisher is closed'); if (!_ch?.active) { if (!pendingSetup) pendingSetup = setup().finally(() => { pendingSetup = undefined; }); _ch = await pendingSetup; } return await _ch.basicPublish(envelope, body); } catch (err) { Error.captureStackTrace(err); // original async trace is likely not useful to users if (++attempts >= maxAttempts) { throw err; } else { // notify & loop emitter.emit('retry', err, envelope, body); } } }; return Object.assign(emitter, { send: send, close() { isClosed = true; if (pendingSetup) return pendingSetup.then(ch => ch.close()); return _ch ? _ch.close() : Promise.resolve(); } }); } /** @internal */ _connect() { this._state.retryTimer = undefined; // get next host, round-robin const host = this._opt.hosts[this._state.hostIndex]; this._state.hostIndex = (this._state.hostIndex + 1) % this._opt.hosts.length; // assume any previously opened socket is already fully closed let socket; if (this._opt.tls) { socket = node_tls_1.default.connect({ port: host.port, host: host.hostname, ...this._opt.socket, ...this._opt.tls }); } else { socket = node_net_1.default.connect({ port: host.port, host: host.hostname, ...this._opt.socket }); } this._socket = socket; socket.setNoDelay(!!this._opt.noDelay); let connectionError; // create connection timeout if (this._opt.connectionTimeout > 0) { this._state.connectionTimer = setTimeout(() => { socket.destroy(new exception_1.AMQPConnectionError('CONNECTION_TIMEOUT', 'connection timed out')); }, this._opt.connectionTimeout); } socket.on('error', err => { connectionError = connectionError || err; }); socket.on('close', () => { if (this._state.readyState === util_1.READY_STATE.CLOSING) { this._state.readyState = util_1.READY_STATE.CLOSED; this._reset(connectionError || new exception_1.AMQPConnectionError('CLOSING', 'connection is closed')); } else { connectionError = connectionError || new exception_1.AMQPConnectionError('CONN_CLOSE', 'socket closed unexpectedly by server'); if (this._state.readyState === util_1.READY_STATE.OPEN) this._state.onConnect = (0, util_1.createDeferred)(true); this._state.readyState = util_1.READY_STATE.CONNECTING; this._reset(connectionError); const retryCount = this._state.retryCount++; const delay = (0, util_1.expBackoff)(this._opt.retryLow, this._opt.retryHigh, retryCount); this._state.retryTimer = setTimeout(this._connect, delay); // emit & cede control to user only as final step // suppress spam during reconnect if (retryCount <= 1) this.emit('error', connectionError); } }); const ogwrite = socket.write; socket.write = (...args) => { this._state.hasWrite = true; return ogwrite.apply(socket, args); }; const readerLoop = async () => { try { const read = (0, util_1.createAsyncReader)(socket); await this._negotiate(read); // consume AMQP DataFrames until the socket is closed while (true) this._handleChunk(await (0, codec_1.decodeFrame)(read)); } catch (err) { // TODO if err instanceof AMQPConnectionError then invoke connection.close + socket.end() + socket.resume() // all bets are off when we get a codec error; just kill the socket if (err.code !== 'READ_END') socket.destroy(err); } }; socket.write(codec_1.PROTOCOL_HEADER); readerLoop(); return socket; } /** @internal Establish connection parameters with the server. */ async _negotiate(read) { const readFrame = async (methodId) => { const frame = await (0, codec_1.decodeFrame)(read); if (frame.channelId === 0 && frame.type === codec_1.FrameType.METHOD && frame.methodId === methodId) return frame.params; if (frame.type === codec_1.FrameType.METHOD && frame.methodId === codec_1.Cmd.ConnectionClose) { const strcode = codec_1.ReplyCode[frame.params.replyCode] || String(frame.params.replyCode); const msg = codec_1.Cmd[frame.params.methodId] + ': ' + frame.params.replyText; throw new exception_1.AMQPConnectionError(strcode, msg); } throw new exception_1.AMQPConnectionError('COMMAND_INVALID', 'received unexpected frame during negotiation: ' + JSON.stringify(frame)); }; // check for version mismatch (only on first chunk) const chunk = await read(8); if (chunk.toString('utf-8', 0, 4) === 'AMQP') { const version = chunk.slice(4).join('-'); const message = `this version of AMQP is not supported; the server suggests ${version}`; throw new exception_1.AMQPConnectionError('VERSION_MISMATCH', message); } this._socket.unshift(chunk); /*const serverParams = */ await readFrame(codec_1.Cmd.ConnectionStart); // TODO support EXTERNAL mechanism, i.e. x509 peer verification // https://github.com/rabbitmq/rabbitmq-auth-mechanism-ssl // serverParams.mechanisms === 'EXTERNAL PLAIN AMQPLAIN' this._writeMethod({ type: codec_1.FrameType.METHOD, channelId: 0, methodId: codec_1.Cmd.ConnectionStartOK, params: { locale: 'en_US', mechanism: 'PLAIN', response: [null, this._opt.username, this._opt.password].join(String.fromCharCode(0)), clientProperties: this._opt.connectionName ? { ...CLIENT_PROPERTIES, connection_name: this._opt.connectionName } : CLIENT_PROPERTIES } }); const params = await readFrame(codec_1.Cmd.ConnectionTune); const channelMax = params.channelMax > 0 ? Math.min(this._opt.maxChannels, params.channelMax) : this._opt.maxChannels; this._state.channelMax = channelMax; this._socket.setMaxListeners(0); // prevent MaxListenersExceededWarning with >10 channels const frameMax = params.frameMax > 0 ? Math.min(this._opt.frameMax, params.frameMax) : this._opt.frameMax; this._state.frameMax = frameMax; const heartbeat = determineHeartbeat(params.heartbeat, this._opt.heartbeat); this._writeMethod({ type: codec_1.FrameType.METHOD, channelId: 0, methodId: codec_1.Cmd.ConnectionTuneOK, params: { channelMax, frameMax, heartbeat } }); this._writeMethod({ type: codec_1.FrameType.METHOD, channelId: 0, methodId: codec_1.Cmd.ConnectionOpen, params: { virtualHost: this._opt.vhost || '/', rsvp1: '' } }); await readFrame(codec_1.Cmd.ConnectionOpenOK); // create heartbeat timeout, or disable when 0 if (heartbeat > 0) { let miss = 0; this._state.hasWrite = this._state.hasRead = false; this._state.heartbeatTimer = setInterval(() => { if (!this._state.hasRead) { if (++miss >= 4) this._socket.destroy(new exception_1.AMQPConnectionError('SOCKET_TIMEOUT', 'socket timed out (no heartbeat)')); } else { this._state.hasRead = false; miss = 0; } if (!this._state.hasWrite) { // if connection.blocked then heartbeat monitoring is disabled if (this._socket.writable && !this._socket.writableCorked) this._socket.write(codec_1.HEARTBEAT_FRAME); } this._state.hasWrite = false; }, Math.ceil(heartbeat * 1000 / 2)); } this._state.readyState = util_1.READY_STATE.OPEN; this._state.retryCount = 1; this._state.onConnect.resolve(); if (this._state.connectionTimer) clearTimeout(this._state.connectionTimer); this._state.connectionTimer = undefined; this.emit('connection'); } /** @internal */ _writeMethod(params) { const frame = (0, codec_1.encodeFrame)(params, this._state.frameMax); this._socket.write(frame); } /** @internal */ _handleChunk(frame) { this._state.hasRead = true; let ch; if (frame) { if (frame.type === codec_1.FrameType.HEARTBEAT) { // still alive } else if (frame.type === codec_1.FrameType.METHOD) { switch (frame.methodId) { case codec_1.Cmd.ConnectionClose: { if (this._socket.writable) { this._writeMethod({ type: codec_1.FrameType.METHOD, channelId: 0, methodId: codec_1.Cmd.ConnectionCloseOK, params: undefined }); this._socket.end(); this._socket.uncork(); } const strcode = codec_1.ReplyCode[frame.params.replyCode] || String(frame.params.replyCode); const souceMethod = frame.params.methodId ? codec_1.Cmd[frame.params.methodId] + ': ' : ''; const msg = souceMethod + frame.params.replyText; this._socket.emit('error', new exception_1.AMQPConnectionError(strcode, msg)); break; } case codec_1.Cmd.ConnectionCloseOK: // just wait for the socket to fully close break; case codec_1.Cmd.ConnectionBlocked: this._socket.cork(); this.emit('connection.blocked', frame.params.reason); break; case codec_1.Cmd.ConnectionUnblocked: this._socket.uncork(); this.emit('connection.unblocked'); break; default: ch = this._state.leased.get(frame.channelId); if (ch == null) { throw new exception_1.AMQPConnectionError('UNEXPECTED_FRAME', 'client received a method frame for an unexpected channel'); } ch._onMethod(frame); } } else if (frame.type === codec_1.FrameType.HEADER) { const ch = this._state.leased.get(frame.channelId); if (ch == null) { // TODO test me throw new exception_1.AMQPConnectionError('UNEXPECTED_FRAME', 'client received a header frame for an unexpected channel'); } ch._onHeader(frame); } else if (frame.type === codec_1.FrameType.BODY) { const ch = this._state.leased.get(frame.channelId); if (ch == null) { // TODO test me throw new exception_1.AMQPConnectionError('UNEXPECTED_FRAME', 'client received a body frame for an unexpected channel'); } ch._onBody(frame); } } } /** @internal */ _reset(err) { for (const ch of this._state.leased.values()) ch._clear(err); this._state.leased.clear(); this._checkEmpty(); if (this._state.connectionTimer) clearTimeout(this._state.connectionTimer); this._state.connectionTimer = undefined; clearInterval(this._state.heartbeatTimer); this._state.heartbeatTimer = undefined; } /** @internal */ _checkEmpty() { if (!this._state.leased.size && this._state.readyState === util_1.READY_STATE.CLOSING) this._state.onEmpty.resolve(); } /** @internal */ async _lazy() { const ch = this._state.lazyChannel; if (ch instanceof Promise) { return ch; } if (ch == null || !ch.active) try { return this._state.lazyChannel = await (this._state.lazyChannel = this.acquire()); } catch (err) { this._state.lazyChannel = void 0; throw err; } return ch; } basicGet(params) { return this._lazy().then(ch => ch.basicGet(params)); } queueDeclare(params) { return this._lazy().then(ch => ch.queueDeclare(params)); } /** {@inheritDoc Channel#exchangeBind} */ exchangeBind(params) { return this._lazy().then(ch => ch.exchangeBind(params)); } /** {@inheritDoc Channel#exchangeDeclare} */ exchangeDeclare(params) { return this._lazy().then(ch => ch.exchangeDeclare(params)); } /** {@inheritDoc Channel#exchangeDelete} */ exchangeDelete(params) { return this._lazy().then(ch => ch.exchangeDelete(params)); } /** {@inheritDoc Channel#exchangeUnbind} */ exchangeUnbind(params) { return this._lazy().then(ch => ch.exchangeUnbind(params)); } /** {@inheritDoc Channel#queueBind} */ queueBind(params) { return this._lazy().then(ch => ch.queueBind(params)); } queueDelete(params) { return this._lazy().then(ch => ch.queueDelete(params)); } queuePurge(params) { return this._lazy().then(ch => ch.queuePurge(params)); } /** {@inheritDoc Channel#queueUnbind} */ queueUnbind(params) { return this._lazy().then(ch => ch.queueUnbind(params)); } /** True if the connection is established and unblocked. See also {@link Connection#on:BLOCKED | Connection#on('connection.blocked')}) */ get ready() { return this._state.readyState === util_1.READY_STATE.OPEN && !this._socket.writableCorked; } /** * Returns a promise which is resolved when the connection is established. * WARNING: if timeout=0 and you call close() before the client can connect, * then this promise may never resolve! * @param timeout Milliseconds to wait before giving up and rejecting the * promise. Use 0 for no timeout. * @param disableAutoClose By default this will automatically call * `connection.close()` when the timeout is reached. If * disableAutoClose=true, then connection will instead continue to retry * after this promise is rejected. You can call `close()` manually. **/ onConnect(timeout = 10_000, disableAutoClose = false) { if (this.ready) { return Promise.resolve(); } if (this._state.readyState >= util_1.READY_STATE.CLOSING) { return Promise.reject(new Error('RabbitMQ failed to connect in time; Connection closed by client')); } // create this early for a useful stack trace const pessimisticError = new Error('RabbitMQ failed to connect in time'); return new Promise((resolve, reject) => { let timer; // capture the most recent connection Error so it can be included in the // final rejection let lastError; const onError = (err) => { lastError = err; }; const onConnection = () => { this.removeListener('connection', onConnection); this.removeListener('error', onError); if (timer != null) { clearTimeout(timer); } resolve(); }; if (timeout > 0) { timer = setTimeout(() => { this.removeListener('connection', onConnection); this.removeListener('error', onError); if (!disableAutoClose) { /* close should never throw but catch and ignore just in case */ this.close().catch(() => { }); } if (lastError) { pessimisticError.cause = lastError; } reject(pessimisticError); }, timeout); } this.on('error', onError); this.on('connection', onConnection); }); } } exports.Connection = Connection; function determineHeartbeat(x, y) { if (x && y) return Math.min(x, y); // according to the AMQP spec, BOTH the client and server must set heartbeat to 0 if (!x && !y) return 0; // otherwise the higher number is used return Math.max(x, y); }