@warlock.js/herald 5.30.0 → 5.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -2
- package/README.md +17 -16
- package/cjs/index.cjs.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.mjs.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-driver.d.mts.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-driver.mjs.map +1 -1
- package/esm/types/channel.types.d.mts +1 -1
- package/llms-full.txt +62 -57
- package/package.json +3 -3
- package/skills/consume-message/SKILL.md +30 -28
- package/skills/herald-basics/SKILL.md +8 -8
- package/skills/index.md +4 -1
- package/skills/publish-message/SKILL.md +19 -16
- package/skills/request-and-respond/SKILL.md +5 -5
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rabbitmq-channel.mjs","names":[],"sources":["../../../../../../../../../packages/herald/src/drivers/rabbitmq/rabbitmq-channel.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\nimport { v } from \"@warlock.js/seal\";\nimport { randomUUID } from \"node:crypto\";\nimport type { ChannelContract } from \"../../contracts\";\nimport type {\n ChannelOptions,\n ChannelStats,\n Message,\n MessageContext,\n MessageHandler,\n MessageMetadata,\n PublishOptions,\n RequestOptions,\n ResponseHandler,\n SubscribeOptions,\n Subscription,\n} from \"../../types\";\n\n/**\n * RabbitMQ Channel Implementation\n *\n * Wraps a RabbitMQ queue/exchange with a unified API.\n *\n * @template TPayload - The typed payload\n */\nexport class RabbitMQChannel<TPayload = unknown> implements ChannelContract<TPayload> {\n public readonly name: string;\n public readonly options: ChannelOptions<TPayload>;\n\n private readonly amqpChannel: any;\n private readonly subscriptions = new Map<string, RabbitMQSubscription>();\n private asserted = false;\n\n /**\n * Create a new RabbitMQ channel\n */\n public constructor(name: string, amqpChannel: any, options?: ChannelOptions<TPayload>) {\n this.name = name;\n this.amqpChannel = amqpChannel;\n this.options = options ?? {};\n }\n\n /**\n * Assert the queue exists\n */\n public async assert(): Promise<void> {\n if (this.asserted) return;\n\n const queueOptions = {\n durable: this.options.durable ?? true,\n autoDelete: this.options.autoDelete ?? false,\n exclusive: this.options.exclusive ?? false,\n messageTtl: this.options.messageTtl,\n maxLength: this.options.maxLength,\n deadLetterExchange: this.options.deadLetter?.channel ? \"\" : undefined,\n deadLetterRoutingKey: this.options.deadLetter?.channel,\n };\n\n await this.amqpChannel.assertQueue(this.name, queueOptions);\n this.asserted = true;\n }\n\n /**\n * Publish a message\n */\n public async publish(payload: TPayload, options?: PublishOptions): Promise<void> {\n await this.assert();\n\n // Validate with schema if provided\n if (this.options.schema) {\n const context = {\n allValues: payload,\n value: payload,\n };\n const result = await v.validate(this.options.schema, payload, { context });\n if (!result.isValid) {\n throw new Error(`Message validation failed: ${JSON.stringify(result.errors)}`);\n }\n payload = result.data as TPayload;\n }\n\n const messageId = randomUUID();\n const timestamp = new Date();\n\n const messageContent = JSON.stringify({\n payload,\n metadata: {\n messageId,\n timestamp: timestamp.toISOString(),\n correlationId: options?.correlationId,\n headers: options?.headers,\n },\n });\n\n const publishOptions: any = {\n persistent: options?.persistent ?? true,\n messageId,\n timestamp: timestamp.getTime(),\n correlationId: options?.correlationId,\n expiration: options?.expiration?.toString(),\n priority: options?.priority,\n headers: options?.headers,\n };\n\n // Handle delayed messages (requires rabbitmq-delayed-message-exchange plugin)\n if (options?.delay) {\n publishOptions.headers = {\n ...publishOptions.headers,\n \"x-delay\": options.delay,\n };\n }\n\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), publishOptions);\n }\n\n /**\n * Publish multiple messages\n */\n public async publishBatch(messages: TPayload[], options?: PublishOptions): Promise<void> {\n for (const payload of messages) {\n await this.publish(payload, options);\n }\n }\n\n /**\n * Subscribe to messages\n *\n * Smart auto-ack behavior (when autoAck is not true):\n * - If handler completes successfully without explicit ack/nack/reject → auto-ack\n * - If handler throws an error → auto-nack (with retry if configured)\n * - If handler explicitly calls ack/nack/reject → respects that call\n */\n public async subscribe(\n handler: MessageHandler<TPayload>,\n options?: SubscribeOptions,\n ): Promise<Subscription> {\n await this.assert();\n\n // Use consumerId from options if provided, otherwise generate a random one\n const subscriptionId = options?.consumerId ?? randomUUID();\n\n // Set prefetch if specified\n if (options?.prefetch) {\n await this.amqpChannel.prefetch(options.prefetch);\n }\n\n // If autoAck is true, RabbitMQ handles ack immediately (fire-and-forget)\n const isFireAndForget = options?.autoAck === true;\n\n const consumerOptions = {\n noAck: isFireAndForget,\n exclusive: options?.exclusive ?? false,\n consumerTag: options?.group ?? subscriptionId,\n };\n\n const { consumerTag } = await this.amqpChannel.consume(\n this.name,\n async (msg: any) => {\n if (!msg) return;\n\n // Track if acknowledgment was handled explicitly\n let ackHandled = isFireAndForget;\n\n // Populated once the message is successfully parsed, so the catch\n // block below can dead-letter with the full envelope. Stays\n // `undefined` when `JSON.parse` itself is what threw.\n let parsedMessage: Message<TPayload> | undefined;\n\n try {\n const content = JSON.parse(msg.content.toString());\n let payload = content.payload as TPayload;\n\n // Validate with schema if provided\n if (this.options.schema) {\n const context = {\n allValues: payload,\n value: payload,\n };\n const result = await v.validate(this.options.schema, payload, { context });\n if (!result.isValid) {\n // Reject invalid messages\n this.amqpChannel.nack(msg, false, false);\n return;\n }\n payload = result.data as TPayload;\n }\n\n const metadata: MessageMetadata = {\n messageId: msg.properties.messageId || content.metadata?.messageId || randomUUID(),\n timestamp: new Date(msg.properties.timestamp || content.metadata?.timestamp),\n correlationId: msg.properties.correlationId || content.metadata?.correlationId,\n replyTo: msg.properties.replyTo,\n priority: msg.properties.priority,\n headers: msg.properties.headers,\n retryCount: msg.properties.headers?.[\"x-retry-count\"] || 0,\n originalChannel: this.name,\n };\n\n const message: Message<TPayload> = {\n metadata,\n payload,\n raw: msg,\n };\n\n parsedMessage = message;\n\n const context: MessageContext = {\n ack: async () => {\n if (!ackHandled) {\n ackHandled = true;\n this.amqpChannel.ack(msg);\n }\n },\n nack: async (requeue = true) => {\n if (!ackHandled) {\n ackHandled = true;\n this.amqpChannel.nack(msg, false, requeue);\n }\n },\n reject: async () => {\n if (!ackHandled) {\n ackHandled = true;\n this.amqpChannel.reject(msg, false);\n }\n },\n reply: async <T>(replyPayload: T) => {\n if (msg.properties.replyTo) {\n const replyContent = JSON.stringify({\n payload: replyPayload,\n metadata: {\n messageId: randomUUID(),\n timestamp: new Date().toISOString(),\n correlationId: msg.properties.correlationId,\n },\n });\n\n this.amqpChannel.sendToQueue(msg.properties.replyTo, Buffer.from(replyContent), {\n correlationId: msg.properties.correlationId,\n });\n }\n },\n retry: async (delay?: number) => {\n if (ackHandled) return;\n ackHandled = true;\n\n await this.retryOrGiveUp(msg, metadata.retryCount || 0, options, message, delay);\n },\n };\n\n // Execute handler\n await handler(message, context);\n\n // Smart auto-ack: if handler succeeded and didn't explicitly handle ack\n if (!ackHandled) {\n this.amqpChannel.ack(msg);\n }\n } catch (error) {\n // Smart auto-nack: if handler threw and didn't explicitly handle ack\n if (ackHandled) return;\n\n if (options?.retry) {\n // A bare `nack(msg, false, true)` redelivers the ORIGINAL message\n // untouched — amqplib/RabbitMQ do not add an `x-retry-count`\n // header on requeue, so a plain requeue here never advances the\n // counter and `maxRetries`/`deadLetter` are silently never\n // reached. Route through the same bounded-retry path `ctx.retry()`\n // uses, so the counter increments (and the cap/dead-letter fires)\n // on this automatic path too.\n const currentRetryCount = msg.properties.headers?.[\"x-retry-count\"] || 0;\n await this.retryOrGiveUp(msg, currentRetryCount, options, parsedMessage);\n } else {\n // No retry configured - reject without requeue (already bounded:\n // a single attempt, no requeue loop possible).\n this.amqpChannel.nack(msg, false, false);\n }\n }\n },\n consumerOptions,\n );\n\n const subscription = new RabbitMQSubscription(\n subscriptionId,\n this.name,\n consumerTag,\n this.amqpChannel,\n );\n\n this.subscriptions.set(subscriptionId, subscription);\n\n return subscription;\n }\n\n /**\n * Unsubscribe by consumer ID\n */\n public async unsubscribeById(consumerId: string): Promise<void> {\n const subscription = this.subscriptions.get(consumerId);\n if (subscription) {\n await subscription.unsubscribe();\n this.subscriptions.delete(consumerId);\n }\n }\n\n /**\n * Stop consuming messages on this channel.\n * Cancels all active subscriptions gracefully.\n */\n public async stopConsuming(): Promise<void> {\n const cancellations = Array.from(this.subscriptions.values()).map(sub =>\n sub.unsubscribe(),\n );\n await Promise.all(cancellations);\n }\n\n /**\n * Send message to dead-letter queue\n */\n private async sendToDeadLetter(\n message: Message<TPayload>,\n deadLetterChannel: string,\n ): Promise<void> {\n const content = JSON.stringify({\n payload: message.payload,\n metadata: {\n ...message.metadata,\n originalChannel: this.name,\n },\n });\n\n this.amqpChannel.sendToQueue(deadLetterChannel, Buffer.from(content), { persistent: true });\n }\n\n /**\n * Dead-letter a message whose body couldn't be parsed into a {@link Message}\n * (e.g. `JSON.parse` itself threw before an envelope existed). Forwards the\n * raw AMQP bytes/properties as-is rather than re-deriving a payload, so a\n * malformed message isn't lost.\n */\n private sendToDeadLetterRaw(msg: any, deadLetterChannel: string): void {\n this.amqpChannel.sendToQueue(deadLetterChannel, msg.content, {\n ...msg.properties,\n persistent: true,\n });\n }\n\n /**\n * Bounded retry shared by the explicit `ctx.retry()` call and the automatic\n * catch when a handler throws without calling it itself — so both paths\n * honor the same cap instead of the automatic path silently requeueing\n * forever (see `subscribe()`'s catch block).\n *\n * Under the cap: republishes with an incremented `x-retry-count` header —\n * NOT a plain `nack(msg, false, true)`, which redelivers the original\n * message untouched and never advances the counter.\n *\n * At/over the cap: dead-letters if configured, otherwise drops the message\n * with a loud `log.error` (never a silent drop) so an operator can see a\n * poison message was discarded instead of it vanishing without a trace.\n */\n private async retryOrGiveUp(\n msg: any,\n currentRetryCount: number,\n options: SubscribeOptions | undefined,\n parsedMessage: Message<TPayload> | undefined,\n delay?: number,\n ): Promise<void> {\n const retryCount = currentRetryCount + 1;\n const maxRetries = options?.retry?.maxRetries ?? 3;\n\n if (retryCount > maxRetries) {\n if (options?.deadLetter) {\n if (parsedMessage) {\n await this.sendToDeadLetter(parsedMessage, options.deadLetter.channel);\n } else {\n this.sendToDeadLetterRaw(msg, options.deadLetter.channel);\n }\n } else {\n log.error(\n \"herald\",\n \"poison-message\",\n `Dropping message on channel \"${this.name}\" after ${retryCount - 1} failed ` +\n `${retryCount - 1 === 1 ? \"retry\" : \"retries\"} (maxRetries: ${maxRetries}) with no ` +\n `dead-letter channel configured.`,\n { channel: this.name, retryCount: retryCount - 1, maxRetries },\n );\n this.amqpChannel.reject(msg, false);\n return;\n }\n\n this.amqpChannel.ack(msg);\n return;\n }\n\n const headers: Record<string, unknown> = {\n ...msg.properties.headers,\n \"x-retry-count\": retryCount,\n };\n\n if (delay) {\n headers[\"x-delay\"] = delay;\n }\n\n this.amqpChannel.sendToQueue(this.name, msg.content, { ...msg.properties, headers });\n\n this.amqpChannel.ack(msg);\n }\n\n /**\n * Request-response pattern\n */\n public async request<TResponse = unknown>(\n payload: TPayload,\n options?: RequestOptions,\n ): Promise<TResponse> {\n await this.assert();\n\n const correlationId = randomUUID();\n const timeout = options?.timeout ?? 30000;\n\n // Create exclusive reply queue\n const { queue: replyQueue } = await this.amqpChannel.assertQueue(\"\", {\n exclusive: true,\n autoDelete: true,\n });\n\n return new Promise<TResponse>((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new Error(`Request timeout after ${timeout}ms`));\n }, timeout);\n\n // Consume reply\n this.amqpChannel.consume(\n replyQueue,\n (msg: any) => {\n if (msg?.properties.correlationId === correlationId) {\n clearTimeout(timeoutId);\n const content = JSON.parse(msg.content.toString());\n resolve(content.payload as TResponse);\n }\n },\n { noAck: true },\n );\n\n // Send request\n const messageContent = JSON.stringify({\n payload,\n metadata: {\n messageId: randomUUID(),\n timestamp: new Date().toISOString(),\n correlationId,\n },\n });\n\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), {\n correlationId,\n replyTo: replyQueue,\n expiration: timeout.toString(),\n ...options,\n });\n });\n }\n\n /**\n * Register response handler for RPC\n */\n public async respond<TResponse = unknown>(\n handler: ResponseHandler<TPayload, TResponse>,\n ): Promise<Subscription> {\n return this.subscribe(async (message, ctx) => {\n const response = await handler(message, ctx);\n await ctx.reply(response);\n await ctx.ack();\n });\n }\n\n /**\n * Get queue statistics\n */\n public async stats(): Promise<ChannelStats> {\n await this.assert();\n\n const queueInfo = await this.amqpChannel.checkQueue(this.name);\n\n return {\n name: this.name,\n messageCount: queueInfo.messageCount,\n consumerCount: queueInfo.consumerCount,\n };\n }\n\n /**\n * Purge all messages\n */\n public async purge(): Promise<number> {\n await this.assert();\n\n const result = await this.amqpChannel.purgeQueue(this.name);\n return result.messageCount;\n }\n\n /**\n * Check if queue exists\n */\n public async exists(): Promise<boolean> {\n try {\n await this.amqpChannel.checkQueue(this.name);\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Delete the queue\n */\n public async delete(): Promise<void> {\n // Cancel all subscriptions\n for (const subscription of this.subscriptions.values()) {\n await subscription.unsubscribe();\n }\n this.subscriptions.clear();\n\n try {\n await this.amqpChannel.deleteQueue(this.name);\n } catch {\n // Ignore if queue doesn't exist\n }\n\n this.asserted = false;\n }\n}\n\n/**\n * RabbitMQ Subscription Implementation\n */\nclass RabbitMQSubscription implements Subscription {\n public readonly id: string;\n public readonly channel: string;\n public readonly consumerTag: string;\n\n private readonly amqpChannel: any;\n private _isActive = true;\n\n public constructor(id: string, channel: string, consumerTag: string, amqpChannel: any) {\n this.id = id;\n this.channel = channel;\n this.consumerTag = consumerTag;\n this.amqpChannel = amqpChannel;\n }\n\n public async unsubscribe(): Promise<void> {\n if (!this._isActive) return;\n\n await this.amqpChannel.cancel(this.consumerTag);\n this._isActive = false;\n }\n\n public async pause(): Promise<void> {\n // RabbitMQ doesn't have native pause, cancel consumer\n await this.amqpChannel.cancel(this.consumerTag);\n }\n\n public async resume(): Promise<void> {\n // Would need to re-subscribe - not directly supported\n throw new Error(\"Resume is not supported for RabbitMQ. Please create a new subscription.\");\n }\n\n public isActive(): boolean {\n return this._isActive;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"rabbitmq-channel.mjs","names":[],"sources":["../../../../../../../../../packages/herald/src/drivers/rabbitmq/rabbitmq-channel.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\nimport { v } from \"@warlock.js/seal\";\nimport { randomUUID } from \"node:crypto\";\nimport type { ChannelContract } from \"../../contracts\";\nimport type {\n ChannelOptions,\n ChannelStats,\n Message,\n MessageContext,\n MessageHandler,\n MessageMetadata,\n PublishOptions,\n RequestOptions,\n ResponseHandler,\n SubscribeOptions,\n Subscription,\n} from \"../../types\";\n\n/**\n * RabbitMQ Channel Implementation\n *\n * Wraps a RabbitMQ queue/exchange with a unified API.\n *\n * @template TPayload - The typed payload\n */\nexport class RabbitMQChannel<TPayload = unknown> implements ChannelContract<TPayload> {\n public readonly name: string;\n public readonly options: ChannelOptions<TPayload>;\n\n private readonly amqpChannel: any;\n private readonly subscriptions = new Map<string, RabbitMQSubscription>();\n private asserted = false;\n\n /**\n * Create a new RabbitMQ channel\n */\n public constructor(name: string, amqpChannel: any, options?: ChannelOptions<TPayload>) {\n this.name = name;\n this.amqpChannel = amqpChannel;\n this.options = options ?? {};\n }\n\n /**\n * Assert the queue exists\n */\n public async assert(): Promise<void> {\n if (this.asserted) return;\n\n const queueOptions = {\n durable: this.options.durable ?? true,\n autoDelete: this.options.autoDelete ?? false,\n exclusive: this.options.exclusive ?? false,\n messageTtl: this.options.messageTtl,\n maxLength: this.options.maxLength,\n deadLetterExchange: this.options.deadLetter?.channel ? \"\" : undefined,\n deadLetterRoutingKey: this.options.deadLetter?.channel,\n };\n\n await this.amqpChannel.assertQueue(this.name, queueOptions);\n this.asserted = true;\n }\n\n /**\n * Publish a message\n */\n public async publish(payload: TPayload, options?: PublishOptions): Promise<void> {\n await this.assert();\n\n // Validate with schema if provided\n if (this.options.schema) {\n const context = {\n allValues: payload,\n value: payload,\n };\n const result = await v.validate(this.options.schema, payload, { context });\n if (!result.isValid) {\n throw new Error(`Message validation failed: ${JSON.stringify(result.errors)}`);\n }\n payload = result.data as TPayload;\n }\n\n const messageId = randomUUID();\n const timestamp = new Date();\n\n const messageContent = JSON.stringify({\n payload,\n metadata: {\n messageId,\n timestamp: timestamp.toISOString(),\n correlationId: options?.correlationId,\n headers: options?.headers,\n },\n });\n\n const publishOptions: any = {\n persistent: options?.persistent ?? true,\n messageId,\n timestamp: timestamp.getTime(),\n correlationId: options?.correlationId,\n expiration: options?.expiration?.toString(),\n priority: options?.priority,\n headers: options?.headers,\n };\n\n // Handle delayed messages (requires rabbitmq-delayed-message-exchange plugin)\n if (options?.delay) {\n publishOptions.headers = {\n ...publishOptions.headers,\n \"x-delay\": options.delay,\n };\n }\n\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), publishOptions);\n }\n\n /**\n * Publish multiple messages\n */\n public async publishBatch(messages: TPayload[], options?: PublishOptions): Promise<void> {\n for (const payload of messages) {\n await this.publish(payload, options);\n }\n }\n\n /**\n * Subscribe to messages\n *\n * Smart auto-ack behavior (when autoAck is not true):\n * - If handler completes successfully without explicit ack/nack/reject → auto-ack\n * - If handler throws an error → auto-nack (with retry if configured)\n * - If handler explicitly calls ack/nack/reject → respects that call\n */\n public async subscribe(\n handler: MessageHandler<TPayload>,\n options?: SubscribeOptions,\n ): Promise<Subscription> {\n await this.assert();\n\n // Use consumerId from options if provided, otherwise generate a random one\n const subscriptionId = options?.consumerId ?? randomUUID();\n\n // Set prefetch if specified\n if (options?.prefetch) {\n await this.amqpChannel.prefetch(options.prefetch);\n }\n\n // If autoAck is true, RabbitMQ handles ack immediately (fire-and-forget)\n const isFireAndForget = options?.autoAck === true;\n\n const consumerOptions = {\n noAck: isFireAndForget,\n exclusive: options?.exclusive ?? false,\n consumerTag: options?.group ?? subscriptionId,\n };\n\n const { consumerTag } = await this.amqpChannel.consume(\n this.name,\n async (msg: any) => {\n if (!msg) return;\n\n // Track if acknowledgment was handled explicitly\n let ackHandled = isFireAndForget;\n\n // Populated once the message is successfully parsed, so the catch\n // block below can dead-letter with the full envelope. Stays\n // `undefined` when `JSON.parse` itself is what threw.\n let parsedMessage: Message<TPayload> | undefined;\n\n try {\n const content = JSON.parse(msg.content.toString());\n let payload = content.payload as TPayload;\n\n // Validate with schema if provided\n if (this.options.schema) {\n const context = {\n allValues: payload,\n value: payload,\n };\n const result = await v.validate(this.options.schema, payload, { context });\n if (!result.isValid) {\n // Reject invalid messages\n this.amqpChannel.nack(msg, false, false);\n return;\n }\n payload = result.data as TPayload;\n }\n\n const metadata: MessageMetadata = {\n messageId: msg.properties.messageId || content.metadata?.messageId || randomUUID(),\n timestamp: new Date(msg.properties.timestamp || content.metadata?.timestamp),\n correlationId: msg.properties.correlationId || content.metadata?.correlationId,\n replyTo: msg.properties.replyTo,\n priority: msg.properties.priority,\n headers: msg.properties.headers,\n retryCount: msg.properties.headers?.[\"x-retry-count\"] || 0,\n originalChannel: this.name,\n };\n\n const message: Message<TPayload> = {\n metadata,\n payload,\n raw: msg,\n };\n\n parsedMessage = message;\n\n const context: MessageContext = {\n ack: async () => {\n if (!ackHandled) {\n ackHandled = true;\n this.amqpChannel.ack(msg);\n }\n },\n nack: async (requeue = true) => {\n if (!ackHandled) {\n ackHandled = true;\n this.amqpChannel.nack(msg, false, requeue);\n }\n },\n reject: async () => {\n if (!ackHandled) {\n ackHandled = true;\n this.amqpChannel.reject(msg, false);\n }\n },\n reply: async <T>(replyPayload: T) => {\n if (msg.properties.replyTo) {\n const replyContent = JSON.stringify({\n payload: replyPayload,\n metadata: {\n messageId: randomUUID(),\n timestamp: new Date().toISOString(),\n correlationId: msg.properties.correlationId,\n },\n });\n\n this.amqpChannel.sendToQueue(msg.properties.replyTo, Buffer.from(replyContent), {\n correlationId: msg.properties.correlationId,\n });\n }\n },\n retry: async (delay?: number) => {\n if (ackHandled) return;\n ackHandled = true;\n\n await this.retryOrGiveUp(msg, metadata.retryCount || 0, options, message, delay);\n },\n };\n\n // Execute handler\n await handler(message, context);\n\n // Smart auto-ack: if handler succeeded and didn't explicitly handle ack\n if (!ackHandled) {\n this.amqpChannel.ack(msg);\n }\n } catch (error) {\n // Smart auto-nack: if handler threw and didn't explicitly handle ack\n if (ackHandled) return;\n\n if (options?.retry) {\n // A bare `nack(msg, false, true)` redelivers the ORIGINAL message\n // untouched — amqplib/RabbitMQ do not add an `x-retry-count`\n // header on requeue, so a plain requeue here never advances the\n // counter and `maxRetries`/`deadLetter` are silently never\n // reached. Route through the same bounded-retry path `ctx.retry()`\n // uses, so the counter increments (and the cap/dead-letter fires)\n // on this automatic path too.\n const currentRetryCount = msg.properties.headers?.[\"x-retry-count\"] || 0;\n await this.retryOrGiveUp(msg, currentRetryCount, options, parsedMessage);\n } else {\n // No retry configured - reject without requeue (already bounded:\n // a single attempt, no requeue loop possible).\n this.amqpChannel.nack(msg, false, false);\n }\n }\n },\n consumerOptions,\n );\n\n const subscription = new RabbitMQSubscription(\n subscriptionId,\n this.name,\n consumerTag,\n this.amqpChannel,\n );\n\n this.subscriptions.set(subscriptionId, subscription);\n\n return subscription;\n }\n\n /**\n * Unsubscribe by consumer ID\n */\n public async unsubscribeById(consumerId: string): Promise<void> {\n const subscription = this.subscriptions.get(consumerId);\n if (subscription) {\n await subscription.unsubscribe();\n this.subscriptions.delete(consumerId);\n }\n }\n\n /**\n * Stop consuming messages on this channel.\n * Cancels all active subscriptions gracefully.\n */\n public async stopConsuming(): Promise<void> {\n const cancellations = Array.from(this.subscriptions.values()).map((sub) => sub.unsubscribe());\n await Promise.all(cancellations);\n }\n\n /**\n * Send message to dead-letter queue\n */\n private async sendToDeadLetter(\n message: Message<TPayload>,\n deadLetterChannel: string,\n ): Promise<void> {\n const content = JSON.stringify({\n payload: message.payload,\n metadata: {\n ...message.metadata,\n originalChannel: this.name,\n },\n });\n\n this.amqpChannel.sendToQueue(deadLetterChannel, Buffer.from(content), { persistent: true });\n }\n\n /**\n * Dead-letter a message whose body couldn't be parsed into a {@link Message}\n * (e.g. `JSON.parse` itself threw before an envelope existed). Forwards the\n * raw AMQP bytes/properties as-is rather than re-deriving a payload, so a\n * malformed message isn't lost.\n */\n private sendToDeadLetterRaw(msg: any, deadLetterChannel: string): void {\n this.amqpChannel.sendToQueue(deadLetterChannel, msg.content, {\n ...msg.properties,\n persistent: true,\n });\n }\n\n /**\n * Bounded retry shared by the explicit `ctx.retry()` call and the automatic\n * catch when a handler throws without calling it itself — so both paths\n * honor the same cap instead of the automatic path silently requeueing\n * forever (see `subscribe()`'s catch block).\n *\n * Under the cap: republishes with an incremented `x-retry-count` header —\n * NOT a plain `nack(msg, false, true)`, which redelivers the original\n * message untouched and never advances the counter.\n *\n * At/over the cap: dead-letters if configured, otherwise drops the message\n * with a loud `log.error` (never a silent drop) so an operator can see a\n * poison message was discarded instead of it vanishing without a trace.\n */\n private async retryOrGiveUp(\n msg: any,\n currentRetryCount: number,\n options: SubscribeOptions | undefined,\n parsedMessage: Message<TPayload> | undefined,\n delay?: number,\n ): Promise<void> {\n const retryCount = currentRetryCount + 1;\n const maxRetries = options?.retry?.maxRetries ?? 3;\n\n if (retryCount > maxRetries) {\n if (options?.deadLetter) {\n if (parsedMessage) {\n await this.sendToDeadLetter(parsedMessage, options.deadLetter.channel);\n } else {\n this.sendToDeadLetterRaw(msg, options.deadLetter.channel);\n }\n } else {\n log.error(\n \"herald\",\n \"poison-message\",\n `Dropping message on channel \"${this.name}\" after ${retryCount - 1} failed ` +\n `${retryCount - 1 === 1 ? \"retry\" : \"retries\"} (maxRetries: ${maxRetries}) with no ` +\n `dead-letter channel configured.`,\n { channel: this.name, retryCount: retryCount - 1, maxRetries },\n );\n this.amqpChannel.reject(msg, false);\n return;\n }\n\n this.amqpChannel.ack(msg);\n return;\n }\n\n const headers: Record<string, unknown> = {\n ...msg.properties.headers,\n \"x-retry-count\": retryCount,\n };\n\n if (delay) {\n headers[\"x-delay\"] = delay;\n }\n\n this.amqpChannel.sendToQueue(this.name, msg.content, { ...msg.properties, headers });\n\n this.amqpChannel.ack(msg);\n }\n\n /**\n * Request-response pattern\n */\n public async request<TResponse = unknown>(\n payload: TPayload,\n options?: RequestOptions,\n ): Promise<TResponse> {\n await this.assert();\n\n const correlationId = randomUUID();\n const timeout = options?.timeout ?? 30000;\n\n // Create exclusive reply queue\n const { queue: replyQueue } = await this.amqpChannel.assertQueue(\"\", {\n exclusive: true,\n autoDelete: true,\n });\n\n return new Promise<TResponse>((resolve, reject) => {\n const timeoutId = setTimeout(() => {\n reject(new Error(`Request timeout after ${timeout}ms`));\n }, timeout);\n\n // Consume reply\n this.amqpChannel.consume(\n replyQueue,\n (msg: any) => {\n if (msg?.properties.correlationId === correlationId) {\n clearTimeout(timeoutId);\n const content = JSON.parse(msg.content.toString());\n resolve(content.payload as TResponse);\n }\n },\n { noAck: true },\n );\n\n // Send request\n const messageContent = JSON.stringify({\n payload,\n metadata: {\n messageId: randomUUID(),\n timestamp: new Date().toISOString(),\n correlationId,\n },\n });\n\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), {\n correlationId,\n replyTo: replyQueue,\n expiration: timeout.toString(),\n ...options,\n });\n });\n }\n\n /**\n * Register response handler for RPC\n */\n public async respond<TResponse = unknown>(\n handler: ResponseHandler<TPayload, TResponse>,\n ): Promise<Subscription> {\n return this.subscribe(async (message, ctx) => {\n const response = await handler(message, ctx);\n await ctx.reply(response);\n await ctx.ack();\n });\n }\n\n /**\n * Get queue statistics\n */\n public async stats(): Promise<ChannelStats> {\n await this.assert();\n\n const queueInfo = await this.amqpChannel.checkQueue(this.name);\n\n return {\n name: this.name,\n messageCount: queueInfo.messageCount,\n consumerCount: queueInfo.consumerCount,\n };\n }\n\n /**\n * Purge all messages\n */\n public async purge(): Promise<number> {\n await this.assert();\n\n const result = await this.amqpChannel.purgeQueue(this.name);\n return result.messageCount;\n }\n\n /**\n * Check if queue exists\n */\n public async exists(): Promise<boolean> {\n try {\n await this.amqpChannel.checkQueue(this.name);\n return true;\n } catch {\n return false;\n }\n }\n\n /**\n * Delete the queue\n */\n public async delete(): Promise<void> {\n // Cancel all subscriptions\n for (const subscription of this.subscriptions.values()) {\n await subscription.unsubscribe();\n }\n this.subscriptions.clear();\n\n try {\n await this.amqpChannel.deleteQueue(this.name);\n } catch {\n // Ignore if queue doesn't exist\n }\n\n this.asserted = false;\n }\n}\n\n/**\n * RabbitMQ Subscription Implementation\n */\nclass RabbitMQSubscription implements Subscription {\n public readonly id: string;\n public readonly channel: string;\n public readonly consumerTag: string;\n\n private readonly amqpChannel: any;\n private _isActive = true;\n\n public constructor(id: string, channel: string, consumerTag: string, amqpChannel: any) {\n this.id = id;\n this.channel = channel;\n this.consumerTag = consumerTag;\n this.amqpChannel = amqpChannel;\n }\n\n public async unsubscribe(): Promise<void> {\n if (!this._isActive) return;\n\n await this.amqpChannel.cancel(this.consumerTag);\n this._isActive = false;\n }\n\n public async pause(): Promise<void> {\n // RabbitMQ doesn't have native pause, cancel consumer\n await this.amqpChannel.cancel(this.consumerTag);\n }\n\n public async resume(): Promise<void> {\n // Would need to re-subscribe - not directly supported\n throw new Error(\"Resume is not supported for RabbitMQ. Please create a new subscription.\");\n }\n\n public isActive(): boolean {\n return this._isActive;\n }\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rabbitmq-driver.d.mts","names":[],"sources":["../../../../../../../../../packages/herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"mappings":";;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"rabbitmq-driver.d.mts","names":[],"sources":["../../../../../../../../../packages/herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"mappings":";;;;;;;;;;;;;;;AAuGA;;;;;;;;;;;;;;;cAAa,cAAA,YAA0B,oBAAA;EAAA,SACrB,IAAA;EAAA,SAEA,SAAA,EAAW,kBAAA;EAAA,iBAEV,OAAA;EAAA,iBACA,MAAA;EAAA,iBACA,QAAA;EAAA,QAET,UAAA;EAAA,QACA,WAAA;EAAA,QACA,YAAA;EAwSiC;;;;;cAjStB,OAAA,EAAS,yBAAA;EAfZ;;;EAAA,IAsBL,WAAA;EAlBM;;;;;;;;;;EAgCV,SAAA,CAAU,QAAA,EAAU,kBAAA;EAsBpB;;;EAAA,WAAA,CAAY,QAAA,EAAU,kBAAA;EAcd;;;;EAAR,OAAA,YAAmB,MAAA,eAAqB,KAAA,EAAO,YAAA,CAAa,QAAA;EAOtD;;;EAAA,OAAA,IAAW,OAAA;EA2GX;;;EAAA,QA5CL,kBAAA;EAsEE;;;EAAA,QA/CI,eAAA;EAsDI;;;EAjCL,UAAA,IAAc,OAAA;EAwCpB;;;EAdA,EAAA,CAAG,KAAA,EAAO,WAAA,EAAa,QAAA,EAAU,mBAAA;EAgBb;;;EATpB,GAAA,CAAI,KAAA,EAAO,WAAA,EAAa,QAAA,EAAU,mBAAA;EA2B5B;;;EApBN,OAAA,qBACL,IAAA,UACA,OAAA,GAAU,cAAA,CAAe,QAAA,IACxB,eAAA,CAAgB,QAAA;EAoCN;;;EAnBA,cAAA,IAAkB,OAAA;EA0DlB;;;;EAjDA,aAAA,IAAiB,OAAA;EAmEP;AAAA;;EAzDV,WAAA,IAAe,OAAA,CAAQ,iBAAA;;;;EAgC7B,eAAA;;;;EAOM,YAAA,CAAa,IAAA,WAAe,OAAA;;;;EAWlC,aAAA;;;;EAOA,gBAAA;AAAA"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rabbitmq-driver.mjs","names":[],"sources":["../../../../../../../../../packages/herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"sourcesContent":["import { EventEmitter } from \"node:events\";\nimport type { BrokerDriverContract, ChannelContract } from \"../../contracts\";\nimport { EventMessage } from \"../../message-managers/event-message\";\nimport { type EventConsumerClass } from \"../../message-managers/types\";\nimport type {\n BrokerDriverType,\n BrokerEvent,\n BrokerEventListener,\n ChannelOptions,\n HealthCheckResult,\n RabbitMQConnectionOptions,\n} from \"../../types\";\nimport { prepareConsumerSubscription } from \"./../../message-managers/prepare-consumer-subscription\";\nimport { RabbitMQChannel } from \"./rabbitmq-channel\";\n\n// ============================================================\n// Lazy-loaded amqplib Module\n// ============================================================\n\n/**\n * Shape of the lazily-imported amqplib module\n */\ntype AmqplibModule = typeof import(\"amqplib\");\n\n/**\n * The single amqplib load, shared by every caller.\n *\n * Memoized as a promise rather than as a resolved value so the loader stays\n * idempotent: without it, two callers arriving before the first `import()`\n * settles would each start their own load and the last writer would win, so a\n * caller could end up observing a module instance it never awaited.\n *\n * Resolves to `undefined` when amqplib is not installed.\n */\nlet amqplibModulePromise: Promise<AmqplibModule | undefined> | undefined;\n\n/**\n * Installation instructions for amqplib\n */\nconst AMQPLIB_INSTALL_INSTRUCTIONS = `\nRabbitMQ driver requires the amqplib package.\nInstall it with:\n\n npx warlock add herald --driver=rabbitmq\n\nOr manually:\n\n npm install amqplib\n pnpm add amqplib\n yarn add amqplib\n`.trim();\n\n/**\n * Strip `user:password@` credentials from any `amqp(s)://` URL embedded in a\n * string. The connection URL carries plaintext broker credentials, and\n * amqplib/Node's URL parser commonly echoes the offending URL verbatim in a\n * malformed-URL error (e.g. an unencoded `@`/`:` in the password) — applied\n * to every error `connect()` surfaces so a credential never reaches whatever\n * the host app does with a thrown connection error (console.error,\n * structured logging, an error tracker).\n */\nfunction redactAmqpCredentials(message: string): string {\n return message.replace(/(amqps?:\\/\\/)[^/@\\s]+@/gi, \"$1****:****@\");\n}\n\n/**\n * Load amqplib, reusing the single shared load for every caller.\n *\n * @returns The amqplib module, or `undefined` when it is not installed.\n */\nfunction loadAmqplibModule(): Promise<AmqplibModule | undefined> {\n if (!amqplibModulePromise) {\n amqplibModulePromise = import(\"amqplib\").catch(() => undefined);\n }\n\n return amqplibModulePromise;\n}\n\n// ============================================================\n// RabbitMQ Driver\n// ============================================================\n\n/**\n * RabbitMQ Driver\n *\n * Implementation of BrokerDriverContract for RabbitMQ/AMQP.\n *\n * **Important:** This driver requires the `amqplib` package to be installed.\n * Install it with: `npx warlock add herald --driver=rabbitmq` or `npm install amqplib`\n *\n * @example\n * ```typescript\n * const driver = new RabbitMQDriver({\n * driver: \"rabbitmq\",\n * host: \"localhost\",\n * port: 5672,\n * username: \"guest\",\n * password: \"guest\",\n * });\n *\n * await driver.connect();\n * const channel = driver.channel(\"user.created\");\n * ```\n */\nexport class RabbitMQDriver implements BrokerDriverContract {\n public readonly name = \"rabbitmq\" as const;\n\n public readonly consumers: EventConsumerClass[] = [];\n\n private readonly options: RabbitMQConnectionOptions;\n private readonly events = new EventEmitter();\n private readonly channels = new Map<string, ChannelContract<any>>();\n\n private connection: any = null;\n private amqpChannel: any = null;\n private _isConnected = false;\n\n /**\n * Create a new RabbitMQ driver\n *\n * @param options - RabbitMQ connection options\n */\n public constructor(options: RabbitMQConnectionOptions) {\n this.options = options;\n }\n\n /**\n * Whether connected to RabbitMQ\n */\n public get isConnected(): boolean {\n return this._isConnected;\n }\n\n /**\n * Subscribe the given consumer class to the driver\n *\n * @param consumer - Consumer class to subscribe\n *\n * @example\n * ```typescript\n * driver.subscribe(UserUpdatedConsumer);\n * ```\n */\n public subscribe(Consumer: EventConsumerClass) {\n if (this.isConnected) {\n this.channel(Consumer.eventName).subscribe(\n prepareConsumerSubscription(Consumer, (error, eventName) => {\n this.events.emit(\"error\", error, eventName);\n }),\n {\n consumerId: Consumer.consumerId,\n },\n );\n } else {\n this.consumers.push(Consumer);\n }\n\n return () => {\n this.unsubscribe(Consumer);\n };\n }\n\n /**\n * Unsubscribe the given consumer\n */\n public unsubscribe(Consumer: EventConsumerClass): void {\n if (this.isConnected) {\n this.channel(Consumer.eventName).unsubscribeById(Consumer.consumerId);\n }\n const index = this.consumers.indexOf(Consumer);\n if (index > -1) {\n this.consumers.splice(index, 1);\n }\n }\n\n /**\n * Publish the given event message.\n * Auto-creates the channel if it hasn't been accessed before.\n */\n public publish<TPayload = Record<string, any>>(event: EventMessage<TPayload>): void {\n this.channel(event.eventName).publish(event.serialize());\n }\n\n /**\n * Connect to RabbitMQ\n */\n public async connect(): Promise<void> {\n const amqplib = await loadAmqplibModule();\n\n if (!amqplib) {\n throw new Error(`amqplib is not installed.\\n\\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);\n }\n\n try {\n // Build connection URL\n const url = this.buildConnectionUrl();\n\n // Build connection options merging our options with native client options\n const connectOptions = {\n heartbeat: this.options.heartbeat ?? 60,\n timeout: this.options.connectionTimeout,\n // Merge native amqplib client options\n ...this.options.clientOptions,\n };\n\n // Connect using cached module\n this.connection = await amqplib.connect(url, connectOptions);\n\n // Create channel\n this.amqpChannel = await this.connection.createChannel();\n\n // Set prefetch if specified\n if (this.options.prefetch) {\n await this.amqpChannel.prefetch(this.options.prefetch);\n }\n\n this._isConnected = true;\n this.events.emit(\"connected\");\n\n for (const consumer of this.consumers) {\n this.subscribe(consumer);\n }\n\n this.consumers.length = 0;\n\n // Handle connection close\n this.connection.on(\"close\", () => {\n this._isConnected = false;\n this.events.emit(\"disconnected\");\n\n if (this.options.reconnect !== false) {\n this.handleReconnect();\n }\n });\n\n // Handle errors\n this.connection.on(\"error\", (error: Error) => {\n this.events.emit(\"error\", error);\n });\n } catch (error) {\n this._isConnected = false;\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(`Failed to connect to RabbitMQ: ${redactAmqpCredentials(message)}`);\n }\n }\n\n /**\n * Build connection URL from options\n */\n private buildConnectionUrl(): string {\n if (this.options.uri) {\n return this.options.uri;\n }\n\n const protocol = \"amqp\";\n const host = this.options.host ?? \"localhost\";\n const port = this.options.port ?? 5672;\n const vhost = this.options.vhost ?? \"/\";\n // URI-encoded so a credential containing a reserved URL character\n // (`@`, `:`, `/`, whitespace — common in generated secrets) can't produce\n // a malformed URL whose parser error echoes the raw credential back.\n const username = encodeURIComponent(this.options.username ?? \"guest\");\n const password = encodeURIComponent(this.options.password ?? \"guest\");\n\n const encodedVhost = encodeURIComponent(vhost);\n\n return `${protocol}://${username}:${password}@${host}:${port}/${encodedVhost}`;\n }\n\n /**\n * Handle reconnection\n */\n private async handleReconnect(): Promise<void> {\n const delay = this.options.reconnectDelay ?? 5000;\n let attempt = 0;\n\n const tryReconnect = async () => {\n attempt++;\n this.events.emit(\"reconnecting\", attempt);\n\n try {\n await this.connect();\n } catch {\n setTimeout(tryReconnect, delay);\n }\n };\n\n setTimeout(tryReconnect, delay);\n }\n\n /**\n * Disconnect from RabbitMQ\n */\n public async disconnect(): Promise<void> {\n if (this.amqpChannel) {\n try {\n await this.amqpChannel.close();\n } catch {\n // Ignore close errors\n }\n this.amqpChannel = null;\n }\n\n if (this.connection) {\n try {\n await this.connection.close();\n } catch {\n // Ignore close errors\n }\n this.connection = null;\n }\n\n this._isConnected = false;\n this.events.emit(\"disconnected\");\n }\n\n /**\n * Register event listener\n */\n public on(event: BrokerEvent, listener: BrokerEventListener): void {\n this.events.on(event, listener as any);\n }\n\n /**\n * Remove event listener\n */\n public off(event: BrokerEvent, listener: BrokerEventListener): void {\n this.events.off(event, listener as any);\n }\n\n /**\n * Get or create a channel\n */\n public channel<TPayload = unknown>(\n name: string,\n options?: ChannelOptions<TPayload>,\n ): ChannelContract<TPayload> {\n // Check cache\n const existing = this.channels.get(name);\n if (existing) {\n return existing as ChannelContract<TPayload>;\n }\n\n // Create new channel\n const channel = new RabbitMQChannel<TPayload>(name, this.amqpChannel, options);\n\n this.channels.set(name, channel);\n return channel;\n }\n\n /**\n * Start consuming messages\n */\n public async startConsuming(): Promise<void> {\n // Channels start consuming when subscribe() is called\n // This method is for batch start if needed\n }\n\n /**\n * Stop consuming messages from all subscribed channels.\n * Gracefully cancels all active consumers.\n */\n public async stopConsuming(): Promise<void> {\n const stops = Array.from(this.channels.values()).map(channel =>\n (channel as RabbitMQChannel<any>).stopConsuming(),\n );\n await Promise.all(stops);\n }\n\n /**\n * Health check\n */\n public async healthCheck(): Promise<HealthCheckResult> {\n if (!this._isConnected || !this.connection) {\n return {\n healthy: false,\n error: \"Not connected to RabbitMQ\",\n };\n }\n\n const start = Date.now();\n\n try {\n // Simple check - verify channel is open\n await this.amqpChannel.checkQueue(\"amq.rabbitmq.reply-to\").catch(() => {\n // Queue might not exist, but if we get here, connection is alive\n });\n\n return {\n healthy: true,\n latency: Date.now() - start,\n };\n } catch (error) {\n return {\n healthy: false,\n error: error instanceof Error ? error.message : String(error),\n latency: Date.now() - start,\n };\n }\n }\n\n /**\n * Get all channel names\n */\n public getChannelNames(): string[] {\n return Array.from(this.channels.keys());\n }\n\n /**\n * Close a specific channel\n */\n public async closeChannel(name: string): Promise<void> {\n const channel = this.channels.get(name);\n if (channel) {\n await channel.delete();\n this.channels.delete(name);\n }\n }\n\n /**\n * Get the raw AMQP channel (for advanced use)\n */\n public getRawChannel(): any {\n return this.amqpChannel;\n }\n\n /**\n * Get the raw connection (for advanced use)\n */\n public getRawConnection(): any {\n return this.connection;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"rabbitmq-driver.mjs","names":[],"sources":["../../../../../../../../../packages/herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"sourcesContent":["import { EventEmitter } from \"node:events\";\nimport type { BrokerDriverContract, ChannelContract } from \"../../contracts\";\nimport { EventMessage } from \"../../message-managers/event-message\";\nimport { type EventConsumerClass } from \"../../message-managers/types\";\nimport type {\n BrokerEvent,\n BrokerEventListener,\n ChannelOptions,\n HealthCheckResult,\n RabbitMQConnectionOptions,\n} from \"../../types\";\nimport { prepareConsumerSubscription } from \"./../../message-managers/prepare-consumer-subscription\";\nimport { RabbitMQChannel } from \"./rabbitmq-channel\";\n\n// ============================================================\n// Lazy-loaded amqplib Module\n// ============================================================\n\n/**\n * Shape of the lazily-imported amqplib module\n */\ntype AmqplibModule = typeof import(\"amqplib\");\n\n/**\n * The single amqplib load, shared by every caller.\n *\n * Memoized as a promise rather than as a resolved value so the loader stays\n * idempotent: without it, two callers arriving before the first `import()`\n * settles would each start their own load and the last writer would win, so a\n * caller could end up observing a module instance it never awaited.\n *\n * Resolves to `undefined` when amqplib is not installed.\n */\nlet amqplibModulePromise: Promise<AmqplibModule | undefined> | undefined;\n\n/**\n * Installation instructions for amqplib\n */\nconst AMQPLIB_INSTALL_INSTRUCTIONS = `\nRabbitMQ driver requires the amqplib package.\nInstall it with:\n\n npx warlock add herald --driver=rabbitmq\n\nOr manually:\n\n npm install amqplib\n pnpm add amqplib\n yarn add amqplib\n`.trim();\n\n/**\n * Strip `user:password@` credentials from any `amqp(s)://` URL embedded in a\n * string. The connection URL carries plaintext broker credentials, and\n * amqplib/Node's URL parser commonly echoes the offending URL verbatim in a\n * malformed-URL error (e.g. an unencoded `@`/`:` in the password) — applied\n * to every error `connect()` surfaces so a credential never reaches whatever\n * the host app does with a thrown connection error (console.error,\n * structured logging, an error tracker).\n */\nfunction redactAmqpCredentials(message: string): string {\n return message.replace(/(amqps?:\\/\\/)[^/@\\s]+@/gi, \"$1****:****@\");\n}\n\n/**\n * Load amqplib, reusing the single shared load for every caller.\n *\n * @returns The amqplib module, or `undefined` when it is not installed.\n */\nfunction loadAmqplibModule(): Promise<AmqplibModule | undefined> {\n if (!amqplibModulePromise) {\n amqplibModulePromise = import(\"amqplib\").catch(() => undefined);\n }\n\n return amqplibModulePromise;\n}\n\n// ============================================================\n// RabbitMQ Driver\n// ============================================================\n\n/**\n * RabbitMQ Driver\n *\n * Implementation of BrokerDriverContract for RabbitMQ/AMQP.\n *\n * **Important:** This driver requires the `amqplib` package to be installed.\n * Install it with: `npx warlock add herald --driver=rabbitmq` or `npm install amqplib`\n *\n * @example\n * ```typescript\n * const driver = new RabbitMQDriver({\n * driver: \"rabbitmq\",\n * host: \"localhost\",\n * port: 5672,\n * username: \"guest\",\n * password: \"guest\",\n * });\n *\n * await driver.connect();\n * const channel = driver.channel(\"user.created\");\n * ```\n */\nexport class RabbitMQDriver implements BrokerDriverContract {\n public readonly name = \"rabbitmq\" as const;\n\n public readonly consumers: EventConsumerClass[] = [];\n\n private readonly options: RabbitMQConnectionOptions;\n private readonly events = new EventEmitter();\n private readonly channels = new Map<string, ChannelContract<any>>();\n\n private connection: any = null;\n private amqpChannel: any = null;\n private _isConnected = false;\n\n /**\n * Create a new RabbitMQ driver\n *\n * @param options - RabbitMQ connection options\n */\n public constructor(options: RabbitMQConnectionOptions) {\n this.options = options;\n }\n\n /**\n * Whether connected to RabbitMQ\n */\n public get isConnected(): boolean {\n return this._isConnected;\n }\n\n /**\n * Subscribe the given consumer class to the driver\n *\n * @param consumer - Consumer class to subscribe\n *\n * @example\n * ```typescript\n * driver.subscribe(UserUpdatedConsumer);\n * ```\n */\n public subscribe(Consumer: EventConsumerClass) {\n if (this.isConnected) {\n this.channel(Consumer.eventName).subscribe(\n prepareConsumerSubscription(Consumer, (error, eventName) => {\n this.events.emit(\"error\", error, eventName);\n }),\n {\n consumerId: Consumer.consumerId,\n },\n );\n } else {\n this.consumers.push(Consumer);\n }\n\n return () => {\n this.unsubscribe(Consumer);\n };\n }\n\n /**\n * Unsubscribe the given consumer\n */\n public unsubscribe(Consumer: EventConsumerClass): void {\n if (this.isConnected) {\n this.channel(Consumer.eventName).unsubscribeById(Consumer.consumerId);\n }\n const index = this.consumers.indexOf(Consumer);\n if (index > -1) {\n this.consumers.splice(index, 1);\n }\n }\n\n /**\n * Publish the given event message.\n * Auto-creates the channel if it hasn't been accessed before.\n */\n public publish<TPayload = Record<string, any>>(event: EventMessage<TPayload>): void {\n this.channel(event.eventName).publish(event.serialize());\n }\n\n /**\n * Connect to RabbitMQ\n */\n public async connect(): Promise<void> {\n const amqplib = await loadAmqplibModule();\n\n if (!amqplib) {\n throw new Error(`amqplib is not installed.\\n\\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);\n }\n\n try {\n // Build connection URL\n const url = this.buildConnectionUrl();\n\n // Build connection options merging our options with native client options\n const connectOptions = {\n heartbeat: this.options.heartbeat ?? 60,\n timeout: this.options.connectionTimeout,\n // Merge native amqplib client options\n ...this.options.clientOptions,\n };\n\n // Connect using cached module\n this.connection = await amqplib.connect(url, connectOptions);\n\n // Create channel\n this.amqpChannel = await this.connection.createChannel();\n\n // Set prefetch if specified\n if (this.options.prefetch) {\n await this.amqpChannel.prefetch(this.options.prefetch);\n }\n\n this._isConnected = true;\n this.events.emit(\"connected\");\n\n for (const consumer of this.consumers) {\n this.subscribe(consumer);\n }\n\n this.consumers.length = 0;\n\n // Handle connection close\n this.connection.on(\"close\", () => {\n this._isConnected = false;\n this.events.emit(\"disconnected\");\n\n if (this.options.reconnect !== false) {\n this.handleReconnect();\n }\n });\n\n // Handle errors\n this.connection.on(\"error\", (error: Error) => {\n this.events.emit(\"error\", error);\n });\n } catch (error) {\n this._isConnected = false;\n const message = error instanceof Error ? error.message : String(error);\n throw new Error(`Failed to connect to RabbitMQ: ${redactAmqpCredentials(message)}`);\n }\n }\n\n /**\n * Build connection URL from options\n */\n private buildConnectionUrl(): string {\n if (this.options.uri) {\n return this.options.uri;\n }\n\n const protocol = \"amqp\";\n const host = this.options.host ?? \"localhost\";\n const port = this.options.port ?? 5672;\n const vhost = this.options.vhost ?? \"/\";\n // URI-encoded so a credential containing a reserved URL character\n // (`@`, `:`, `/`, whitespace — common in generated secrets) can't produce\n // a malformed URL whose parser error echoes the raw credential back.\n const username = encodeURIComponent(this.options.username ?? \"guest\");\n const password = encodeURIComponent(this.options.password ?? \"guest\");\n\n const encodedVhost = encodeURIComponent(vhost);\n\n return `${protocol}://${username}:${password}@${host}:${port}/${encodedVhost}`;\n }\n\n /**\n * Handle reconnection\n */\n private async handleReconnect(): Promise<void> {\n const delay = this.options.reconnectDelay ?? 5000;\n let attempt = 0;\n\n const tryReconnect = async () => {\n attempt++;\n this.events.emit(\"reconnecting\", attempt);\n\n try {\n await this.connect();\n } catch {\n setTimeout(tryReconnect, delay);\n }\n };\n\n setTimeout(tryReconnect, delay);\n }\n\n /**\n * Disconnect from RabbitMQ\n */\n public async disconnect(): Promise<void> {\n if (this.amqpChannel) {\n try {\n await this.amqpChannel.close();\n } catch {\n // Ignore close errors\n }\n this.amqpChannel = null;\n }\n\n if (this.connection) {\n try {\n await this.connection.close();\n } catch {\n // Ignore close errors\n }\n this.connection = null;\n }\n\n this._isConnected = false;\n this.events.emit(\"disconnected\");\n }\n\n /**\n * Register event listener\n */\n public on(event: BrokerEvent, listener: BrokerEventListener): void {\n this.events.on(event, listener as any);\n }\n\n /**\n * Remove event listener\n */\n public off(event: BrokerEvent, listener: BrokerEventListener): void {\n this.events.off(event, listener as any);\n }\n\n /**\n * Get or create a channel\n */\n public channel<TPayload = unknown>(\n name: string,\n options?: ChannelOptions<TPayload>,\n ): ChannelContract<TPayload> {\n // Check cache\n const existing = this.channels.get(name);\n if (existing) {\n return existing as ChannelContract<TPayload>;\n }\n\n // Create new channel\n const channel = new RabbitMQChannel<TPayload>(name, this.amqpChannel, options);\n\n this.channels.set(name, channel);\n return channel;\n }\n\n /**\n * Start consuming messages\n */\n public async startConsuming(): Promise<void> {\n // Channels start consuming when subscribe() is called\n // This method is for batch start if needed\n }\n\n /**\n * Stop consuming messages from all subscribed channels.\n * Gracefully cancels all active consumers.\n */\n public async stopConsuming(): Promise<void> {\n const stops = Array.from(this.channels.values()).map((channel) =>\n (channel as RabbitMQChannel<any>).stopConsuming(),\n );\n await Promise.all(stops);\n }\n\n /**\n * Health check\n */\n public async healthCheck(): Promise<HealthCheckResult> {\n if (!this._isConnected || !this.connection) {\n return {\n healthy: false,\n error: \"Not connected to RabbitMQ\",\n };\n }\n\n const start = Date.now();\n\n try {\n // Simple check - verify channel is open\n await this.amqpChannel.checkQueue(\"amq.rabbitmq.reply-to\").catch(() => {\n // Queue might not exist, but if we get here, connection is alive\n });\n\n return {\n healthy: true,\n latency: Date.now() - start,\n };\n } catch (error) {\n return {\n healthy: false,\n error: error instanceof Error ? error.message : String(error),\n latency: Date.now() - start,\n };\n }\n }\n\n /**\n * Get all channel names\n */\n public getChannelNames(): string[] {\n return Array.from(this.channels.keys());\n }\n\n /**\n * Close a specific channel\n */\n public async closeChannel(name: string): Promise<void> {\n const channel = this.channels.get(name);\n if (channel) {\n await channel.delete();\n this.channels.delete(name);\n }\n }\n\n /**\n * Get the raw AMQP channel (for advanced use)\n */\n public getRawChannel(): any {\n return this.amqpChannel;\n }\n\n /**\n * Get the raw connection (for advanced use)\n */\n public getRawConnection(): any {\n return this.connection;\n }\n}\n"],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { DeadLetterOptions } from "./subscribe.types.mjs";
|
|
|
2
2
|
import { BaseValidator } from "@warlock.js/seal";
|
|
3
3
|
|
|
4
4
|
//#region ../../packages/herald/src/types/channel.types.d.ts
|
|
5
|
-
type ChannelOptions<
|
|
5
|
+
type ChannelOptions<_TPayload = unknown> = {
|
|
6
6
|
type?: "queue" | "topic" | "fanout";
|
|
7
7
|
durable?: boolean;
|
|
8
8
|
autoDelete?: boolean;
|
package/llms-full.txt
CHANGED
|
@@ -25,9 +25,9 @@ herald()
|
|
|
25
25
|
.subscribe(async (message, ctx) => {
|
|
26
26
|
try {
|
|
27
27
|
await sendWelcomeEmail(message.payload.email);
|
|
28
|
-
await ctx.ack();
|
|
28
|
+
await ctx.ack(); // success — message removed from queue
|
|
29
29
|
} catch (error) {
|
|
30
|
-
await ctx.nack(true);
|
|
30
|
+
await ctx.nack(true); // failure — requeue for redelivery
|
|
31
31
|
}
|
|
32
32
|
});
|
|
33
33
|
```
|
|
@@ -38,7 +38,7 @@ herald()
|
|
|
38
38
|
- Handler **throws** → herald **auto-nacks** (requeue, or DLQ/reject once retries are exhausted — see below).
|
|
39
39
|
- Handler calls `ctx.ack()` / `ctx.nack()` / `ctx.reject()` / `ctx.retry()` → herald respects that and does nothing further.
|
|
40
40
|
|
|
41
|
-
So the `try/catch` above is optional — letting the error throw produces the same auto-nack. Call `ctx` methods explicitly only when you want a
|
|
41
|
+
So the `try/catch` above is optional — letting the error throw produces the same auto-nack. Call `ctx` methods explicitly only when you want a _non-default_ outcome (reject without requeue, route to DLQ, delayed retry). The one mode that loses messages is `autoAck: true`, where the broker acks on delivery before your handler runs.
|
|
42
42
|
|
|
43
43
|
## Message context — flow control
|
|
44
44
|
|
|
@@ -52,23 +52,23 @@ ctx.reply(payload); // for request-response pattern (see request-and-respond
|
|
|
52
52
|
|
|
53
53
|
Pick by intent:
|
|
54
54
|
|
|
55
|
-
| Intent
|
|
56
|
-
|
|
|
57
|
-
| Processed cleanly
|
|
58
|
-
| Transient failure, try again
|
|
59
|
-
| Permanent failure, send to DLQ
|
|
60
|
-
| Validation failure (bad message) | `ctx.reject()` — don't requeue
|
|
55
|
+
| Intent | Use |
|
|
56
|
+
| -------------------------------- | ------------------------------------- |
|
|
57
|
+
| Processed cleanly | `ctx.ack()` |
|
|
58
|
+
| Transient failure, try again | `ctx.nack(true)` or `ctx.retry(5000)` |
|
|
59
|
+
| Permanent failure, send to DLQ | `ctx.nack(false)` or `ctx.reject()` |
|
|
60
|
+
| Validation failure (bad message) | `ctx.reject()` — don't requeue |
|
|
61
61
|
|
|
62
62
|
## Subscribe options
|
|
63
63
|
|
|
64
64
|
```ts
|
|
65
65
|
await channel.subscribe(handler, {
|
|
66
|
-
group: "email-workers",
|
|
67
|
-
prefetch: 10,
|
|
68
|
-
autoAck: false,
|
|
69
|
-
exclusive: false,
|
|
66
|
+
group: "email-workers", // consumer group / tag — multiple consumers share work
|
|
67
|
+
prefetch: 10, // concurrency — how many in-flight messages this consumer holds
|
|
68
|
+
autoAck: false, // default; keep it false in production
|
|
69
|
+
exclusive: false, // single consumer only on this channel?
|
|
70
70
|
retry: {
|
|
71
|
-
maxRetries: 3,
|
|
71
|
+
maxRetries: 3, // redelivery ceiling on handler throw (delay not auto-applied — see below)
|
|
72
72
|
delay: 1000,
|
|
73
73
|
},
|
|
74
74
|
deadLetter: {
|
|
@@ -109,10 +109,12 @@ The DLQ always forwards the full original payload plus the message metadata, so
|
|
|
109
109
|
Messages that exhausted retries land in `user.created.failed`. Subscribe to it separately for alerting / manual inspection:
|
|
110
110
|
|
|
111
111
|
```ts
|
|
112
|
-
herald()
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
});
|
|
112
|
+
herald()
|
|
113
|
+
.channel("user.created.failed")
|
|
114
|
+
.subscribe(async (message, ctx) => {
|
|
115
|
+
await alerts.notify(`Failed: ${JSON.stringify(message.payload)}`);
|
|
116
|
+
await ctx.ack();
|
|
117
|
+
});
|
|
116
118
|
```
|
|
117
119
|
|
|
118
120
|
## Decorator-style consumers — `@Consumable` + `EventConsumer`
|
|
@@ -123,7 +125,7 @@ For class-based consumers that pair with the `EventMessage` (the `publish-messag
|
|
|
123
125
|
import { Consumable, EventConsumer } from "@warlock.js/herald";
|
|
124
126
|
import type { ConsumedEventMessage } from "@warlock.js/herald";
|
|
125
127
|
|
|
126
|
-
@Consumable()
|
|
128
|
+
@Consumable() // or @Consumable({ broker: "analytics" }) to target a non-default broker
|
|
127
129
|
export class UserCreatedConsumer extends EventConsumer<{ id: number; email: string }> {
|
|
128
130
|
public static eventName = "user.created";
|
|
129
131
|
|
|
@@ -175,7 +177,7 @@ export class UserCreatedConsumer extends EventConsumer {
|
|
|
175
177
|
import { defineConsumer } from "@warlock.js/herald";
|
|
176
178
|
|
|
177
179
|
export const userCreatedConsumer = defineConsumer<{ id: number; email: string }>("user.created", {
|
|
178
|
-
schema: userCreatedSchema,
|
|
180
|
+
schema: userCreatedSchema, // optional seal validation
|
|
179
181
|
handle: async (payload, event) => {
|
|
180
182
|
await sendWelcomeEmail(payload.email);
|
|
181
183
|
},
|
|
@@ -191,11 +193,11 @@ export const userCreatedConsumer = defineConsumer<{ id: number; email: string }>
|
|
|
191
193
|
```ts
|
|
192
194
|
const subscription = await channel.subscribe(handler);
|
|
193
195
|
|
|
194
|
-
subscription.id;
|
|
195
|
-
subscription.isActive();
|
|
196
|
+
subscription.id; // the consumer id (string)
|
|
197
|
+
subscription.isActive(); // boolean
|
|
196
198
|
|
|
197
|
-
await subscription.unsubscribe();
|
|
198
|
-
await subscription.pause();
|
|
199
|
+
await subscription.unsubscribe(); // cancel the consumer on the broker
|
|
200
|
+
await subscription.pause(); // also cancels the consumer (RabbitMQ has no native pause)
|
|
199
201
|
```
|
|
200
202
|
|
|
201
203
|
On the RabbitMQ driver, `pause()` and `unsubscribe()` both cancel the consumer; `resume()` is **not supported and throws** — create a fresh subscription to start consuming again. You can also cancel by id from the channel: `await channel.unsubscribeById(consumerId)`, or drop every consumer on the channel with `await channel.stopConsuming()`.
|
|
@@ -206,10 +208,10 @@ The channel exposes queue-management helpers beyond pub/sub:
|
|
|
206
208
|
|
|
207
209
|
```ts
|
|
208
210
|
const { messageCount, consumerCount } = await channel.stats(); // queue depth + live consumers
|
|
209
|
-
const purged = await channel.purge();
|
|
210
|
-
const ok = await channel.exists();
|
|
211
|
-
await channel.assert();
|
|
212
|
-
await channel.delete();
|
|
211
|
+
const purged = await channel.purge(); // drop all pending messages, returns the count
|
|
212
|
+
const ok = await channel.exists(); // does the queue exist on the broker?
|
|
213
|
+
await channel.assert(); // create the queue with its options (idempotent)
|
|
214
|
+
await channel.delete(); // remove the queue entirely
|
|
213
215
|
```
|
|
214
216
|
|
|
215
217
|
`assert()` runs lazily on the first `publish` / `subscribe`, so you rarely call it directly — reach for it only to pre-create a queue before any traffic.
|
|
@@ -304,20 +306,20 @@ await connectToBroker({
|
|
|
304
306
|
host: process.env.ANALYTICS_HOST,
|
|
305
307
|
});
|
|
306
308
|
|
|
307
|
-
herald().channel("emails").publish({
|
|
308
|
-
herald("analytics").channel("events").publish({
|
|
309
|
+
herald().channel("emails").publish({/* ... */}); // → notifications
|
|
310
|
+
herald("analytics").channel("events").publish({/* ... */}); // → analytics
|
|
309
311
|
```
|
|
310
312
|
|
|
311
313
|
`connectToBroker` defaults `isDefault` to `true`, and the registry promotes the most-recently-registered default. So when you register more than one broker, mark the secondaries `isDefault: false` — otherwise the last one wins and `herald()` returns the wrong broker.
|
|
312
314
|
|
|
313
315
|
## Pick a skill
|
|
314
316
|
|
|
315
|
-
| If the task is about…
|
|
316
|
-
|
|
|
317
|
-
| Publishing messages — single + batch + publish options (priority, ttl, delay, persistent, headers)
|
|
318
|
-
| Subscribing to messages — handler signature, message context (ack/nack/retry), prefetch, retry policy, dead-letter
|
|
319
|
-
| Request/response (RPC) — `channel.request(...)` + `channel.respond(...)` for synchronous-style calls over a message bus | the `request-and-respond` topic
|
|
320
|
-
| Schema-validated channels — `.channel(name, { schema })` with `@warlock.js/seal`
|
|
317
|
+
| If the task is about… | Load |
|
|
318
|
+
| ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
319
|
+
| Publishing messages — single + batch + publish options (priority, ttl, delay, persistent, headers) | the `publish-message` topic |
|
|
320
|
+
| Subscribing to messages — handler signature, message context (ack/nack/retry), prefetch, retry policy, dead-letter | the `consume-message` topic |
|
|
321
|
+
| Request/response (RPC) — `channel.request(...)` + `channel.respond(...)` for synchronous-style calls over a message bus | the `request-and-respond` topic |
|
|
322
|
+
| Schema-validated channels — `.channel(name, { schema })` with `@warlock.js/seal` | See the `seal-basics` topic of the `warlock-js-seal` skill + skills below |
|
|
321
323
|
|
|
322
324
|
## Things NOT to do
|
|
323
325
|
|
|
@@ -363,8 +365,8 @@ type UserPayload = { userId: number; email: string };
|
|
|
363
365
|
|
|
364
366
|
const channel = herald().channel<UserPayload>("user.created");
|
|
365
367
|
|
|
366
|
-
await channel.publish({ userId: 1, email: "test@example.com" });
|
|
367
|
-
await channel.publish({ userId: "1" } as never);
|
|
368
|
+
await channel.publish({ userId: 1, email: "test@example.com" }); // ✅ typed
|
|
369
|
+
await channel.publish({ userId: "1" } as never); // ❌ compile error
|
|
368
370
|
```
|
|
369
371
|
|
|
370
372
|
Share the payload type between producer and consumer via a common `types/` file.
|
|
@@ -381,7 +383,7 @@ const userSchema = v.object({
|
|
|
381
383
|
|
|
382
384
|
const channel = herald().channel("user.created", { schema: userSchema });
|
|
383
385
|
|
|
384
|
-
await channel.publish({ userId: 1, email: "invalid" });
|
|
386
|
+
await channel.publish({ userId: 1, email: "invalid" }); // Throws — fails .email()
|
|
385
387
|
```
|
|
386
388
|
|
|
387
389
|
The schema runs `validate()` before the message hits the broker. Invalid payloads never leave the producer. See the `seal-basics` topic of the `warlock-js-seal` skill.
|
|
@@ -390,10 +392,10 @@ The schema runs `validate()` before the message hits the broker. Invalid payload
|
|
|
390
392
|
|
|
391
393
|
```ts
|
|
392
394
|
await channel.publish(payload, {
|
|
393
|
-
priority: 5,
|
|
394
|
-
expiration: 60_000,
|
|
395
|
-
delay: 5_000,
|
|
396
|
-
persistent: true,
|
|
395
|
+
priority: 5, // 0-9, higher = served first
|
|
396
|
+
expiration: 60_000, // ms — message expires if not consumed (RabbitMQ uses `expiration`, not `ttl`)
|
|
397
|
+
delay: 5_000, // ms — delayed delivery (requires RabbitMQ delay plugin)
|
|
398
|
+
persistent: true, // survive broker restart (default true)
|
|
397
399
|
correlationId: "uuid", // for tracking across services
|
|
398
400
|
headers: {
|
|
399
401
|
tenantId: "42",
|
|
@@ -407,14 +409,17 @@ The headers field is free-form — consumers read `message.metadata.headers?.ten
|
|
|
407
409
|
## Batch publishing
|
|
408
410
|
|
|
409
411
|
```ts
|
|
410
|
-
await channel.publishBatch(
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
412
|
+
await channel.publishBatch(
|
|
413
|
+
[
|
|
414
|
+
{ userId: 1, email: "a@example.com" },
|
|
415
|
+
{ userId: 2, email: "b@example.com" },
|
|
416
|
+
{ userId: 3, email: "c@example.com" },
|
|
417
|
+
],
|
|
418
|
+
{
|
|
419
|
+
// Options apply to every message in the batch
|
|
420
|
+
persistent: true,
|
|
421
|
+
},
|
|
422
|
+
);
|
|
418
423
|
```
|
|
419
424
|
|
|
420
425
|
Under the hood `publishBatch` iterates and calls `.publish()` per message — there's no AMQP-level batching today. It's an ergonomic shape for "emit these N items" (bulk import, fanout to many recipients), not a throughput optimization. The same `options` apply to every message.
|
|
@@ -464,7 +469,7 @@ export default {
|
|
|
464
469
|
```ts
|
|
465
470
|
// The use case opts in — channel name defaults to the use case name.
|
|
466
471
|
export const createUserUseCase = useCase({
|
|
467
|
-
name: "users.create",
|
|
472
|
+
name: "users.create", // → publishes to channel "users.create"
|
|
468
473
|
schema: createUserSchema,
|
|
469
474
|
handler: async (data) => User.create(data),
|
|
470
475
|
broadcast: true,
|
|
@@ -548,12 +553,12 @@ The promise rejects if no reply arrives within `timeout` ms. Pick a sane number
|
|
|
548
553
|
|
|
549
554
|
## When to use it vs HTTP
|
|
550
555
|
|
|
551
|
-
| Use HTTP
|
|
552
|
-
|
|
|
556
|
+
| Use HTTP | Use request/respond |
|
|
557
|
+
| ------------------------------- | ------------------------------------------- |
|
|
553
558
|
| Stateless, fast, idempotent ops | Slow / queued ops where the caller can wait |
|
|
554
|
-
| Public API surface
|
|
555
|
-
| Frontend consumption
|
|
556
|
-
| Synchronous user-facing flow
|
|
559
|
+
| Public API surface | Internal service-to-service |
|
|
560
|
+
| Frontend consumption | Backend orchestration |
|
|
561
|
+
| Synchronous user-facing flow | Async-but-needs-result patterns |
|
|
557
562
|
|
|
558
563
|
The bus adds queue persistence and retry. HTTP is faster for sub-100ms ops; the bus shines when the operation has variable duration or needs a queue's backpressure.
|
|
559
564
|
|
package/package.json
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
"@mongez/copper": "^2.1.3",
|
|
9
9
|
"@mongez/events": "^2.2.8",
|
|
10
10
|
"@mongez/reinforcements": "^4.0.2",
|
|
11
|
-
"@warlock.js/logger": "5.
|
|
12
|
-
"@warlock.js/seal": "5.
|
|
11
|
+
"@warlock.js/logger": "5.31.0",
|
|
12
|
+
"@warlock.js/seal": "5.31.0"
|
|
13
13
|
},
|
|
14
14
|
"repository": {
|
|
15
15
|
"type": "git",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"author": "hassanzohdy",
|
|
39
39
|
"license": "MIT",
|
|
40
|
-
"version": "5.
|
|
40
|
+
"version": "5.31.0",
|
|
41
41
|
"main": "./cjs/index.cjs",
|
|
42
42
|
"module": "./esm/index.mjs",
|
|
43
43
|
"types": "./esm/index.d.mts",
|
|
@@ -17,9 +17,9 @@ herald()
|
|
|
17
17
|
.subscribe(async (message, ctx) => {
|
|
18
18
|
try {
|
|
19
19
|
await sendWelcomeEmail(message.payload.email);
|
|
20
|
-
await ctx.ack();
|
|
20
|
+
await ctx.ack(); // success — message removed from queue
|
|
21
21
|
} catch (error) {
|
|
22
|
-
await ctx.nack(true);
|
|
22
|
+
await ctx.nack(true); // failure — requeue for redelivery
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
```
|
|
@@ -30,7 +30,7 @@ herald()
|
|
|
30
30
|
- Handler **throws** → herald **auto-nacks** (requeue, or DLQ/reject once retries are exhausted — see below).
|
|
31
31
|
- Handler calls `ctx.ack()` / `ctx.nack()` / `ctx.reject()` / `ctx.retry()` → herald respects that and does nothing further.
|
|
32
32
|
|
|
33
|
-
So the `try/catch` above is optional — letting the error throw produces the same auto-nack. Call `ctx` methods explicitly only when you want a
|
|
33
|
+
So the `try/catch` above is optional — letting the error throw produces the same auto-nack. Call `ctx` methods explicitly only when you want a _non-default_ outcome (reject without requeue, route to DLQ, delayed retry). The one mode that loses messages is `autoAck: true`, where the broker acks on delivery before your handler runs.
|
|
34
34
|
|
|
35
35
|
## Message context — flow control
|
|
36
36
|
|
|
@@ -44,23 +44,23 @@ ctx.reply(payload); // for request-response pattern (see request-and-respond
|
|
|
44
44
|
|
|
45
45
|
Pick by intent:
|
|
46
46
|
|
|
47
|
-
| Intent
|
|
48
|
-
|
|
|
49
|
-
| Processed cleanly
|
|
50
|
-
| Transient failure, try again
|
|
51
|
-
| Permanent failure, send to DLQ
|
|
52
|
-
| Validation failure (bad message) | `ctx.reject()` — don't requeue
|
|
47
|
+
| Intent | Use |
|
|
48
|
+
| -------------------------------- | ------------------------------------- |
|
|
49
|
+
| Processed cleanly | `ctx.ack()` |
|
|
50
|
+
| Transient failure, try again | `ctx.nack(true)` or `ctx.retry(5000)` |
|
|
51
|
+
| Permanent failure, send to DLQ | `ctx.nack(false)` or `ctx.reject()` |
|
|
52
|
+
| Validation failure (bad message) | `ctx.reject()` — don't requeue |
|
|
53
53
|
|
|
54
54
|
## Subscribe options
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
57
|
await channel.subscribe(handler, {
|
|
58
|
-
group: "email-workers",
|
|
59
|
-
prefetch: 10,
|
|
60
|
-
autoAck: false,
|
|
61
|
-
exclusive: false,
|
|
58
|
+
group: "email-workers", // consumer group / tag — multiple consumers share work
|
|
59
|
+
prefetch: 10, // concurrency — how many in-flight messages this consumer holds
|
|
60
|
+
autoAck: false, // default; keep it false in production
|
|
61
|
+
exclusive: false, // single consumer only on this channel?
|
|
62
62
|
retry: {
|
|
63
|
-
maxRetries: 3,
|
|
63
|
+
maxRetries: 3, // redelivery ceiling on handler throw (delay not auto-applied — see below)
|
|
64
64
|
delay: 1000,
|
|
65
65
|
},
|
|
66
66
|
deadLetter: {
|
|
@@ -101,10 +101,12 @@ The DLQ always forwards the full original payload plus the message metadata, so
|
|
|
101
101
|
Messages that exhausted retries land in `user.created.failed`. Subscribe to it separately for alerting / manual inspection:
|
|
102
102
|
|
|
103
103
|
```ts
|
|
104
|
-
herald()
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
});
|
|
104
|
+
herald()
|
|
105
|
+
.channel("user.created.failed")
|
|
106
|
+
.subscribe(async (message, ctx) => {
|
|
107
|
+
await alerts.notify(`Failed: ${JSON.stringify(message.payload)}`);
|
|
108
|
+
await ctx.ack();
|
|
109
|
+
});
|
|
108
110
|
```
|
|
109
111
|
|
|
110
112
|
## Decorator-style consumers — `@Consumable` + `EventConsumer`
|
|
@@ -115,7 +117,7 @@ For class-based consumers that pair with the `EventMessage` (the `publish-messag
|
|
|
115
117
|
import { Consumable, EventConsumer } from "@warlock.js/herald";
|
|
116
118
|
import type { ConsumedEventMessage } from "@warlock.js/herald";
|
|
117
119
|
|
|
118
|
-
@Consumable()
|
|
120
|
+
@Consumable() // or @Consumable({ broker: "analytics" }) to target a non-default broker
|
|
119
121
|
export class UserCreatedConsumer extends EventConsumer<{ id: number; email: string }> {
|
|
120
122
|
public static eventName = "user.created";
|
|
121
123
|
|
|
@@ -167,7 +169,7 @@ export class UserCreatedConsumer extends EventConsumer {
|
|
|
167
169
|
import { defineConsumer } from "@warlock.js/herald";
|
|
168
170
|
|
|
169
171
|
export const userCreatedConsumer = defineConsumer<{ id: number; email: string }>("user.created", {
|
|
170
|
-
schema: userCreatedSchema,
|
|
172
|
+
schema: userCreatedSchema, // optional seal validation
|
|
171
173
|
handle: async (payload, event) => {
|
|
172
174
|
await sendWelcomeEmail(payload.email);
|
|
173
175
|
},
|
|
@@ -183,11 +185,11 @@ export const userCreatedConsumer = defineConsumer<{ id: number; email: string }>
|
|
|
183
185
|
```ts
|
|
184
186
|
const subscription = await channel.subscribe(handler);
|
|
185
187
|
|
|
186
|
-
subscription.id;
|
|
187
|
-
subscription.isActive();
|
|
188
|
+
subscription.id; // the consumer id (string)
|
|
189
|
+
subscription.isActive(); // boolean
|
|
188
190
|
|
|
189
|
-
await subscription.unsubscribe();
|
|
190
|
-
await subscription.pause();
|
|
191
|
+
await subscription.unsubscribe(); // cancel the consumer on the broker
|
|
192
|
+
await subscription.pause(); // also cancels the consumer (RabbitMQ has no native pause)
|
|
191
193
|
```
|
|
192
194
|
|
|
193
195
|
On the RabbitMQ driver, `pause()` and `unsubscribe()` both cancel the consumer; `resume()` is **not supported and throws** — create a fresh subscription to start consuming again. You can also cancel by id from the channel: `await channel.unsubscribeById(consumerId)`, or drop every consumer on the channel with `await channel.stopConsuming()`.
|
|
@@ -198,10 +200,10 @@ The channel exposes queue-management helpers beyond pub/sub:
|
|
|
198
200
|
|
|
199
201
|
```ts
|
|
200
202
|
const { messageCount, consumerCount } = await channel.stats(); // queue depth + live consumers
|
|
201
|
-
const purged = await channel.purge();
|
|
202
|
-
const ok = await channel.exists();
|
|
203
|
-
await channel.assert();
|
|
204
|
-
await channel.delete();
|
|
203
|
+
const purged = await channel.purge(); // drop all pending messages, returns the count
|
|
204
|
+
const ok = await channel.exists(); // does the queue exist on the broker?
|
|
205
|
+
await channel.assert(); // create the queue with its options (idempotent)
|
|
206
|
+
await channel.delete(); // remove the queue entirely
|
|
205
207
|
```
|
|
206
208
|
|
|
207
209
|
`assert()` runs lazily on the first `publish` / `subscribe`, so you rarely call it directly — reach for it only to pre-create a queue before any traffic.
|