UNPKG

@bitrix24/b24rabbitmq

Version:

Library for integrating Bitrix24 applications with RabbitMQ

421 lines (415 loc) 19.4 kB
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 };