@bitrix24/b24rabbitmq
Version:
Library for integrating Bitrix24 applications with RabbitMQ
486 lines (480 loc) • 18.4 kB
JavaScript
/**
* @version @bitrix24/b24rabbitmq v0.1.1
* @copyright (c) 2026 Bitrix24
* @licence MIT
* @links https://github.com/bitrix24/b24rabbitmq - GitHub
* @links https://github.com/bitrix24/b24rabbitmq/blob/main/docs/en/1_page.md - Documentation
*/
import amqp from 'amqplib';
const defaultLogger = {
debug: (message, ...args) => {
console.debug(message, ...args);
},
info: (message, ...args) => {
console.info(message, ...args);
},
warn: (message, ...args) => {
console.warn(message, ...args);
},
error: (message, ...args) => {
console.error(message, ...args);
}
};
const URL_CREDENTIAL_WITH_PASSWORD = /(amqps?:\/\/)([^:@]+):([^@]+)@/g;
const URL_CREDENTIAL_USER_ONLY = /(amqps?:\/\/)([^:@/]+)@/g;
function sanitizeUrl(url) {
return url.replace(URL_CREDENTIAL_WITH_PASSWORD, "$1***:***@").replace(URL_CREDENTIAL_USER_ONLY, "$1***@");
}
function safeErrorMessage(error) {
const parts = [];
const seen = /* @__PURE__ */ new Set();
let current = error;
for (let depth = 0; depth < 10 && current != null && !seen.has(current); depth++) {
seen.add(current);
if (current instanceof Error) {
parts.push(current.message);
current = current.cause;
} else {
parts.push(String(current));
break;
}
}
if (parts.length === 0) {
parts.push(String(error));
}
return sanitizeUrl(parts.join(": "));
}
class RabbitMQBase {
connection;
channel;
config;
logger;
constructor(config) {
this.config = {
channel: { prefetchCount: 1 },
...config
};
this.logger = config.logger ?? defaultLogger;
}
/**
* Open the AMQP connection and channel. Not a TypeScript `abstract`
* method — the base implementation throws unless a subclass overrides
* it; `RabbitMQProducer` and `RabbitMQConsumer` do exactly that
* (publish channel vs consumer channel + reconnect listener).
*/
async connect() {
throw new Error("Need override this function");
}
/**
* Iterate the config's `exchanges` array and assert each one against
* the broker. Called by `initialize()`; idempotent on the broker side.
* @protected
*/
async setupExchanges() {
for (const exchange of this.config.exchanges) {
await this.registerExchange(exchange);
}
}
/**
* Declare a single exchange on the active channel. `Producer` overrides
* this to also cache the exchange in a local map.
*
* @param exchange the exchange to assert — see {@link ExchangeParams}.
*/
async registerExchange(exchange) {
await this.channel.assertExchange(
exchange.name,
exchange.type,
exchange.options || {}
);
}
/**
* Iterate the config's `queues` array, assert each queue and create
* its bindings. Called by `Consumer.initialize()`.
* @protected
*/
async setupQueues() {
for (const queue of this.config.queues) {
await this.registerQueue(queue);
}
}
/**
* Declare a single queue (and its bindings) on the active channel.
*
* The library merges three sources into the queue's `arguments`:
* (1) library-injected `x-max-priority` (from `queue.maxPriority`,
* default 10; omitted when set to 0),
* (2) library-injected `x-dead-letter-exchange` / `x-dead-letter-routing-key`
* (from `queue.deadLetter`),
* (3) caller-supplied `queue.options.arguments`.
*
* On per-key conflict, the caller wins; sibling keys survive.
*
* @param queue the queue to assert — see {@link QueueParams}.
* @returns the amqplib assertQueue reply (with the broker-assigned
* queue name if `queue.name` was empty).
*/
async registerQueue(queue) {
const maxPriority = queue.maxPriority ?? 10;
const mergedArguments = {};
if (maxPriority > 0) {
mergedArguments["x-max-priority"] = maxPriority;
}
if (queue.deadLetter) {
mergedArguments["x-dead-letter-exchange"] = queue.deadLetter.exchange;
mergedArguments["x-dead-letter-routing-key"] = queue.deadLetter.routingKey ?? "";
}
const { arguments: callerArguments, ...callerRestOptions } = queue.options ?? {};
if (callerArguments) {
Object.assign(mergedArguments, callerArguments);
}
const q = await this.channel.assertQueue(
queue.name || "",
{
...callerRestOptions,
arguments: mergedArguments
}
);
for (const binding of queue.bindings) {
if (binding.headers) {
await this.channel.bindQueue(
q.queue,
binding.exchange,
binding.routingKey || "",
binding.headers
);
} else {
await this.channel.bindQueue(
q.queue,
binding.exchange,
binding.routingKey || ""
);
}
}
return q;
}
/**
* Close the channel and the connection. Safe to call repeatedly or
* before `initialize()` (the optional-chaining handles a missing
* channel/connection). `RabbitMQConsumer` overrides this to also
* clear its reconnect-tracking state.
*/
async disconnect() {
await this.channel?.close();
await this.connection?.close();
this.logger.info("[RabbitMQ::Base] disconnect");
}
}
class RabbitMQProducer extends RabbitMQBase {
exchanges = /* @__PURE__ */ new Map();
/**
* Open the connection + publish channel and assert every exchange
* from the config. Throws if the broker is unreachable or the
* topology assertion fails. Call once before {@link publish}.
*/
async initialize() {
await this.connect();
await this.setupExchanges();
}
/**
* Open the AMQP connection and create a publish channel.
*
* Intentionally does NOT call `channel.prefetch()` — `prefetch` is a
* consumer-side flow-control setting (limits unacked deliveries on the
* channel) and has no effect on publishing.
*/
async connect() {
try {
this.logger.info("[RabbitMQ::Producer] connect ...");
this.connection = await amqp.connect(this.config.connection.url);
this.channel = await this.connection.createChannel();
this.logger.info("[RabbitMQ::Producer] connected successfully");
} catch (error) {
const problem = error instanceof Error ? error : new Error(`[RabbitMQ::Producer] connected error`, { cause: error });
this.logger.error("[RabbitMQ::Producer] connect failed:", safeErrorMessage(problem));
throw problem;
}
}
/**
* Assert an exchange on the broker (delegates to `RabbitMQBase`) and
* remember it in the Producer's local map. The cached entries are
* informational — `publish()` does not re-assert before sending.
*/
async registerExchange(exchange) {
await super.registerExchange(exchange);
this.exchanges.set(exchange.name, exchange);
}
/**
* Publish a message to an exchange, serialized as JSON.
*
* @param exchangeName Target exchange (must be declared in the config).
* @param routingKey Routing key for the broker to match against bindings.
* @param message Payload — JSON-serialized into a Buffer.
* @param options AMQP publish options. Defaults to `priority: 5`.
*
* @returns
* The boolean returned by `amqplib`'s `channel.publish()`. This reflects
* **only the client-side write buffer state**, not broker acknowledgment:
* - `true` — the message was written to the channel's outgoing buffer.
* - `false` — the buffer is full; the caller should wait for the channel's
* `'drain'` event before publishing more (back-pressure signal).
*
* It does **not** mean the broker has received or persisted the message.
* For at-least-once delivery you need publisher confirms (Track 4 capability,
* post-v0.1) — switch to `createConfirmChannel()` and await
* `channel.waitForConfirms()`.
*/
async publish(exchangeName, routingKey, message, options = {}) {
return this.channel.publish(
exchangeName,
routingKey,
Buffer.from(JSON.stringify(message)),
{
priority: 5,
...options
}
);
}
}
class RabbitMQConsumer extends RabbitMQBase {
retries = 0;
handlers = /* @__PURE__ */ new Map();
/** Queue names we have an active `consume()` subscription on, so we can re-subscribe after reconnect. */
subscribedQueues = /* @__PURE__ */ new Set();
/** queueName → active amqplib consumerTag, so `unRegisterHandler` can issue `basic.cancel`. */
consumerTags = /* @__PURE__ */ new Map();
/** Guard so multiple 'close' events don't kick off concurrent reconnect loops. */
reconnectInProgress = false;
/**
* Open the connection + consumer channel, assert every exchange and
* queue (with bindings) from the config, then arm the reconnect
* listener. Throws if the broker is unreachable or any assertion
* fails. Call once before {@link registerHandler} + {@link consume}.
*/
async initialize() {
await this.connect();
await this.setupExchanges();
await this.setupQueues();
}
/**
* Open a connection, create a channel, apply prefetch, and wire the
* close-listener that drives reconnect. Throws on failure — the caller
* (initialize or the reconnect loop) decides what to do with errors.
*/
async connect() {
this.logger.info("[RabbitMQ::Consumer] connect ...");
this.connection = await amqp.connect(this.config.connection.url);
this.channel = await this.connection.createChannel();
await this.channel.prefetch(this.config.channel.prefetchCount);
this.connection.on("close", () => {
void this.handleReconnect();
});
this.connection.on("error", (error) => {
this.logger.error("[RabbitMQ::Consumer] connection error", safeErrorMessage(error));
});
this.logger.info("[RabbitMQ::Consumer] connected successfully");
this.retries = 0;
}
/**
* Bounded async reconnect loop. Triggered by the 'close' event on the
* connection. Sleeps `reconnectInterval` between attempts, re-asserts the
* topology and re-subscribes any queues that had active consume() calls.
*
* Never throws — a synchronous throw out of an event listener would be
* uncatchable for the caller and cause an unhandled-exception crash.
* On exhaustion we log and give up; the process stays alive and the
* caller can decide to recreate the consumer.
*
* `maxRetries: 0` disables reconnect entirely (the loop never enters);
* the log message in that case is "reconnect disabled" rather than
* "max retries exceeded".
*/
async handleReconnect() {
if (this.reconnectInProgress) return;
this.reconnectInProgress = true;
this.retries = 0;
const maxRetries = this.config.connection.maxRetries ?? 5;
const interval = this.config.connection.reconnectInterval ?? 5e3;
try {
while (this.retries < maxRetries) {
this.retries++;
this.logger.info(`[RabbitMQ::Consumer] reconnecting (attempt ${this.retries}/${maxRetries}) in ${interval}ms`);
await new Promise((resolve) => setTimeout(resolve, interval));
try {
await this.connect();
await this.setupExchanges();
await this.setupQueues();
for (const queueName of this.subscribedQueues) {
const reply = await this.channel.consume(queueName, this.buildDeliveryCallback(queueName));
this.consumerTags.set(queueName, reply.consumerTag);
}
this.logger.info("[RabbitMQ::Consumer] reconnected and re-subscribed");
this.reconnectInProgress = false;
return;
} catch (error) {
this.logger.error(
`[RabbitMQ::Consumer] reconnect attempt ${this.retries} failed:`,
safeErrorMessage(error)
);
}
}
if (maxRetries === 0) {
this.logger.error("[RabbitMQ::Consumer] reconnect disabled (maxRetries=0); the consumer is no longer connected.");
} else {
this.logger.error(`[RabbitMQ::Consumer] max connection retries (${maxRetries}) exceeded; giving up. The consumer is no longer connected.`);
}
} finally {
this.reconnectInProgress = false;
}
}
/**
* Register an async handler for a queue. Storage only — no broker
* traffic happens here; the handler activates once {@link consume} is
* called for the same `queueName`. Registering twice for the same
* queue replaces the previous handler.
*
* The handler receives the JSON-parsed message body plus terminal
* `ack` / `nack` callbacks. `nack` always sends `requeue=false` so
* rejected messages route through any configured dead-letter
* exchange — to replay, publish a fresh copy.
*
* Parse errors (invalid JSON) and uncaught handler rejections are
* logged and `nack`'d by the library — the handler itself never sees
* a parse failure.
*
* The library does NOT validate the parsed body — spreading it into
* a trusted object (`{ ...trusted, ...msg }`) or assigning to
* `Object.prototype`-adjacent keys is the caller's responsibility.
* Validate the shape before merging.
*
* @typeParam T the parsed message body shape. The library does not
* validate at runtime; supply a narrow type and validate at the
* call site if needed.
* @param queueName must match an asserted queue (see `RabbitMQConfig.queues`).
* @param handler async callback invoked for every delivery.
*/
registerHandler(queueName, handler) {
this.handlers.set(queueName, handler);
}
/**
* Remove a previously registered handler, issue an AMQP `basic.cancel`
* for the queue's active consumer (if any), and drop the queue from the
* reconnect-resubscribe set.
*
* Issuing `basic.cancel` is what actually stops the broker delivering on
* the live channel: without it the consumer tag stays open, deliveries
* keep arriving at a now-handlerless callback, and — with
* `prefetchCount=1` — the unacked delivery blocks the queue. Dropping the
* queue from `subscribedQueues` covers the same hazard across a reconnect.
*
* Returns a promise because the cancel is a broker round-trip. The handler
* map and tracking sets are cleared synchronously **before** the await, so
* even an un-awaited call immediately stops dispatching to the handler.
*
* @param queueName the queue whose subscription to tear down. Unknown
* queues are a no-op.
*/
async unRegisterHandler(queueName) {
this.handlers.delete(queueName);
this.subscribedQueues.delete(queueName);
const consumerTag = this.consumerTags.get(queueName);
if (consumerTag === void 0) return;
this.consumerTags.delete(queueName);
try {
await this.channel.cancel(consumerTag);
} catch (error) {
this.logger.warn(
`[RabbitMQ::Consumer] basic.cancel for "${queueName}" failed:`,
safeErrorMessage(error)
);
}
}
/**
* Subscribe to a queue. Tracks the subscription so the reconnect
* loop can re-attach after a broker drop. Each delivery is
* JSON-parsed and forwarded to the handler registered via
* {@link registerHandler}; if no handler exists for the queue, the
* delivery is silently ignored (it stays un-acked until the channel
* closes — register your handler **before** calling `consume`).
*
* @param queueName name of an already-asserted queue. Server-named
* queues (where the broker assigns the name in `initialize()`)
* should be looked up via the `assertQueue` reply if you need to
* pass the generated name back in.
* @returns the amqplib `Replies.Consume` (contains `consumerTag`).
*/
async consume(queueName) {
this.subscribedQueues.add(queueName);
const reply = await this.channel.consume(queueName, this.buildDeliveryCallback(queueName));
this.consumerTags.set(queueName, reply.consumerTag);
return reply;
}
/**
* Close the channel and connection, and reset the consumer to a clean
* state so it can be safely re-`initialize()`d. Without this override
* the reconnect tracking (`subscribedQueues`, `reconnectInProgress`,
* `retries`) would leak across consumer lifecycles.
*/
async disconnect() {
this.subscribedQueues.clear();
this.handlers.clear();
this.consumerTags.clear();
this.reconnectInProgress = false;
this.retries = 0;
await super.disconnect();
}
/**
* Build the delivery callback for a queue. Extracted so the reconnect path
* can use the same logic without duplicating the closure inline.
*
* Each invocation owns a per-delivery `terminated` flag that guards
* `channel.ack` / `channel.nack` so only the FIRST terminal call wins:
*
* - `ack()` then `throw` → only `ack` fires; the catch's safety-net
* `nack` is suppressed. amqplib treats a second terminal call on the
* same delivery as a protocol error (channel close in production).
* - `nack()` then `throw` → only `nack` fires.
* - `ack(); ack()` → only the first ack fires.
* - Handler throws without calling `ack`/`nack` → the catch nacks once.
*
* Idempotency is per-delivery: a separate `terminated` lives in each
* outer-`return async (msg) => …` invocation, so two messages each get
* their own first-call-wins guard.
*/
buildDeliveryCallback(queueName) {
return async (msg) => {
if (!msg) return;
const handler = this.handlers.get(queueName);
if (!handler) return;
let terminated = false;
const ack = () => {
if (terminated) {
this.logger.warn("[RabbitMQ::Consumer] ack suppressed \u2014 delivery already terminated");
return;
}
this.channel.ack(msg);
terminated = true;
};
const nack = () => {
if (terminated) {
this.logger.warn("[RabbitMQ::Consumer] nack suppressed \u2014 delivery already terminated");
return;
}
this.channel.nack(msg, false, false);
terminated = true;
};
try {
const content = JSON.parse(msg.content.toString());
await handler(content, ack, nack);
} catch (error) {
this.logger.error(`[RabbitMQ] Error processing message: ${safeErrorMessage(error)}`);
if (!terminated) {
this.channel.nack(msg, false, false);
terminated = true;
}
}
};
}
}
export { RabbitMQBase, RabbitMQConsumer, RabbitMQProducer };
//# sourceMappingURL=index.mjs.map