rabbitmq-client
Version:
Robust, typed, RabbitMQ (0-9-1) client library
633 lines (632 loc) • 27.6 kB
JavaScript
"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);
}