amqplib-envelop
Version:
A wrapper for working with RabbitMQ using the amqplib npm package
519 lines (474 loc) • 16.5 kB
JavaScript
const { EventEmitter } = require('events');
const amqplib = require('amqplib');
/**
* @class RabbitMQ
* @desc Wrapper class for working with RabbitMQ.
* @fires RabbitMQ#reconnect - After a dropped connection/channel has been re-established
* and previously asserted exchanges/queues/bindings/consumers have been restored. Useful
* for consumers (like {@link RpcClient}) that hold state tied to the channel, such as an
* exclusive reply queue, and need to recreate it after a reconnect.
*/
class RabbitMQ extends EventEmitter {
/**
* @private
* @desc Interval in seconds between timeouts for heartbeat pulses in the connection
* between the client and the RabbitMQ server. Used to check activity and connection status.
* @type {number}
*/
#HEARTBEAT = 60;
/**
* @private
* @desc Time (in milliseconds) within which the client must establish a connection with the
* RabbitMQ server. If the connection is not established within this period,
* a timeout error may occur.
* @type {number}
*/
#CONNECTION_TIMEOUT = 30_000;
/**
* @private
* @desc Whether the instance should automatically try to reconnect (and restore
* previously asserted exchanges/queues/bindings/consumers) when the connection
* or channel is unexpectedly closed.
* @type {boolean}
*/
#reconnect = true;
/**
* @private
* @desc Delay (in milliseconds) between reconnection attempts.
* @type {number}
*/
#reconnectDelay = 5000;
/**
* @private
* @desc Guards against scheduling overlapping reconnection attempts.
* @type {boolean}
*/
#reconnecting = false;
/**
* @private
* @desc Set while `closeConnection` is intentionally closing the connection/channel,
* so the resulting 'close' events do not trigger a reconnect attempt.
* @type {boolean}
*/
#closingIntentionally = false;
/**
* @private
* @desc Exchanges asserted via {@link assertExchange}, keyed by exchange name.
* Replayed after a reconnect.
* @type {Map<string, object>}
*/
#exchanges = new Map();
/**
* @private
* @desc Queues asserted via {@link assertQueue}, keyed by queue name.
* Replayed after a reconnect.
* @type {Map<string, object>}
*/
#queues = new Map();
/**
* @private
* @desc Bindings created via {@link bindQueue}, keyed by `queue|source|pattern`.
* Replayed after a reconnect.
* @type {Map<string, object>}
*/
#bindings = new Map();
/**
* @private
* @desc Consumers registered via {@link consume}, keyed by queue name.
* Replayed after a reconnect.
* @type {Map<string, object>}
*/
#consumers = new Map();
/**
* @static
* @desc Active instance of RabbitMQ.
* @type {RabbitMQ|null}
*/
static activeInstance = null;
/**
* @static
* @private
* @desc In-flight initialization promise, used so concurrent calls to
* {@link initAndGetInstance} share a single connection attempt instead of racing.
* @type {Promise<RabbitMQ>|null}
*/
static #initPromise = null;
/**
* @static
* @private
* @desc Whether {@link registerGracefulShutdown} has already attached process listeners.
* @type {boolean}
*/
static #shutdownRegistered = false;
/**
* @param {object} connection - Connection configuration.
* @param {boolean} [connection.reconnect=true] - Automatically reconnect and restore
* exchanges/queues/bindings/consumers if the connection or channel closes unexpectedly.
* @param {number} [connection.reconnectDelay=5000] - Delay (ms) between reconnect attempts.
*/
constructor(connection) {
super();
this.connectionArgs = connection;
this.connection = null;
this.channel = null;
this.#reconnect = connection?.reconnect !== false;
this.#reconnectDelay = connection?.reconnectDelay ?? 5000;
}
async init() {
try {
await this.#establishConnection();
} catch (err) {
console.error(`${__filename}/init error:`, err?.message);
this.#scheduleReconnect();
}
}
/**
* @private
* @desc Opens the connection and channel, and attaches error/close listeners to them.
*/
async #establishConnection() {
this.connection = await amqplib.connect(this.getConnectionString());
this.#attachConnectionListeners();
this.channel = await this.connection.createChannel();
this.#attachChannelListeners();
}
/**
* @private
* @desc Logs connection-level errors and triggers a reconnect on unexpected close,
* so a dropped connection cannot crash the process with an unhandled 'error' event.
*/
#attachConnectionListeners() {
this.connection.on('error', (err) => {
console.error(`${__filename}/connection error:`, err?.message);
});
this.connection.on('close', () => {
if (this.#closingIntentionally) return;
console.error(`${__filename}/connection closed unexpectedly`);
this.#scheduleReconnect();
});
}
/**
* @private
* @desc Logs channel-level errors and triggers a reconnect on unexpected close.
*/
#attachChannelListeners() {
this.channel.on('error', (err) => {
console.error(`${__filename}/channel error:`, err?.message);
});
this.channel.on('close', () => {
if (this.#closingIntentionally) return;
console.error(`${__filename}/channel closed unexpectedly`);
this.#scheduleReconnect();
});
}
/**
* @private
* @desc Schedules a reconnection attempt after {@link #reconnectDelay} milliseconds.
* On success, replays previously asserted exchanges/queues/bindings/consumers.
* On failure, reschedules itself.
*/
#scheduleReconnect() {
if (!this.#reconnect || this.#reconnecting || this.#closingIntentionally) return;
this.#reconnecting = true;
setTimeout(async () => {
this.#reconnecting = false;
if (this.#closingIntentionally) return;
try {
this.connection = null;
this.channel = null;
await this.#establishConnection();
await this.#restoreState();
this.emit('reconnect');
} catch (err) {
console.error(`${__filename}/reconnect error:`, err?.message);
if (this.connection) {
try {
await this.connection.close();
} catch (closeErr) {
console.error(`${__filename}/reconnect cleanup error:`, closeErr?.message);
}
}
this.connection = null;
this.channel = null;
this.#scheduleReconnect();
}
}, this.#reconnectDelay);
}
/**
* @private
* @desc Re-applies exchanges, queues, bindings and consumers that were set up before
* a reconnect, in dependency order (exchanges, then queues, then bindings, then consumers).
*/
async #restoreState() {
await Promise.all(
[...this.#exchanges.values()].map((exchange) => this.assertExchange(exchange)),
);
await Promise.all(
[...this.#queues.values()].map((queue) => this.assertQueue(queue)),
);
await Promise.all(
[...this.#bindings.values()].map((binding) => this.bindQueue(binding)),
);
await Promise.all(
[...this.#consumers.values()].map(({ queueConfig, callback, consumerOptions }) => (
this.consume(queueConfig, callback, consumerOptions)
)),
);
}
getConnectionString() {
const {
protocol = 'amqp:',
hostname,
vhost = '',
port = 5672,
user,
password,
connectionName,
} = this.connectionArgs;
return `${protocol}//${user}:${password}@${hostname}:${port}/${vhost}?${this.connectionQuery(connectionName)}`;
}
connectionQuery(connectionName) {
const clientProperties = `${encodeURIComponent(`{"connection_name":"${connectionName}"}`)}`;
return `heartbeat=${this.#HEARTBEAT}&connection_timeout=${this.#CONNECTION_TIMEOUT}&client_properties=${clientProperties}`;
}
/**
* @returns {RabbitMQ|null} - active instance of RabbitMQ or null
*/
static getActiveInstance() {
return RabbitMQ.activeInstance;
}
/**
* Method for initialization and obtaining the active instance of RabbitMQ.
* Concurrent calls share a single connection attempt instead of racing.
* @param {object} connection - Connection configuration.
* @returns {RabbitMQ} - Active instance of RabbitMQ.
*/
static async initAndGetInstance(connection) {
if (RabbitMQ.activeInstance) {
return RabbitMQ.activeInstance;
}
if (!RabbitMQ.#initPromise) {
RabbitMQ.#initPromise = (async () => {
const instance = new RabbitMQ(connection);
await instance.init();
RabbitMQ.activeInstance = instance;
return instance;
})();
}
try {
return await RabbitMQ.#initPromise;
} finally {
RabbitMQ.#initPromise = null;
}
}
/**
* Registers SIGINT/SIGTERM handlers that close the active RabbitMQ connection before
* the process exits. Opt-in: call this explicitly if you want the library to manage
* shutdown for you, instead of doing it manually (see README "Using (worker)").
* @param {string[]} [signals=['SIGINT', 'SIGTERM']] - Signals to listen for.
*/
static registerGracefulShutdown(signals = ['SIGINT', 'SIGTERM']) {
if (RabbitMQ.#shutdownRegistered) {
return;
}
RabbitMQ.#shutdownRegistered = true;
const shutdown = async () => {
try {
if (RabbitMQ.activeInstance) {
await RabbitMQ.activeInstance.closeConnection();
}
} catch (err) {
console.error(`${__filename}/registerGracefulShutdown error:`, err?.message);
} finally {
process.exit(0);
}
};
signals.forEach((signal) => process.once(signal, shutdown));
}
/**
* Binding a queue to an exchange with the specified parameters.
* @param {object} bindConfig - Configuration for binding a queue to an exchange.
* @param {string} bindConfig.queue - Queue name.
* @param {string} bindConfig.source - Exchange name to which the queue is bound.
* @param {string} bindConfig.pattern - Binding pattern (routing key or pattern).
*/
async bindQueue(bindConfig) {
try {
const { queue, source, pattern } = bindConfig;
await this.channel.bindQueue(queue, source, pattern);
this.#bindings.set(`${queue}|${source}|${pattern}`, bindConfig);
} catch (err) {
console.error(`${__filename}/bindQueue error:`, err?.message);
}
}
/**
* Unbinding a queue from an exchange with the specified parameters.
* @param {object} bindConfig - Configuration for unbinding a queue from an exchange.
* @param {string} bindConfig.queue - Queue name.
* @param {string} bindConfig.source - Exchange name to unbind the queue from.
* @param {string} bindConfig.pattern - Binding pattern (routing key or pattern).
*/
async unbindQueue(bindConfig) {
try {
const { queue, source, pattern } = bindConfig;
await this.channel.unbindQueue(queue, source, pattern);
this.#bindings.delete(`${queue}|${source}|${pattern}`);
} catch (err) {
console.error(`${__filename}/unbindQueue error:`, err?.message);
}
}
/**
* Checking the existence of a queue and creating it if it does not exist.
* @param {object} queueConfig - Queue configuration.
* @param {string} queueConfig.name - Queue name.
* @param {boolean} [queueConfig.durable=true] - Marks the queue as durable (persisted).
* @param {object} [queueConfig.arguments] - Additional arguments for the queue.
*/
async assertQueue(queueConfig) {
try {
const { name, durable = true, arguments: queueArguments } = queueConfig;
await this.channel.assertQueue(name, { durable, arguments: queueArguments });
this.#queues.set(name, queueConfig);
} catch (err) {
console.error(`${__filename}/assertQueue error:`, err?.message);
}
}
/**
* Deleting a queue.
* @param {String} queue - Queue name.
* @param {Object} options - Additional queue options (ifUnused, ifEmpty, etc.).
*/
async deleteQueue(queue, options) {
try {
await this.channel.deleteQueue(queue, options);
this.#queues.delete(queue);
this.#consumers.delete(queue);
} catch (err) {
console.error(`${__filename}/deleteQueue error:`, err?.message);
}
}
/**
* Checking the existence of an exchange and creating it if it does not exist.
* @param {string} exchange - Exchange name.
* @param {string} type - Exchange type (e.g., 'direct', 'fanout', 'topic').
* @param {object} [options] - Additional options for creating the exchange.
*/
async assertExchange(exchangeConfig) {
try {
const { name: exchange, type, options } = exchangeConfig;
await this.channel.assertExchange(exchange, type, options);
this.#exchanges.set(exchange, exchangeConfig);
} catch (err) {
console.error(`${__filename}/assertExchange error:`, err?.message);
}
}
/**
* Consuming messages from a queue with the ability to specify parameters.
* @param {object|string} queueConfig - Queue configuration or just the queue name
* (if specified as a string).
* @param {function} callback - Message handler function.
* @param {object} consumerOptions - additional configurations for consume setting up
* @returns {object|undefined} - amqplib's `{ consumerTag }`, or undefined on error.
*/
async consume(queueConfig, callback, consumerOptions) {
try {
const {
name,
durable = true,
arguments: queueArguments,
prefetch = 1,
} = typeof queueConfig === 'string' ? { name: queueConfig } : queueConfig;
this.channel.prefetch(prefetch);
await this.channel.assertQueue(name, {
durable,
arguments: queueArguments,
});
const consumeResult = await this.channel.consume(name, callback, consumerOptions);
this.#consumers.set(name, { queueConfig, callback, consumerOptions });
return consumeResult;
} catch (err) {
console.error(`${__filename}/consume error:`, err?.message);
return undefined;
}
}
/**
* Sending a message to a queue.
* @param {string} queue - Queue name.
* @param {object} message - Message object.
* @param {object} [options] - Options for sending the message.
*/
async sendToQueue(queue, message, options) {
try {
const messageBuffer = Buffer.from(JSON.stringify(message));
return this.channel.sendToQueue(queue, messageBuffer, options);
} catch (err) {
console.error(`${__filename}/sendToQueue error:`, err?.message);
return false;
}
}
/**
* Confirming message receipt.
* @param {object} msg - Message object.
*/
async ack(msg) {
try {
await this.channel.ack(msg);
} catch (err) {
console.error(`${__filename}/ack error:`, err?.message);
}
}
/**
* Negative message acknowledgment.
* @param {object} msg - Message object.
* @param {boolean} [requeue=true] - Requeue the message (default is true).
*/
async nack(msg, requeue = true) {
try {
await this.channel.nack(msg, false, requeue);
} catch (err) {
console.error(`${__filename}/nack error:`, err?.message);
}
}
/**
* Getting the message object as JSON.
* @param {object} message - Message object.
* @returns {object|null} - Message object as JSON or null in case of error.
*/
// eslint-disable-next-line class-methods-use-this
getMsgObj(message) {
try {
return JSON.parse(message.content.toString());
} catch (err) {
console.error(`${__filename}/getMsgObj error:`, err?.message);
return null;
}
}
/**
* @method closeConnection
* @desc Closes the channel and connection if they exist, and clears any tracked
* exchanges/queues/bindings/consumers so they are not replayed by a reconnect.
* @return {Promise<void>}
*/
async closeConnection() {
this.#closingIntentionally = true;
try {
if (this.channel) {
await this.channel.close();
this.channel = null;
}
if (this.connection) {
await this.connection.close();
this.connection = null;
}
this.#exchanges.clear();
this.#queues.clear();
this.#bindings.clear();
this.#consumers.clear();
} catch (err) {
console.error(`${__filename}/closeConnection error:`, err?.message);
} finally {
this.#closingIntentionally = false;
}
}
}
module.exports = RabbitMQ;