@cloudamqp/amqp-client
Version:
AMQP 0-9-1 client, both for browsers (WebSocket) and node (TCP Socket)
172 lines (159 loc) • 5.4 kB
text/typescript
import { AMQPGeneratorConsumer } from "./amqp-consumer.js"
import type { AMQPChannel } from "./amqp-channel.js"
import type { AMQPConsumer } from "./amqp-consumer.js"
import type { AMQPMessage } from "./amqp-message.js"
import type { ConsumeParams } from "./amqp-channel.js"
import { decodeMessage } from "./amqp-codec-registry.js"
import type { ParserMap, CoderMap } from "./amqp-codec-registry.js"
/** @internal */
export interface ConsumerDefinition {
queueName: string
consumeParams: ConsumeParams
callback?: (msg: AMQPMessage) => void | Promise<void>
prefetch?: number
parsers?: ParserMap
coders?: CoderMap
requeueOnNack?: boolean
}
/**
* A persistent queue subscription returned by {@link AMQPQueue.subscribe}.
*
* Remains valid across reconnections — the underlying channel and consumer tag
* are swapped in-place after each reconnect. Use `cancel()` to unsubscribe and
* remove from auto-recovery.
*/
export class AMQPSubscription {
protected consumer: AMQPConsumer
/** @internal Consumer definition used for recovery after reconnect. */
readonly def: ConsumerDefinition
/** @internal */
onCancel?: () => void
/** @internal */
constructor(consumer: AMQPConsumer, def: ConsumerDefinition) {
this.consumer = consumer
this.def = def
}
/** The underlying channel. Reflects the most recent channel after a reconnect. */
get channel(): AMQPChannel {
return this.consumer.channel
}
/** The consumer tag. Reflects the most recent tag after a reconnect. */
get consumerTag(): string {
return this.consumer.tag
}
/**
* Cancel the subscription, close its dedicated channel, and remove it
* from session auto-recovery.
*
* The subscription owns the channel that `queue.subscribe()` opened
* for it, so cancelling here also closes that channel — otherwise
* each cancelled subscription leaks a channel until the connection
* drops.
*
* Best-effort: never throws on wire-level failures. If the channel
* dropped mid-cancel or the broker is gone, the consumer is already
* effectively dead — there's nothing for the caller to recover from,
* so swallowing the error means call sites don't need `.catch(() => {})`
* boilerplate around every cancel.
*/
async cancel(): Promise<void> {
this.onCancel?.()
const ch = this.consumer.channel
try {
await this.consumer.cancel()
} catch {
// Channel/connection closed before basic.cancel could complete —
// the consumer is gone either way.
}
if (!ch.closed) {
try {
await ch.close()
} catch {
// Channel/connection dropped before close could complete.
}
}
}
/**
* Swap in a new underlying consumer after reconnect.
* @internal
*/
setConsumer(consumer: AMQPConsumer): void {
this.consumer = consumer
}
}
/**
* A persistent queue subscription that yields messages via an async iterator.
* Returned by {@link AMQPQueue.subscribe} when no callback is provided.
*
* Bridges across reconnections — the iterator continues yielding after each
* reconnect without the caller needing to re-subscribe.
*
* @example
* ```ts
* const sub = await session.subscribe("my-queue", { noAck: true })
* for await (const msg of sub) {
* console.log(msg.bodyString())
* }
* ```
*/
export class AMQPGeneratorSubscription<P extends ParserMap = {}>
extends AMQPSubscription
implements AsyncIterable<AMQPMessage<P>>
{
private stopped = false
private consumerReady?: () => void
override setConsumer(consumer: AMQPConsumer): void {
super.setConsumer(consumer)
this.consumerReady?.()
delete this.consumerReady
}
override async cancel(): Promise<void> {
this.stopped = true
this.consumerReady?.()
delete this.consumerReady
await super.cancel()
}
async *[Symbol.asyncIterator](): AsyncGenerator<AMQPMessage<P>, void, undefined> {
const autoAck = !this.def.consumeParams.noAck
const requeueOnNack = this.def.requeueOnNack ?? true
let prev: AMQPMessage | undefined
while (!this.stopped) {
const consumer = this.consumer
if (!(consumer instanceof AMQPGeneratorConsumer)) {
throw new Error("Cannot iterate messages on a callback-based subscription")
}
let decodeError: unknown
try {
for await (const msg of consumer.messages) {
if (this.stopped) return
if (autoAck) await prev?.ack()
if (this.def.parsers || this.def.coders) {
try {
await decodeMessage(msg, this.def.parsers ?? {}, this.def.coders ?? {})
} catch (err) {
if (autoAck) {
await msg.nack(requeueOnNack)
continue
}
decodeError = err
throw err
}
}
prev = msg
yield msg as AMQPMessage<P>
}
} catch (err) {
// Decode errors should propagate to the caller.
if (err === decodeError) throw err
// Channel/connection close errors are expected during reconnect — swallow them.
}
// Reset on disconnect; unacked messages are requeued by the server when the channel closes
prev = undefined
if (!this.stopped) {
await new Promise<void>((resolve) => {
this.consumerReady = resolve
})
}
}
}
}