UNPKG

@bitrix24/b24rabbitmq

Version:

Library for integrating Bitrix24 applications with RabbitMQ

486 lines (480 loc) 18.4 kB
/** * @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