@bitrix24/b24rabbitmq
Version:
Library for integrating Bitrix24 applications with RabbitMQ
421 lines (415 loc) • 19.4 kB
TypeScript
import amqp from 'amqplib';
/**
* Declarative description of an AMQP exchange. Passed to
* {@link RabbitMQBase.registerExchange} (called automatically from
* `Producer.initialize()` / `Consumer.initialize()` for every entry in
* {@link RabbitMQConfig.exchanges}).
*/
interface ExchangeParams {
/** Exchange name. Must match the routing target used in `Producer.publish()`. */
name: string;
/** Exchange type — see the AMQP 0-9-1 specification for routing semantics. */
type: 'direct' | 'fanout' | 'topic' | 'headers';
/** Optional amqplib assertion options (`durable`, `autoDelete`, `internal`, etc.). */
options?: amqp.Options.AssertExchange;
}
/**
* Declarative description of an AMQP queue plus its bindings and an
* optional dead-letter destination. Library-injected `x-max-priority`
* and `x-dead-letter-*` arguments are merged with any caller-supplied
* `options.arguments` per-key — caller values win on conflict, sibling
* keys survive.
*/
interface QueueParams {
/** Queue name. Omit or pass `''` to let the broker auto-generate one. */
name?: string;
/** Optional amqplib assertion options (`durable`, `exclusive`, `arguments`, etc.). */
options?: amqp.Options.AssertQueue;
/**
* Maximum supported priority for AMQP priority queues. Translated to
* `x-max-priority` in the queue arguments. AMQP accepts 1..255; values
* `<= 0` are treated as "opt out of priority" and the key is omitted
* before the assert (the broker rejects `x-max-priority: 0`). Values
* above 255 are not clamped by this library — the broker will reject
* the assert. RabbitMQ's own guidance is to stay in the 1..10 range.
* @default 10
*/
maxPriority?: number;
/** Exchange-to-queue bindings created together with the queue. */
bindings: {
/** Source exchange name. */
exchange: string;
/** Routing key for `direct` / `topic` exchanges. Empty string when binding to a `fanout`. */
routingKey?: string;
/** Header match arguments for `headers` exchanges. */
headers?: Record<string, unknown>;
}[];
/**
* Dead-letter destination — translated to `x-dead-letter-exchange` and
* `x-dead-letter-routing-key` queue arguments. The library merges these
* with `maxPriority` and any caller-supplied `options.arguments` into
* one arguments object.
*/
deadLetter?: {
exchange: string;
routingKey?: string;
};
}
/**
* The full configuration object accepted by `RabbitMQProducer` and
* `RabbitMQConsumer`. One config describes the topology end-to-end;
* `initialize()` asserts exchanges + queues + bindings against the
* broker.
*/
interface RabbitMQConfig {
/** AMQP connection settings. Prefer `amqps://` outside localhost. */
connection: {
/**
* Connection URL — `amqp://` or `amqps://` (TLS). Keep credentials
* in environment variables, not in source-controlled config.
*/
url: string;
/**
* Milliseconds to wait between consumer reconnect attempts.
* `0` is technically valid (retry as fast as the event loop allows)
* but not recommended — use the default unless you have a reason.
* @default 5000
*/
reconnectInterval?: number;
/**
* Maximum number of consumer reconnect attempts after a connection
* drop. Set to `0` to disable reconnect entirely (the consumer will
* give up on the first `'close'` event).
* @default 5
*/
maxRetries?: number;
};
/** Exchanges to assert at `initialize()` time. */
exchanges: ExchangeParams[];
/** Queues to assert at `initialize()` time (consumer side). */
queues: QueueParams[];
/** Channel-level options applied at `connect()` time. */
channel?: {
/** Consumer prefetch: max unacked deliveries per channel. @default 1 */
prefetchCount?: number;
};
/**
* Optional logger receiver. Defaults to a thin `console.*` wrapper
* (see `src/logger.ts: defaultLogger`). Pass `pino`, `consola`,
* the `@bitrix24/b24jssdk` logger, or any object satisfying the
* {@link Logger} interface to route diagnostics through your own
* stack.
*
* Credentials in connection URLs are scrubbed by THIS LIBRARY
* before they reach the logger — but only for messages emitted by
* this library. If your logger implementation also receives errors
* from your own code paths (e.g. forwards to an error-reporter),
* sanitize URL credentials there too.
*/
logger?: Logger;
}
/**
* Minimal logger interface the library calls into. Compatible by shape
* with `console`, `pino`, `consola`, and the `@bitrix24/b24jssdk`
* logger. All four levels are required so call sites stay simple;
* implementations that don't care about a level can supply a noop.
*/
interface Logger {
/** Verbose-level diagnostic; emitted on hot paths if the implementation honours debug. */
debug(message: string, ...args: unknown[]): void;
/** Informational diagnostic — lifecycle transitions, successful connects, etc. */
info(message: string, ...args: unknown[]): void;
/** Recoverable anomaly — e.g. a reconnect attempt that failed but will be retried. */
warn(message: string, ...args: unknown[]): void;
/** Unrecoverable or noteworthy failure — connect errors, reconnect exhaustion, parse errors. */
error(message: string, ...args: unknown[]): void;
}
/**
* Loose convenience shape for application messages. The library itself
* does NOT enforce this — `Consumer.registerHandler<T>` lets callers
* supply their own narrower type. Provided for users who want a
* starting point; recommend defining your own typed payload instead of
* relying on the open index signature.
*/
interface Message {
routingKey: string;
date: string;
entityTypeId?: number;
entityId?: number;
retryCount?: number;
additionalData?: Record<string, unknown>;
/** Open index signature: extra fields are typed `unknown` and must be narrowed at access. */
[key: string]: unknown;
}
/**
* Publish options. Extends `amqplib`'s `Options.Publish` (correlationId,
* replyTo, messageId, persistent, expiration, etc. — see amqplib types)
* and pins a sensible default priority.
*/
interface MessageOptions extends amqp.Options.Publish {
/**
* Message priority for AMQP priority queues. Valid range 0..255 per
* the AMQP spec; values above `QueueParams.maxPriority` are capped by
* the broker. On a non-priority queue (no `x-max-priority` argument)
* the broker accepts but ignores this field.
* @default 5
*/
priority?: number;
/** Custom AMQP headers (routing for `headers` exchanges; arbitrary metadata otherwise). */
headers?: Record<string, unknown>;
}
/**
* Async handler for a single delivery. Receives the parsed JSON payload
* plus terminal `ack` / `nack` callbacks. AMQP message properties
* (correlationId, headers, replyTo, …) are NOT currently surfaced —
* embed them in the body if you need them.
*
* `nack` always sends `requeue=false` so the message routes through any
* configured dead-letter exchange. To replay, publish a fresh copy
* (see `examples/02-retry-dlq`).
*
* First terminal call wins (per-delivery): once `ack()` or `nack()` has
* fired — or the library's safety-net `nack` has fired because the
* handler threw without calling either — any further `ack()` / `nack()`
* call from the same handler invocation is suppressed (logged at
* `warn`). This guards against the protocol-error path where a handler
* that called `ack()` and then threw would otherwise have both `ack`
* and `nack` go on the wire for the same delivery.
*
* The library does NOT validate payloads at runtime; the `T = unknown`
* default forces TypeScript callers to narrow before access, but
* provides no JS-level guarantee. For untrusted payloads, validate the
* shape (zod / valibot / hand-rolled type guard) inside the handler
* before acting on fields.
*
* @typeParam T the shape of the parsed message body.
*/
type MessageHandler<T = unknown> = (msg: T, ack: () => void, nack: () => void) => Promise<void>;
declare abstract class RabbitMQBase {
protected connection: amqp.ChannelModel;
protected channel: amqp.Channel;
protected config: RabbitMQConfig;
protected logger: Logger;
constructor(config: RabbitMQConfig);
/**
* 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).
*/
connect(): Promise<void>;
/**
* Iterate the config's `exchanges` array and assert each one against
* the broker. Called by `initialize()`; idempotent on the broker side.
* @protected
*/
protected setupExchanges(): Promise<void>;
/**
* 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}.
*/
registerExchange(exchange: ExchangeParams): Promise<void>;
/**
* Iterate the config's `queues` array, assert each queue and create
* its bindings. Called by `Consumer.initialize()`.
* @protected
*/
protected setupQueues(): Promise<void>;
/**
* 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).
*/
registerQueue(queue: QueueParams): Promise<amqp.Replies.AssertQueue>;
/**
* 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.
*/
disconnect(): Promise<void>;
}
declare class RabbitMQProducer extends RabbitMQBase {
private exchanges;
/**
* 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}.
*/
initialize(): Promise<void>;
/**
* 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.
*/
connect(): Promise<void>;
/**
* 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.
*/
registerExchange(exchange: ExchangeParams): Promise<void>;
/**
* 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()`.
*/
publish<T>(exchangeName: string, routingKey: string, message: T, options?: MessageOptions): Promise<boolean>;
}
declare class RabbitMQConsumer extends RabbitMQBase {
private retries;
private handlers;
/** Queue names we have an active `consume()` subscription on, so we can re-subscribe after reconnect. */
private subscribedQueues;
/** queueName → active amqplib consumerTag, so `unRegisterHandler` can issue `basic.cancel`. */
private consumerTags;
/** Guard so multiple 'close' events don't kick off concurrent reconnect loops. */
private reconnectInProgress;
/**
* 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}.
*/
initialize(): Promise<void>;
/**
* 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.
*/
connect(): Promise<void>;
/**
* 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".
*/
private handleReconnect;
/**
* 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<T>(queueName: string, handler: MessageHandler<T>): void;
/**
* 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.
*/
unRegisterHandler(queueName: string): Promise<void>;
/**
* 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`).
*/
consume(queueName: string): Promise<amqp.Replies.Consume>;
/**
* 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.
*/
disconnect(): Promise<void>;
/**
* 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.
*/
private buildDeliveryCallback;
}
export { RabbitMQBase, RabbitMQConsumer, RabbitMQProducer };
export type { ExchangeParams, Logger, Message, MessageHandler, MessageOptions, QueueParams, RabbitMQConfig };