@warlock.js/herald 4.14.0 → 4.16.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 +42 -21
- package/cjs/index.cjs +97 -44
- package/cjs/index.cjs.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts +22 -0
- package/esm/drivers/rabbitmq/rabbitmq-channel.d.mts.map +1 -1
- package/esm/drivers/rabbitmq/rabbitmq-channel.mjs +63 -20
- 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 +33 -23
- package/esm/drivers/rabbitmq/rabbitmq-driver.mjs.map +1 -1
- package/esm/message-managers/prepare-consumer-subscription.mjs +1 -1
- package/esm/message-managers/prepare-consumer-subscription.mjs.map +1 -1
- package/llms-full.txt +6 -3
- package/package.json +20 -15
- package/skills/consume-message/SKILL.md +5 -3
- package/skills/herald-basics/SKILL.md +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rabbitmq-channel.mjs","names":[],"sources":["../../../../../../../../herald/src/drivers/rabbitmq/rabbitmq-channel.ts"],"sourcesContent":["import { v } from \"@warlock.js/seal\";\r\nimport { randomUUID } from \"node:crypto\";\r\nimport type { ChannelContract } from \"../../contracts\";\r\nimport type {\r\n ChannelOptions,\r\n ChannelStats,\r\n Message,\r\n MessageContext,\r\n MessageHandler,\r\n MessageMetadata,\r\n PublishOptions,\r\n RequestOptions,\r\n ResponseHandler,\r\n SubscribeOptions,\r\n Subscription,\r\n} from \"../../types\";\r\n\r\n/**\r\n * RabbitMQ Channel Implementation\r\n *\r\n * Wraps a RabbitMQ queue/exchange with a unified API.\r\n *\r\n * @template TPayload - The typed payload\r\n */\r\nexport class RabbitMQChannel<TPayload = unknown> implements ChannelContract<TPayload> {\r\n public readonly name: string;\r\n public readonly options: ChannelOptions<TPayload>;\r\n\r\n private readonly amqpChannel: any;\r\n private readonly subscriptions = new Map<string, RabbitMQSubscription>();\r\n private asserted = false;\r\n\r\n /**\r\n * Create a new RabbitMQ channel\r\n */\r\n public constructor(name: string, amqpChannel: any, options?: ChannelOptions<TPayload>) {\r\n this.name = name;\r\n this.amqpChannel = amqpChannel;\r\n this.options = options ?? {};\r\n }\r\n\r\n /**\r\n * Assert the queue exists\r\n */\r\n public async assert(): Promise<void> {\r\n if (this.asserted) return;\r\n\r\n const queueOptions = {\r\n durable: this.options.durable ?? true,\r\n autoDelete: this.options.autoDelete ?? false,\r\n exclusive: this.options.exclusive ?? false,\r\n messageTtl: this.options.messageTtl,\r\n maxLength: this.options.maxLength,\r\n deadLetterExchange: this.options.deadLetter?.channel ? \"\" : undefined,\r\n deadLetterRoutingKey: this.options.deadLetter?.channel,\r\n };\r\n\r\n await this.amqpChannel.assertQueue(this.name, queueOptions);\r\n this.asserted = true;\r\n }\r\n\r\n /**\r\n * Publish a message\r\n */\r\n public async publish(payload: TPayload, options?: PublishOptions): Promise<void> {\r\n await this.assert();\r\n\r\n // Validate with schema if provided\r\n if (this.options.schema) {\r\n const context = {\r\n allValues: payload,\r\n value: payload,\r\n };\r\n const result = await v.validate(this.options.schema, payload, { context });\r\n if (!result.isValid) {\r\n throw new Error(`Message validation failed: ${JSON.stringify(result.errors)}`);\r\n }\r\n payload = result.data as TPayload;\r\n }\r\n\r\n const messageId = randomUUID();\r\n const timestamp = new Date();\r\n\r\n const messageContent = JSON.stringify({\r\n payload,\r\n metadata: {\r\n messageId,\r\n timestamp: timestamp.toISOString(),\r\n correlationId: options?.correlationId,\r\n headers: options?.headers,\r\n },\r\n });\r\n\r\n const publishOptions: any = {\r\n persistent: options?.persistent ?? true,\r\n messageId,\r\n timestamp: timestamp.getTime(),\r\n correlationId: options?.correlationId,\r\n expiration: options?.expiration?.toString(),\r\n priority: options?.priority,\r\n headers: options?.headers,\r\n };\r\n\r\n // Handle delayed messages (requires rabbitmq-delayed-message-exchange plugin)\r\n if (options?.delay) {\r\n publishOptions.headers = {\r\n ...publishOptions.headers,\r\n \"x-delay\": options.delay,\r\n };\r\n }\r\n\r\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), publishOptions);\r\n }\r\n\r\n /**\r\n * Publish multiple messages\r\n */\r\n public async publishBatch(messages: TPayload[], options?: PublishOptions): Promise<void> {\r\n for (const payload of messages) {\r\n await this.publish(payload, options);\r\n }\r\n }\r\n\r\n /**\r\n * Subscribe to messages\r\n *\r\n * Smart auto-ack behavior (when autoAck is not true):\r\n * - If handler completes successfully without explicit ack/nack/reject → auto-ack\r\n * - If handler throws an error → auto-nack (with retry if configured)\r\n * - If handler explicitly calls ack/nack/reject → respects that call\r\n */\r\n public async subscribe(\r\n handler: MessageHandler<TPayload>,\r\n options?: SubscribeOptions,\r\n ): Promise<Subscription> {\r\n await this.assert();\r\n\r\n // Use consumerId from options if provided, otherwise generate a random one\r\n const subscriptionId = options?.consumerId ?? randomUUID();\r\n\r\n // Set prefetch if specified\r\n if (options?.prefetch) {\r\n await this.amqpChannel.prefetch(options.prefetch);\r\n }\r\n\r\n // If autoAck is true, RabbitMQ handles ack immediately (fire-and-forget)\r\n const isFireAndForget = options?.autoAck === true;\r\n\r\n const consumerOptions = {\r\n noAck: isFireAndForget,\r\n exclusive: options?.exclusive ?? false,\r\n consumerTag: options?.group ?? subscriptionId,\r\n };\r\n\r\n const { consumerTag } = await this.amqpChannel.consume(\r\n this.name,\r\n async (msg: any) => {\r\n if (!msg) return;\r\n\r\n // Track if acknowledgment was handled explicitly\r\n let ackHandled = isFireAndForget;\r\n\r\n try {\r\n const content = JSON.parse(msg.content.toString());\r\n let payload = content.payload as TPayload;\r\n\r\n // Validate with schema if provided\r\n if (this.options.schema) {\r\n const context = {\r\n allValues: payload,\r\n value: payload,\r\n };\r\n const result = await v.validate(this.options.schema, payload, { context });\r\n if (!result.isValid) {\r\n // Reject invalid messages\r\n this.amqpChannel.nack(msg, false, false);\r\n return;\r\n }\r\n payload = result.data as TPayload;\r\n }\r\n\r\n const metadata: MessageMetadata = {\r\n messageId: msg.properties.messageId || content.metadata?.messageId || randomUUID(),\r\n timestamp: new Date(msg.properties.timestamp || content.metadata?.timestamp),\r\n correlationId: msg.properties.correlationId || content.metadata?.correlationId,\r\n replyTo: msg.properties.replyTo,\r\n priority: msg.properties.priority,\r\n headers: msg.properties.headers,\r\n retryCount: msg.properties.headers?.[\"x-retry-count\"] || 0,\r\n originalChannel: this.name,\r\n };\r\n\r\n const message: Message<TPayload> = {\r\n metadata,\r\n payload,\r\n raw: msg,\r\n };\r\n\r\n const context: MessageContext = {\r\n ack: async () => {\r\n if (!ackHandled) {\r\n ackHandled = true;\r\n this.amqpChannel.ack(msg);\r\n }\r\n },\r\n nack: async (requeue = true) => {\r\n if (!ackHandled) {\r\n ackHandled = true;\r\n this.amqpChannel.nack(msg, false, requeue);\r\n }\r\n },\r\n reject: async () => {\r\n if (!ackHandled) {\r\n ackHandled = true;\r\n this.amqpChannel.reject(msg, false);\r\n }\r\n },\r\n reply: async <T>(replyPayload: T) => {\r\n if (msg.properties.replyTo) {\r\n const replyContent = JSON.stringify({\r\n payload: replyPayload,\r\n metadata: {\r\n messageId: randomUUID(),\r\n timestamp: new Date().toISOString(),\r\n correlationId: msg.properties.correlationId,\r\n },\r\n });\r\n\r\n this.amqpChannel.sendToQueue(msg.properties.replyTo, Buffer.from(replyContent), {\r\n correlationId: msg.properties.correlationId,\r\n });\r\n }\r\n },\r\n retry: async (delay?: number) => {\r\n if (ackHandled) return;\r\n ackHandled = true;\r\n\r\n const retryCount = (metadata.retryCount || 0) + 1;\r\n const maxRetries = options?.retry?.maxRetries ?? 3;\r\n\r\n if (retryCount > maxRetries) {\r\n // Send to dead-letter if configured\r\n if (options?.deadLetter) {\r\n await this.sendToDeadLetter(message, options.deadLetter.channel);\r\n }\r\n this.amqpChannel.ack(msg);\r\n return;\r\n }\r\n\r\n // Republish with retry count\r\n const headers = {\r\n ...msg.properties.headers,\r\n \"x-retry-count\": retryCount,\r\n };\r\n\r\n if (delay) {\r\n headers[\"x-delay\"] = delay;\r\n }\r\n\r\n this.amqpChannel.sendToQueue(this.name, msg.content, { ...msg.properties, headers });\r\n\r\n this.amqpChannel.ack(msg);\r\n },\r\n };\r\n\r\n // Execute handler\r\n await handler(message, context);\r\n\r\n // Smart auto-ack: if handler succeeded and didn't explicitly handle ack\r\n if (!ackHandled) {\r\n this.amqpChannel.ack(msg);\r\n }\r\n } catch (error) {\r\n // Smart auto-nack: if handler threw and didn't explicitly handle ack\r\n if (ackHandled) return;\r\n\r\n // Handle errors - nack and potentially retry\r\n if (options?.retry) {\r\n const retryCount = msg.properties.headers?.[\"x-retry-count\"] || 0;\r\n if (retryCount < options.retry.maxRetries) {\r\n // Requeue for retry\r\n this.amqpChannel.nack(msg, false, true);\r\n } else if (options.deadLetter) {\r\n // Send to dead-letter\r\n this.amqpChannel.nack(msg, false, false);\r\n } else {\r\n this.amqpChannel.reject(msg, false);\r\n }\r\n } else {\r\n // No retry configured - reject without requeue\r\n this.amqpChannel.nack(msg, false, false);\r\n }\r\n }\r\n },\r\n consumerOptions,\r\n );\r\n\r\n const subscription = new RabbitMQSubscription(\r\n subscriptionId,\r\n this.name,\r\n consumerTag,\r\n this.amqpChannel,\r\n );\r\n\r\n this.subscriptions.set(subscriptionId, subscription);\r\n\r\n return subscription;\r\n }\r\n\r\n /**\r\n * Unsubscribe by consumer ID\r\n */\r\n public async unsubscribeById(consumerId: string): Promise<void> {\r\n const subscription = this.subscriptions.get(consumerId);\r\n if (subscription) {\r\n await subscription.unsubscribe();\r\n this.subscriptions.delete(consumerId);\r\n }\r\n }\r\n\r\n /**\r\n * Stop consuming messages on this channel.\r\n * Cancels all active subscriptions gracefully.\r\n */\r\n public async stopConsuming(): Promise<void> {\r\n const cancellations = Array.from(this.subscriptions.values()).map(sub =>\r\n sub.unsubscribe(),\r\n );\r\n await Promise.all(cancellations);\r\n }\r\n\r\n /**\r\n * Send message to dead-letter queue\r\n */\r\n private async sendToDeadLetter(\r\n message: Message<TPayload>,\r\n deadLetterChannel: string,\r\n ): Promise<void> {\r\n const content = JSON.stringify({\r\n payload: message.payload,\r\n metadata: {\r\n ...message.metadata,\r\n originalChannel: this.name,\r\n },\r\n });\r\n\r\n this.amqpChannel.sendToQueue(deadLetterChannel, Buffer.from(content), { persistent: true });\r\n }\r\n\r\n /**\r\n * Request-response pattern\r\n */\r\n public async request<TResponse = unknown>(\r\n payload: TPayload,\r\n options?: RequestOptions,\r\n ): Promise<TResponse> {\r\n await this.assert();\r\n\r\n const correlationId = randomUUID();\r\n const timeout = options?.timeout ?? 30000;\r\n\r\n // Create exclusive reply queue\r\n const { queue: replyQueue } = await this.amqpChannel.assertQueue(\"\", {\r\n exclusive: true,\r\n autoDelete: true,\r\n });\r\n\r\n return new Promise<TResponse>((resolve, reject) => {\r\n const timeoutId = setTimeout(() => {\r\n reject(new Error(`Request timeout after ${timeout}ms`));\r\n }, timeout);\r\n\r\n // Consume reply\r\n this.amqpChannel.consume(\r\n replyQueue,\r\n (msg: any) => {\r\n if (msg?.properties.correlationId === correlationId) {\r\n clearTimeout(timeoutId);\r\n const content = JSON.parse(msg.content.toString());\r\n resolve(content.payload as TResponse);\r\n }\r\n },\r\n { noAck: true },\r\n );\r\n\r\n // Send request\r\n const messageContent = JSON.stringify({\r\n payload,\r\n metadata: {\r\n messageId: randomUUID(),\r\n timestamp: new Date().toISOString(),\r\n correlationId,\r\n },\r\n });\r\n\r\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), {\r\n correlationId,\r\n replyTo: replyQueue,\r\n expiration: timeout.toString(),\r\n ...options,\r\n });\r\n });\r\n }\r\n\r\n /**\r\n * Register response handler for RPC\r\n */\r\n public async respond<TResponse = unknown>(\r\n handler: ResponseHandler<TPayload, TResponse>,\r\n ): Promise<Subscription> {\r\n return this.subscribe(async (message, ctx) => {\r\n const response = await handler(message, ctx);\r\n await ctx.reply(response);\r\n await ctx.ack();\r\n });\r\n }\r\n\r\n /**\r\n * Get queue statistics\r\n */\r\n public async stats(): Promise<ChannelStats> {\r\n await this.assert();\r\n\r\n const queueInfo = await this.amqpChannel.checkQueue(this.name);\r\n\r\n return {\r\n name: this.name,\r\n messageCount: queueInfo.messageCount,\r\n consumerCount: queueInfo.consumerCount,\r\n };\r\n }\r\n\r\n /**\r\n * Purge all messages\r\n */\r\n public async purge(): Promise<number> {\r\n await this.assert();\r\n\r\n const result = await this.amqpChannel.purgeQueue(this.name);\r\n return result.messageCount;\r\n }\r\n\r\n /**\r\n * Check if queue exists\r\n */\r\n public async exists(): Promise<boolean> {\r\n try {\r\n await this.amqpChannel.checkQueue(this.name);\r\n return true;\r\n } catch {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Delete the queue\r\n */\r\n public async delete(): Promise<void> {\r\n // Cancel all subscriptions\r\n for (const subscription of this.subscriptions.values()) {\r\n await subscription.unsubscribe();\r\n }\r\n this.subscriptions.clear();\r\n\r\n try {\r\n await this.amqpChannel.deleteQueue(this.name);\r\n } catch {\r\n // Ignore if queue doesn't exist\r\n }\r\n\r\n this.asserted = false;\r\n }\r\n}\r\n\r\n/**\r\n * RabbitMQ Subscription Implementation\r\n */\r\nclass RabbitMQSubscription implements Subscription {\r\n public readonly id: string;\r\n public readonly channel: string;\r\n public readonly consumerTag: string;\r\n\r\n private readonly amqpChannel: any;\r\n private _isActive = true;\r\n\r\n public constructor(id: string, channel: string, consumerTag: string, amqpChannel: any) {\r\n this.id = id;\r\n this.channel = channel;\r\n this.consumerTag = consumerTag;\r\n this.amqpChannel = amqpChannel;\r\n }\r\n\r\n public async unsubscribe(): Promise<void> {\r\n if (!this._isActive) return;\r\n\r\n await this.amqpChannel.cancel(this.consumerTag);\r\n this._isActive = false;\r\n }\r\n\r\n public async pause(): Promise<void> {\r\n // RabbitMQ doesn't have native pause, cancel consumer\r\n await this.amqpChannel.cancel(this.consumerTag);\r\n }\r\n\r\n public async resume(): Promise<void> {\r\n // Would need to re-subscribe - not directly supported\r\n throw new Error(\"Resume is not supported for RabbitMQ. Please create a new subscription.\");\r\n }\r\n\r\n public isActive(): boolean {\r\n return this._isActive;\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"rabbitmq-channel.mjs","names":[],"sources":["../../../../../../../../herald/src/drivers/rabbitmq/rabbitmq-channel.ts"],"sourcesContent":["import { log } from \"@warlock.js/logger\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { randomUUID } from \"node:crypto\";\r\nimport type { ChannelContract } from \"../../contracts\";\r\nimport type {\r\n ChannelOptions,\r\n ChannelStats,\r\n Message,\r\n MessageContext,\r\n MessageHandler,\r\n MessageMetadata,\r\n PublishOptions,\r\n RequestOptions,\r\n ResponseHandler,\r\n SubscribeOptions,\r\n Subscription,\r\n} from \"../../types\";\r\n\r\n/**\r\n * RabbitMQ Channel Implementation\r\n *\r\n * Wraps a RabbitMQ queue/exchange with a unified API.\r\n *\r\n * @template TPayload - The typed payload\r\n */\r\nexport class RabbitMQChannel<TPayload = unknown> implements ChannelContract<TPayload> {\r\n public readonly name: string;\r\n public readonly options: ChannelOptions<TPayload>;\r\n\r\n private readonly amqpChannel: any;\r\n private readonly subscriptions = new Map<string, RabbitMQSubscription>();\r\n private asserted = false;\r\n\r\n /**\r\n * Create a new RabbitMQ channel\r\n */\r\n public constructor(name: string, amqpChannel: any, options?: ChannelOptions<TPayload>) {\r\n this.name = name;\r\n this.amqpChannel = amqpChannel;\r\n this.options = options ?? {};\r\n }\r\n\r\n /**\r\n * Assert the queue exists\r\n */\r\n public async assert(): Promise<void> {\r\n if (this.asserted) return;\r\n\r\n const queueOptions = {\r\n durable: this.options.durable ?? true,\r\n autoDelete: this.options.autoDelete ?? false,\r\n exclusive: this.options.exclusive ?? false,\r\n messageTtl: this.options.messageTtl,\r\n maxLength: this.options.maxLength,\r\n deadLetterExchange: this.options.deadLetter?.channel ? \"\" : undefined,\r\n deadLetterRoutingKey: this.options.deadLetter?.channel,\r\n };\r\n\r\n await this.amqpChannel.assertQueue(this.name, queueOptions);\r\n this.asserted = true;\r\n }\r\n\r\n /**\r\n * Publish a message\r\n */\r\n public async publish(payload: TPayload, options?: PublishOptions): Promise<void> {\r\n await this.assert();\r\n\r\n // Validate with schema if provided\r\n if (this.options.schema) {\r\n const context = {\r\n allValues: payload,\r\n value: payload,\r\n };\r\n const result = await v.validate(this.options.schema, payload, { context });\r\n if (!result.isValid) {\r\n throw new Error(`Message validation failed: ${JSON.stringify(result.errors)}`);\r\n }\r\n payload = result.data as TPayload;\r\n }\r\n\r\n const messageId = randomUUID();\r\n const timestamp = new Date();\r\n\r\n const messageContent = JSON.stringify({\r\n payload,\r\n metadata: {\r\n messageId,\r\n timestamp: timestamp.toISOString(),\r\n correlationId: options?.correlationId,\r\n headers: options?.headers,\r\n },\r\n });\r\n\r\n const publishOptions: any = {\r\n persistent: options?.persistent ?? true,\r\n messageId,\r\n timestamp: timestamp.getTime(),\r\n correlationId: options?.correlationId,\r\n expiration: options?.expiration?.toString(),\r\n priority: options?.priority,\r\n headers: options?.headers,\r\n };\r\n\r\n // Handle delayed messages (requires rabbitmq-delayed-message-exchange plugin)\r\n if (options?.delay) {\r\n publishOptions.headers = {\r\n ...publishOptions.headers,\r\n \"x-delay\": options.delay,\r\n };\r\n }\r\n\r\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), publishOptions);\r\n }\r\n\r\n /**\r\n * Publish multiple messages\r\n */\r\n public async publishBatch(messages: TPayload[], options?: PublishOptions): Promise<void> {\r\n for (const payload of messages) {\r\n await this.publish(payload, options);\r\n }\r\n }\r\n\r\n /**\r\n * Subscribe to messages\r\n *\r\n * Smart auto-ack behavior (when autoAck is not true):\r\n * - If handler completes successfully without explicit ack/nack/reject → auto-ack\r\n * - If handler throws an error → auto-nack (with retry if configured)\r\n * - If handler explicitly calls ack/nack/reject → respects that call\r\n */\r\n public async subscribe(\r\n handler: MessageHandler<TPayload>,\r\n options?: SubscribeOptions,\r\n ): Promise<Subscription> {\r\n await this.assert();\r\n\r\n // Use consumerId from options if provided, otherwise generate a random one\r\n const subscriptionId = options?.consumerId ?? randomUUID();\r\n\r\n // Set prefetch if specified\r\n if (options?.prefetch) {\r\n await this.amqpChannel.prefetch(options.prefetch);\r\n }\r\n\r\n // If autoAck is true, RabbitMQ handles ack immediately (fire-and-forget)\r\n const isFireAndForget = options?.autoAck === true;\r\n\r\n const consumerOptions = {\r\n noAck: isFireAndForget,\r\n exclusive: options?.exclusive ?? false,\r\n consumerTag: options?.group ?? subscriptionId,\r\n };\r\n\r\n const { consumerTag } = await this.amqpChannel.consume(\r\n this.name,\r\n async (msg: any) => {\r\n if (!msg) return;\r\n\r\n // Track if acknowledgment was handled explicitly\r\n let ackHandled = isFireAndForget;\r\n\r\n // Populated once the message is successfully parsed, so the catch\r\n // block below can dead-letter with the full envelope. Stays\r\n // `undefined` when `JSON.parse` itself is what threw.\r\n let parsedMessage: Message<TPayload> | undefined;\r\n\r\n try {\r\n const content = JSON.parse(msg.content.toString());\r\n let payload = content.payload as TPayload;\r\n\r\n // Validate with schema if provided\r\n if (this.options.schema) {\r\n const context = {\r\n allValues: payload,\r\n value: payload,\r\n };\r\n const result = await v.validate(this.options.schema, payload, { context });\r\n if (!result.isValid) {\r\n // Reject invalid messages\r\n this.amqpChannel.nack(msg, false, false);\r\n return;\r\n }\r\n payload = result.data as TPayload;\r\n }\r\n\r\n const metadata: MessageMetadata = {\r\n messageId: msg.properties.messageId || content.metadata?.messageId || randomUUID(),\r\n timestamp: new Date(msg.properties.timestamp || content.metadata?.timestamp),\r\n correlationId: msg.properties.correlationId || content.metadata?.correlationId,\r\n replyTo: msg.properties.replyTo,\r\n priority: msg.properties.priority,\r\n headers: msg.properties.headers,\r\n retryCount: msg.properties.headers?.[\"x-retry-count\"] || 0,\r\n originalChannel: this.name,\r\n };\r\n\r\n const message: Message<TPayload> = {\r\n metadata,\r\n payload,\r\n raw: msg,\r\n };\r\n\r\n parsedMessage = message;\r\n\r\n const context: MessageContext = {\r\n ack: async () => {\r\n if (!ackHandled) {\r\n ackHandled = true;\r\n this.amqpChannel.ack(msg);\r\n }\r\n },\r\n nack: async (requeue = true) => {\r\n if (!ackHandled) {\r\n ackHandled = true;\r\n this.amqpChannel.nack(msg, false, requeue);\r\n }\r\n },\r\n reject: async () => {\r\n if (!ackHandled) {\r\n ackHandled = true;\r\n this.amqpChannel.reject(msg, false);\r\n }\r\n },\r\n reply: async <T>(replyPayload: T) => {\r\n if (msg.properties.replyTo) {\r\n const replyContent = JSON.stringify({\r\n payload: replyPayload,\r\n metadata: {\r\n messageId: randomUUID(),\r\n timestamp: new Date().toISOString(),\r\n correlationId: msg.properties.correlationId,\r\n },\r\n });\r\n\r\n this.amqpChannel.sendToQueue(msg.properties.replyTo, Buffer.from(replyContent), {\r\n correlationId: msg.properties.correlationId,\r\n });\r\n }\r\n },\r\n retry: async (delay?: number) => {\r\n if (ackHandled) return;\r\n ackHandled = true;\r\n\r\n await this.retryOrGiveUp(msg, metadata.retryCount || 0, options, message, delay);\r\n },\r\n };\r\n\r\n // Execute handler\r\n await handler(message, context);\r\n\r\n // Smart auto-ack: if handler succeeded and didn't explicitly handle ack\r\n if (!ackHandled) {\r\n this.amqpChannel.ack(msg);\r\n }\r\n } catch (error) {\r\n // Smart auto-nack: if handler threw and didn't explicitly handle ack\r\n if (ackHandled) return;\r\n\r\n if (options?.retry) {\r\n // A bare `nack(msg, false, true)` redelivers the ORIGINAL message\r\n // untouched — amqplib/RabbitMQ do not add an `x-retry-count`\r\n // header on requeue, so a plain requeue here never advances the\r\n // counter and `maxRetries`/`deadLetter` are silently never\r\n // reached. Route through the same bounded-retry path `ctx.retry()`\r\n // uses, so the counter increments (and the cap/dead-letter fires)\r\n // on this automatic path too.\r\n const currentRetryCount = msg.properties.headers?.[\"x-retry-count\"] || 0;\r\n await this.retryOrGiveUp(msg, currentRetryCount, options, parsedMessage);\r\n } else {\r\n // No retry configured - reject without requeue (already bounded:\r\n // a single attempt, no requeue loop possible).\r\n this.amqpChannel.nack(msg, false, false);\r\n }\r\n }\r\n },\r\n consumerOptions,\r\n );\r\n\r\n const subscription = new RabbitMQSubscription(\r\n subscriptionId,\r\n this.name,\r\n consumerTag,\r\n this.amqpChannel,\r\n );\r\n\r\n this.subscriptions.set(subscriptionId, subscription);\r\n\r\n return subscription;\r\n }\r\n\r\n /**\r\n * Unsubscribe by consumer ID\r\n */\r\n public async unsubscribeById(consumerId: string): Promise<void> {\r\n const subscription = this.subscriptions.get(consumerId);\r\n if (subscription) {\r\n await subscription.unsubscribe();\r\n this.subscriptions.delete(consumerId);\r\n }\r\n }\r\n\r\n /**\r\n * Stop consuming messages on this channel.\r\n * Cancels all active subscriptions gracefully.\r\n */\r\n public async stopConsuming(): Promise<void> {\r\n const cancellations = Array.from(this.subscriptions.values()).map(sub =>\r\n sub.unsubscribe(),\r\n );\r\n await Promise.all(cancellations);\r\n }\r\n\r\n /**\r\n * Send message to dead-letter queue\r\n */\r\n private async sendToDeadLetter(\r\n message: Message<TPayload>,\r\n deadLetterChannel: string,\r\n ): Promise<void> {\r\n const content = JSON.stringify({\r\n payload: message.payload,\r\n metadata: {\r\n ...message.metadata,\r\n originalChannel: this.name,\r\n },\r\n });\r\n\r\n this.amqpChannel.sendToQueue(deadLetterChannel, Buffer.from(content), { persistent: true });\r\n }\r\n\r\n /**\r\n * Dead-letter a message whose body couldn't be parsed into a {@link Message}\r\n * (e.g. `JSON.parse` itself threw before an envelope existed). Forwards the\r\n * raw AMQP bytes/properties as-is rather than re-deriving a payload, so a\r\n * malformed message isn't lost.\r\n */\r\n private sendToDeadLetterRaw(msg: any, deadLetterChannel: string): void {\r\n this.amqpChannel.sendToQueue(deadLetterChannel, msg.content, {\r\n ...msg.properties,\r\n persistent: true,\r\n });\r\n }\r\n\r\n /**\r\n * Bounded retry shared by the explicit `ctx.retry()` call and the automatic\r\n * catch when a handler throws without calling it itself — so both paths\r\n * honor the same cap instead of the automatic path silently requeueing\r\n * forever (see `subscribe()`'s catch block).\r\n *\r\n * Under the cap: republishes with an incremented `x-retry-count` header —\r\n * NOT a plain `nack(msg, false, true)`, which redelivers the original\r\n * message untouched and never advances the counter.\r\n *\r\n * At/over the cap: dead-letters if configured, otherwise drops the message\r\n * with a loud `log.error` (never a silent drop) so an operator can see a\r\n * poison message was discarded instead of it vanishing without a trace.\r\n */\r\n private async retryOrGiveUp(\r\n msg: any,\r\n currentRetryCount: number,\r\n options: SubscribeOptions | undefined,\r\n parsedMessage: Message<TPayload> | undefined,\r\n delay?: number,\r\n ): Promise<void> {\r\n const retryCount = currentRetryCount + 1;\r\n const maxRetries = options?.retry?.maxRetries ?? 3;\r\n\r\n if (retryCount > maxRetries) {\r\n if (options?.deadLetter) {\r\n if (parsedMessage) {\r\n await this.sendToDeadLetter(parsedMessage, options.deadLetter.channel);\r\n } else {\r\n this.sendToDeadLetterRaw(msg, options.deadLetter.channel);\r\n }\r\n } else {\r\n log.error(\r\n \"herald\",\r\n \"poison-message\",\r\n `Dropping message on channel \"${this.name}\" after ${retryCount - 1} failed ` +\r\n `${retryCount - 1 === 1 ? \"retry\" : \"retries\"} (maxRetries: ${maxRetries}) with no ` +\r\n `dead-letter channel configured.`,\r\n { channel: this.name, retryCount: retryCount - 1, maxRetries },\r\n );\r\n this.amqpChannel.reject(msg, false);\r\n return;\r\n }\r\n\r\n this.amqpChannel.ack(msg);\r\n return;\r\n }\r\n\r\n const headers: Record<string, unknown> = {\r\n ...msg.properties.headers,\r\n \"x-retry-count\": retryCount,\r\n };\r\n\r\n if (delay) {\r\n headers[\"x-delay\"] = delay;\r\n }\r\n\r\n this.amqpChannel.sendToQueue(this.name, msg.content, { ...msg.properties, headers });\r\n\r\n this.amqpChannel.ack(msg);\r\n }\r\n\r\n /**\r\n * Request-response pattern\r\n */\r\n public async request<TResponse = unknown>(\r\n payload: TPayload,\r\n options?: RequestOptions,\r\n ): Promise<TResponse> {\r\n await this.assert();\r\n\r\n const correlationId = randomUUID();\r\n const timeout = options?.timeout ?? 30000;\r\n\r\n // Create exclusive reply queue\r\n const { queue: replyQueue } = await this.amqpChannel.assertQueue(\"\", {\r\n exclusive: true,\r\n autoDelete: true,\r\n });\r\n\r\n return new Promise<TResponse>((resolve, reject) => {\r\n const timeoutId = setTimeout(() => {\r\n reject(new Error(`Request timeout after ${timeout}ms`));\r\n }, timeout);\r\n\r\n // Consume reply\r\n this.amqpChannel.consume(\r\n replyQueue,\r\n (msg: any) => {\r\n if (msg?.properties.correlationId === correlationId) {\r\n clearTimeout(timeoutId);\r\n const content = JSON.parse(msg.content.toString());\r\n resolve(content.payload as TResponse);\r\n }\r\n },\r\n { noAck: true },\r\n );\r\n\r\n // Send request\r\n const messageContent = JSON.stringify({\r\n payload,\r\n metadata: {\r\n messageId: randomUUID(),\r\n timestamp: new Date().toISOString(),\r\n correlationId,\r\n },\r\n });\r\n\r\n this.amqpChannel.sendToQueue(this.name, Buffer.from(messageContent), {\r\n correlationId,\r\n replyTo: replyQueue,\r\n expiration: timeout.toString(),\r\n ...options,\r\n });\r\n });\r\n }\r\n\r\n /**\r\n * Register response handler for RPC\r\n */\r\n public async respond<TResponse = unknown>(\r\n handler: ResponseHandler<TPayload, TResponse>,\r\n ): Promise<Subscription> {\r\n return this.subscribe(async (message, ctx) => {\r\n const response = await handler(message, ctx);\r\n await ctx.reply(response);\r\n await ctx.ack();\r\n });\r\n }\r\n\r\n /**\r\n * Get queue statistics\r\n */\r\n public async stats(): Promise<ChannelStats> {\r\n await this.assert();\r\n\r\n const queueInfo = await this.amqpChannel.checkQueue(this.name);\r\n\r\n return {\r\n name: this.name,\r\n messageCount: queueInfo.messageCount,\r\n consumerCount: queueInfo.consumerCount,\r\n };\r\n }\r\n\r\n /**\r\n * Purge all messages\r\n */\r\n public async purge(): Promise<number> {\r\n await this.assert();\r\n\r\n const result = await this.amqpChannel.purgeQueue(this.name);\r\n return result.messageCount;\r\n }\r\n\r\n /**\r\n * Check if queue exists\r\n */\r\n public async exists(): Promise<boolean> {\r\n try {\r\n await this.amqpChannel.checkQueue(this.name);\r\n return true;\r\n } catch {\r\n return false;\r\n }\r\n }\r\n\r\n /**\r\n * Delete the queue\r\n */\r\n public async delete(): Promise<void> {\r\n // Cancel all subscriptions\r\n for (const subscription of this.subscriptions.values()) {\r\n await subscription.unsubscribe();\r\n }\r\n this.subscriptions.clear();\r\n\r\n try {\r\n await this.amqpChannel.deleteQueue(this.name);\r\n } catch {\r\n // Ignore if queue doesn't exist\r\n }\r\n\r\n this.asserted = false;\r\n }\r\n}\r\n\r\n/**\r\n * RabbitMQ Subscription Implementation\r\n */\r\nclass RabbitMQSubscription implements Subscription {\r\n public readonly id: string;\r\n public readonly channel: string;\r\n public readonly consumerTag: string;\r\n\r\n private readonly amqpChannel: any;\r\n private _isActive = true;\r\n\r\n public constructor(id: string, channel: string, consumerTag: string, amqpChannel: any) {\r\n this.id = id;\r\n this.channel = channel;\r\n this.consumerTag = consumerTag;\r\n this.amqpChannel = amqpChannel;\r\n }\r\n\r\n public async unsubscribe(): Promise<void> {\r\n if (!this._isActive) return;\r\n\r\n await this.amqpChannel.cancel(this.consumerTag);\r\n this._isActive = false;\r\n }\r\n\r\n public async pause(): Promise<void> {\r\n // RabbitMQ doesn't have native pause, cancel consumer\r\n await this.amqpChannel.cancel(this.consumerTag);\r\n }\r\n\r\n public async resume(): Promise<void> {\r\n // Would need to re-subscribe - not directly supported\r\n throw new Error(\"Resume is not supported for RabbitMQ. Please create a new subscription.\");\r\n }\r\n\r\n public isActive(): boolean {\r\n return this._isActive;\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rabbitmq-driver.d.mts","names":[],"sources":["../../../../../../../../herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"mappings":";;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"rabbitmq-driver.d.mts","names":[],"sources":["../../../../../../../../herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"mappings":";;;;;;;;;;;;;;;AAwGA;;;;;;;;;;;;;;;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"}
|
|
@@ -4,13 +4,16 @@ import { EventEmitter } from "node:events";
|
|
|
4
4
|
|
|
5
5
|
//#region ../herald/src/drivers/rabbitmq/rabbitmq-driver.ts
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
*
|
|
7
|
+
* The single amqplib load, shared by every caller.
|
|
8
|
+
*
|
|
9
|
+
* Memoized as a promise rather than as a resolved value so the loader stays
|
|
10
|
+
* idempotent: without it, two callers arriving before the first `import()`
|
|
11
|
+
* settles would each start their own load and the last writer would win, so a
|
|
12
|
+
* caller could end up observing a module instance it never awaited.
|
|
13
|
+
*
|
|
14
|
+
* Resolves to `undefined` when amqplib is not installed.
|
|
12
15
|
*/
|
|
13
|
-
let
|
|
16
|
+
let amqplibModulePromise;
|
|
14
17
|
/**
|
|
15
18
|
* Installation instructions for amqplib
|
|
16
19
|
*/
|
|
@@ -27,17 +30,26 @@ Or manually:
|
|
|
27
30
|
yarn add amqplib
|
|
28
31
|
`.trim();
|
|
29
32
|
/**
|
|
30
|
-
*
|
|
33
|
+
* Strip `user:password@` credentials from any `amqp(s)://` URL embedded in a
|
|
34
|
+
* string. The connection URL carries plaintext broker credentials, and
|
|
35
|
+
* amqplib/Node's URL parser commonly echoes the offending URL verbatim in a
|
|
36
|
+
* malformed-URL error (e.g. an unencoded `@`/`:` in the password) — applied
|
|
37
|
+
* to every error `connect()` surfaces so a credential never reaches whatever
|
|
38
|
+
* the host app does with a thrown connection error (console.error,
|
|
39
|
+
* structured logging, an error tracker).
|
|
31
40
|
*/
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
function redactAmqpCredentials(message) {
|
|
42
|
+
return message.replace(/(amqps?:\/\/)[^/@\s]+@/gi, "$1****:****@");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Load amqplib, reusing the single shared load for every caller.
|
|
46
|
+
*
|
|
47
|
+
* @returns The amqplib module, or `undefined` when it is not installed.
|
|
48
|
+
*/
|
|
49
|
+
function loadAmqplibModule() {
|
|
50
|
+
if (!amqplibModulePromise) amqplibModulePromise = import("amqplib").catch(() => void 0);
|
|
51
|
+
return amqplibModulePromise;
|
|
39
52
|
}
|
|
40
|
-
loadAmqplibModule();
|
|
41
53
|
/**
|
|
42
54
|
* RabbitMQ Driver
|
|
43
55
|
*
|
|
@@ -120,11 +132,8 @@ var RabbitMQDriver = class {
|
|
|
120
132
|
* Connect to RabbitMQ
|
|
121
133
|
*/
|
|
122
134
|
async connect() {
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
await loadAmqplibModule();
|
|
126
|
-
if (!isModuleExists) throw new Error(`amqplib is not installed.\n\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);
|
|
127
|
-
}
|
|
135
|
+
const amqplib = await loadAmqplibModule();
|
|
136
|
+
if (!amqplib) throw new Error(`amqplib is not installed.\n\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);
|
|
128
137
|
try {
|
|
129
138
|
const url = this.buildConnectionUrl();
|
|
130
139
|
const connectOptions = {
|
|
@@ -132,7 +141,7 @@ var RabbitMQDriver = class {
|
|
|
132
141
|
timeout: this.options.connectionTimeout,
|
|
133
142
|
...this.options.clientOptions
|
|
134
143
|
};
|
|
135
|
-
this.connection = await
|
|
144
|
+
this.connection = await amqplib.connect(url, connectOptions);
|
|
136
145
|
this.amqpChannel = await this.connection.createChannel();
|
|
137
146
|
if (this.options.prefetch) await this.amqpChannel.prefetch(this.options.prefetch);
|
|
138
147
|
this._isConnected = true;
|
|
@@ -149,7 +158,8 @@ var RabbitMQDriver = class {
|
|
|
149
158
|
});
|
|
150
159
|
} catch (error) {
|
|
151
160
|
this._isConnected = false;
|
|
152
|
-
|
|
161
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
162
|
+
throw new Error(`Failed to connect to RabbitMQ: ${redactAmqpCredentials(message)}`);
|
|
153
163
|
}
|
|
154
164
|
}
|
|
155
165
|
/**
|
|
@@ -161,7 +171,7 @@ var RabbitMQDriver = class {
|
|
|
161
171
|
const host = this.options.host ?? "localhost";
|
|
162
172
|
const port = this.options.port ?? 5672;
|
|
163
173
|
const vhost = this.options.vhost ?? "/";
|
|
164
|
-
return `${protocol}://${this.options.username ?? "guest"}:${this.options.password ?? "guest"}@${host}:${port}/${encodeURIComponent(vhost)}`;
|
|
174
|
+
return `${protocol}://${encodeURIComponent(this.options.username ?? "guest")}:${encodeURIComponent(this.options.password ?? "guest")}@${host}:${port}/${encodeURIComponent(vhost)}`;
|
|
165
175
|
}
|
|
166
176
|
/**
|
|
167
177
|
* Handle reconnection
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rabbitmq-driver.mjs","names":[],"sources":["../../../../../../../../herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"sourcesContent":["import { EventEmitter } from \"node:events\";\r\nimport type { BrokerDriverContract, ChannelContract } from \"../../contracts\";\r\nimport { EventMessage } from \"../../message-managers/event-message\";\r\nimport { EventConsumerClass } from \"../../message-managers/types\";\r\nimport type {\r\n BrokerDriverType,\r\n BrokerEvent,\r\n BrokerEventListener,\r\n ChannelOptions,\r\n HealthCheckResult,\r\n RabbitMQConnectionOptions,\r\n} from \"../../types\";\r\nimport { prepareConsumerSubscription } from \"./../../message-managers/prepare-consumer-subscription\";\r\nimport { RabbitMQChannel } from \"./rabbitmq-channel\";\r\n\r\n// ============================================================\r\n// Lazy-loaded amqplib Module\r\n// ============================================================\r\n\r\n/**\r\n * Cached amqplib module (loaded once, reused)\r\n */\r\nlet amqplibModule: typeof import(\"amqplib\");\r\n\r\n/**\r\n * Module availability flag\r\n */\r\nlet isModuleExists: boolean | null = null;\r\n\r\n/**\r\n * Installation instructions for amqplib\r\n */\r\nconst AMQPLIB_INSTALL_INSTRUCTIONS = `\r\nRabbitMQ driver requires the amqplib package.\r\nInstall it with:\r\n\r\n npx warlock add herald --driver=rabbitmq\r\n\r\nOr manually:\r\n\r\n npm install amqplib\r\n pnpm add amqplib\r\n yarn add amqplib\r\n`.trim();\r\n\r\n/**\r\n * Load amqplib module\r\n */\r\nasync function loadAmqplibModule() {\r\n try {\r\n amqplibModule = await import(\"amqplib\");\r\n isModuleExists = true;\r\n } catch {\r\n isModuleExists = false;\r\n }\r\n}\r\n\r\n// Kick off eager loading immediately\r\nloadAmqplibModule();\r\n\r\n// ============================================================\r\n// RabbitMQ Driver\r\n// ============================================================\r\n\r\n/**\r\n * RabbitMQ Driver\r\n *\r\n * Implementation of BrokerDriverContract for RabbitMQ/AMQP.\r\n *\r\n * **Important:** This driver requires the `amqplib` package to be installed.\r\n * Install it with: `npx warlock add herald --driver=rabbitmq` or `npm install amqplib`\r\n *\r\n * @example\r\n * ```typescript\r\n * const driver = new RabbitMQDriver({\r\n * driver: \"rabbitmq\",\r\n * host: \"localhost\",\r\n * port: 5672,\r\n * username: \"guest\",\r\n * password: \"guest\",\r\n * });\r\n *\r\n * await driver.connect();\r\n * const channel = driver.channel(\"user.created\");\r\n * ```\r\n */\r\nexport class RabbitMQDriver implements BrokerDriverContract {\r\n public readonly name = \"rabbitmq\" as const;\r\n\r\n public readonly consumers: EventConsumerClass[] = [];\r\n\r\n private readonly options: RabbitMQConnectionOptions;\r\n private readonly events = new EventEmitter();\r\n private readonly channels = new Map<string, ChannelContract<any>>();\r\n\r\n private connection: any = null;\r\n private amqpChannel: any = null;\r\n private _isConnected = false;\r\n\r\n /**\r\n * Create a new RabbitMQ driver\r\n *\r\n * @param options - RabbitMQ connection options\r\n */\r\n public constructor(options: RabbitMQConnectionOptions) {\r\n this.options = options;\r\n }\r\n\r\n /**\r\n * Whether connected to RabbitMQ\r\n */\r\n public get isConnected(): boolean {\r\n return this._isConnected;\r\n }\r\n\r\n /**\r\n * Subscribe the given consumer class to the driver\r\n *\r\n * @param consumer - Consumer class to subscribe\r\n *\r\n * @example\r\n * ```typescript\r\n * driver.subscribe(UserUpdatedConsumer);\r\n * ```\r\n */\r\n public subscribe(Consumer: EventConsumerClass) {\r\n if (this.isConnected) {\r\n this.channel(Consumer.eventName).subscribe(\r\n prepareConsumerSubscription(Consumer, (error, eventName) => {\r\n this.events.emit(\"error\", error, eventName);\r\n }),\r\n {\r\n consumerId: Consumer.consumerId,\r\n },\r\n );\r\n } else {\r\n this.consumers.push(Consumer);\r\n }\r\n\r\n return () => {\r\n this.unsubscribe(Consumer);\r\n };\r\n }\r\n\r\n /**\r\n * Unsubscribe the given consumer\r\n */\r\n public unsubscribe(Consumer: EventConsumerClass): void {\r\n if (this.isConnected) {\r\n this.channel(Consumer.eventName).unsubscribeById(Consumer.consumerId);\r\n }\r\n const index = this.consumers.indexOf(Consumer);\r\n if (index > -1) {\r\n this.consumers.splice(index, 1);\r\n }\r\n }\r\n\r\n /**\r\n * Publish the given event message.\r\n * Auto-creates the channel if it hasn't been accessed before.\r\n */\r\n public publish<TPayload = Record<string, any>>(event: EventMessage<TPayload>): void {\r\n this.channel(event.eventName).publish(event.serialize());\r\n }\r\n\r\n /**\r\n * Connect to RabbitMQ\r\n */\r\n public async connect(): Promise<void> {\r\n // Check if amqplib is installed\r\n if (isModuleExists === false) {\r\n throw new Error(`amqplib is not installed.\\n\\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);\r\n }\r\n\r\n // Wait for module to load if still loading\r\n if (isModuleExists === null) {\r\n await loadAmqplibModule();\r\n if (!isModuleExists) {\r\n throw new Error(`amqplib is not installed.\\n\\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);\r\n }\r\n }\r\n\r\n try {\r\n // Build connection URL\r\n const url = this.buildConnectionUrl();\r\n\r\n // Build connection options merging our options with native client options\r\n const connectOptions = {\r\n heartbeat: this.options.heartbeat ?? 60,\r\n timeout: this.options.connectionTimeout,\r\n // Merge native amqplib client options\r\n ...this.options.clientOptions,\r\n };\r\n\r\n // Connect using cached module\r\n this.connection = await amqplibModule.connect(url, connectOptions);\r\n\r\n // Create channel\r\n this.amqpChannel = await this.connection.createChannel();\r\n\r\n // Set prefetch if specified\r\n if (this.options.prefetch) {\r\n await this.amqpChannel.prefetch(this.options.prefetch);\r\n }\r\n\r\n this._isConnected = true;\r\n this.events.emit(\"connected\");\r\n\r\n for (const consumer of this.consumers) {\r\n this.subscribe(consumer);\r\n }\r\n\r\n this.consumers.length = 0;\r\n\r\n // Handle connection close\r\n this.connection.on(\"close\", () => {\r\n this._isConnected = false;\r\n this.events.emit(\"disconnected\");\r\n\r\n if (this.options.reconnect !== false) {\r\n this.handleReconnect();\r\n }\r\n });\r\n\r\n // Handle errors\r\n this.connection.on(\"error\", (error: Error) => {\r\n this.events.emit(\"error\", error);\r\n });\r\n } catch (error) {\r\n this._isConnected = false;\r\n throw new Error(\r\n `Failed to connect to RabbitMQ: ${error instanceof Error ? error.message : String(error)}`,\r\n );\r\n }\r\n }\r\n\r\n /**\r\n * Build connection URL from options\r\n */\r\n private buildConnectionUrl(): string {\r\n if (this.options.uri) {\r\n return this.options.uri;\r\n }\r\n\r\n const protocol = \"amqp\";\r\n const host = this.options.host ?? \"localhost\";\r\n const port = this.options.port ?? 5672;\r\n const vhost = this.options.vhost ?? \"/\";\r\n const username = this.options.username ?? \"guest\";\r\n const password = this.options.password ?? \"guest\";\r\n\r\n const encodedVhost = encodeURIComponent(vhost);\r\n\r\n return `${protocol}://${username}:${password}@${host}:${port}/${encodedVhost}`;\r\n }\r\n\r\n /**\r\n * Handle reconnection\r\n */\r\n private async handleReconnect(): Promise<void> {\r\n const delay = this.options.reconnectDelay ?? 5000;\r\n let attempt = 0;\r\n\r\n const tryReconnect = async () => {\r\n attempt++;\r\n this.events.emit(\"reconnecting\", attempt);\r\n\r\n try {\r\n await this.connect();\r\n } catch {\r\n setTimeout(tryReconnect, delay);\r\n }\r\n };\r\n\r\n setTimeout(tryReconnect, delay);\r\n }\r\n\r\n /**\r\n * Disconnect from RabbitMQ\r\n */\r\n public async disconnect(): Promise<void> {\r\n if (this.amqpChannel) {\r\n try {\r\n await this.amqpChannel.close();\r\n } catch {\r\n // Ignore close errors\r\n }\r\n this.amqpChannel = null;\r\n }\r\n\r\n if (this.connection) {\r\n try {\r\n await this.connection.close();\r\n } catch {\r\n // Ignore close errors\r\n }\r\n this.connection = null;\r\n }\r\n\r\n this._isConnected = false;\r\n this.events.emit(\"disconnected\");\r\n }\r\n\r\n /**\r\n * Register event listener\r\n */\r\n public on(event: BrokerEvent, listener: BrokerEventListener): void {\r\n this.events.on(event, listener as any);\r\n }\r\n\r\n /**\r\n * Remove event listener\r\n */\r\n public off(event: BrokerEvent, listener: BrokerEventListener): void {\r\n this.events.off(event, listener as any);\r\n }\r\n\r\n /**\r\n * Get or create a channel\r\n */\r\n public channel<TPayload = unknown>(\r\n name: string,\r\n options?: ChannelOptions<TPayload>,\r\n ): ChannelContract<TPayload> {\r\n // Check cache\r\n const existing = this.channels.get(name);\r\n if (existing) {\r\n return existing as ChannelContract<TPayload>;\r\n }\r\n\r\n // Create new channel\r\n const channel = new RabbitMQChannel<TPayload>(name, this.amqpChannel, options);\r\n\r\n this.channels.set(name, channel);\r\n return channel;\r\n }\r\n\r\n /**\r\n * Start consuming messages\r\n */\r\n public async startConsuming(): Promise<void> {\r\n // Channels start consuming when subscribe() is called\r\n // This method is for batch start if needed\r\n }\r\n\r\n /**\r\n * Stop consuming messages from all subscribed channels.\r\n * Gracefully cancels all active consumers.\r\n */\r\n public async stopConsuming(): Promise<void> {\r\n const stops = Array.from(this.channels.values()).map(channel =>\r\n (channel as RabbitMQChannel<any>).stopConsuming(),\r\n );\r\n await Promise.all(stops);\r\n }\r\n\r\n /**\r\n * Health check\r\n */\r\n public async healthCheck(): Promise<HealthCheckResult> {\r\n if (!this._isConnected || !this.connection) {\r\n return {\r\n healthy: false,\r\n error: \"Not connected to RabbitMQ\",\r\n };\r\n }\r\n\r\n const start = Date.now();\r\n\r\n try {\r\n // Simple check - verify channel is open\r\n await this.amqpChannel.checkQueue(\"amq.rabbitmq.reply-to\").catch(() => {\r\n // Queue might not exist, but if we get here, connection is alive\r\n });\r\n\r\n return {\r\n healthy: true,\r\n latency: Date.now() - start,\r\n };\r\n } catch (error) {\r\n return {\r\n healthy: false,\r\n error: error instanceof Error ? error.message : String(error),\r\n latency: Date.now() - start,\r\n };\r\n }\r\n }\r\n\r\n /**\r\n * Get all channel names\r\n */\r\n public getChannelNames(): string[] {\r\n return Array.from(this.channels.keys());\r\n }\r\n\r\n /**\r\n * Close a specific channel\r\n */\r\n public async closeChannel(name: string): Promise<void> {\r\n const channel = this.channels.get(name);\r\n if (channel) {\r\n await channel.delete();\r\n this.channels.delete(name);\r\n }\r\n }\r\n\r\n /**\r\n * Get the raw AMQP channel (for advanced use)\r\n */\r\n public getRawChannel(): any {\r\n return this.amqpChannel;\r\n }\r\n\r\n /**\r\n * Get the raw connection (for advanced use)\r\n */\r\n public getRawConnection(): any {\r\n return this.connection;\r\n }\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"rabbitmq-driver.mjs","names":[],"sources":["../../../../../../../../herald/src/drivers/rabbitmq/rabbitmq-driver.ts"],"sourcesContent":["import { EventEmitter } from \"node:events\";\r\nimport type { BrokerDriverContract, ChannelContract } from \"../../contracts\";\r\nimport { EventMessage } from \"../../message-managers/event-message\";\r\nimport { EventConsumerClass } from \"../../message-managers/types\";\r\nimport type {\r\n BrokerDriverType,\r\n BrokerEvent,\r\n BrokerEventListener,\r\n ChannelOptions,\r\n HealthCheckResult,\r\n RabbitMQConnectionOptions,\r\n} from \"../../types\";\r\nimport { prepareConsumerSubscription } from \"./../../message-managers/prepare-consumer-subscription\";\r\nimport { RabbitMQChannel } from \"./rabbitmq-channel\";\r\n\r\n// ============================================================\r\n// Lazy-loaded amqplib Module\r\n// ============================================================\r\n\r\n/**\r\n * Shape of the lazily-imported amqplib module\r\n */\r\ntype AmqplibModule = typeof import(\"amqplib\");\r\n\r\n/**\r\n * The single amqplib load, shared by every caller.\r\n *\r\n * Memoized as a promise rather than as a resolved value so the loader stays\r\n * idempotent: without it, two callers arriving before the first `import()`\r\n * settles would each start their own load and the last writer would win, so a\r\n * caller could end up observing a module instance it never awaited.\r\n *\r\n * Resolves to `undefined` when amqplib is not installed.\r\n */\r\nlet amqplibModulePromise: Promise<AmqplibModule | undefined> | undefined;\r\n\r\n/**\r\n * Installation instructions for amqplib\r\n */\r\nconst AMQPLIB_INSTALL_INSTRUCTIONS = `\r\nRabbitMQ driver requires the amqplib package.\r\nInstall it with:\r\n\r\n npx warlock add herald --driver=rabbitmq\r\n\r\nOr manually:\r\n\r\n npm install amqplib\r\n pnpm add amqplib\r\n yarn add amqplib\r\n`.trim();\r\n\r\n/**\r\n * Strip `user:password@` credentials from any `amqp(s)://` URL embedded in a\r\n * string. The connection URL carries plaintext broker credentials, and\r\n * amqplib/Node's URL parser commonly echoes the offending URL verbatim in a\r\n * malformed-URL error (e.g. an unencoded `@`/`:` in the password) — applied\r\n * to every error `connect()` surfaces so a credential never reaches whatever\r\n * the host app does with a thrown connection error (console.error,\r\n * structured logging, an error tracker).\r\n */\r\nfunction redactAmqpCredentials(message: string): string {\r\n return message.replace(/(amqps?:\\/\\/)[^/@\\s]+@/gi, \"$1****:****@\");\r\n}\r\n\r\n/**\r\n * Load amqplib, reusing the single shared load for every caller.\r\n *\r\n * @returns The amqplib module, or `undefined` when it is not installed.\r\n */\r\nfunction loadAmqplibModule(): Promise<AmqplibModule | undefined> {\r\n if (!amqplibModulePromise) {\r\n amqplibModulePromise = import(\"amqplib\").catch(() => undefined);\r\n }\r\n\r\n return amqplibModulePromise;\r\n}\r\n\r\n// ============================================================\r\n// RabbitMQ Driver\r\n// ============================================================\r\n\r\n/**\r\n * RabbitMQ Driver\r\n *\r\n * Implementation of BrokerDriverContract for RabbitMQ/AMQP.\r\n *\r\n * **Important:** This driver requires the `amqplib` package to be installed.\r\n * Install it with: `npx warlock add herald --driver=rabbitmq` or `npm install amqplib`\r\n *\r\n * @example\r\n * ```typescript\r\n * const driver = new RabbitMQDriver({\r\n * driver: \"rabbitmq\",\r\n * host: \"localhost\",\r\n * port: 5672,\r\n * username: \"guest\",\r\n * password: \"guest\",\r\n * });\r\n *\r\n * await driver.connect();\r\n * const channel = driver.channel(\"user.created\");\r\n * ```\r\n */\r\nexport class RabbitMQDriver implements BrokerDriverContract {\r\n public readonly name = \"rabbitmq\" as const;\r\n\r\n public readonly consumers: EventConsumerClass[] = [];\r\n\r\n private readonly options: RabbitMQConnectionOptions;\r\n private readonly events = new EventEmitter();\r\n private readonly channels = new Map<string, ChannelContract<any>>();\r\n\r\n private connection: any = null;\r\n private amqpChannel: any = null;\r\n private _isConnected = false;\r\n\r\n /**\r\n * Create a new RabbitMQ driver\r\n *\r\n * @param options - RabbitMQ connection options\r\n */\r\n public constructor(options: RabbitMQConnectionOptions) {\r\n this.options = options;\r\n }\r\n\r\n /**\r\n * Whether connected to RabbitMQ\r\n */\r\n public get isConnected(): boolean {\r\n return this._isConnected;\r\n }\r\n\r\n /**\r\n * Subscribe the given consumer class to the driver\r\n *\r\n * @param consumer - Consumer class to subscribe\r\n *\r\n * @example\r\n * ```typescript\r\n * driver.subscribe(UserUpdatedConsumer);\r\n * ```\r\n */\r\n public subscribe(Consumer: EventConsumerClass) {\r\n if (this.isConnected) {\r\n this.channel(Consumer.eventName).subscribe(\r\n prepareConsumerSubscription(Consumer, (error, eventName) => {\r\n this.events.emit(\"error\", error, eventName);\r\n }),\r\n {\r\n consumerId: Consumer.consumerId,\r\n },\r\n );\r\n } else {\r\n this.consumers.push(Consumer);\r\n }\r\n\r\n return () => {\r\n this.unsubscribe(Consumer);\r\n };\r\n }\r\n\r\n /**\r\n * Unsubscribe the given consumer\r\n */\r\n public unsubscribe(Consumer: EventConsumerClass): void {\r\n if (this.isConnected) {\r\n this.channel(Consumer.eventName).unsubscribeById(Consumer.consumerId);\r\n }\r\n const index = this.consumers.indexOf(Consumer);\r\n if (index > -1) {\r\n this.consumers.splice(index, 1);\r\n }\r\n }\r\n\r\n /**\r\n * Publish the given event message.\r\n * Auto-creates the channel if it hasn't been accessed before.\r\n */\r\n public publish<TPayload = Record<string, any>>(event: EventMessage<TPayload>): void {\r\n this.channel(event.eventName).publish(event.serialize());\r\n }\r\n\r\n /**\r\n * Connect to RabbitMQ\r\n */\r\n public async connect(): Promise<void> {\r\n const amqplib = await loadAmqplibModule();\r\n\r\n if (!amqplib) {\r\n throw new Error(`amqplib is not installed.\\n\\n${AMQPLIB_INSTALL_INSTRUCTIONS}`);\r\n }\r\n\r\n try {\r\n // Build connection URL\r\n const url = this.buildConnectionUrl();\r\n\r\n // Build connection options merging our options with native client options\r\n const connectOptions = {\r\n heartbeat: this.options.heartbeat ?? 60,\r\n timeout: this.options.connectionTimeout,\r\n // Merge native amqplib client options\r\n ...this.options.clientOptions,\r\n };\r\n\r\n // Connect using cached module\r\n this.connection = await amqplib.connect(url, connectOptions);\r\n\r\n // Create channel\r\n this.amqpChannel = await this.connection.createChannel();\r\n\r\n // Set prefetch if specified\r\n if (this.options.prefetch) {\r\n await this.amqpChannel.prefetch(this.options.prefetch);\r\n }\r\n\r\n this._isConnected = true;\r\n this.events.emit(\"connected\");\r\n\r\n for (const consumer of this.consumers) {\r\n this.subscribe(consumer);\r\n }\r\n\r\n this.consumers.length = 0;\r\n\r\n // Handle connection close\r\n this.connection.on(\"close\", () => {\r\n this._isConnected = false;\r\n this.events.emit(\"disconnected\");\r\n\r\n if (this.options.reconnect !== false) {\r\n this.handleReconnect();\r\n }\r\n });\r\n\r\n // Handle errors\r\n this.connection.on(\"error\", (error: Error) => {\r\n this.events.emit(\"error\", error);\r\n });\r\n } catch (error) {\r\n this._isConnected = false;\r\n const message = error instanceof Error ? error.message : String(error);\r\n throw new Error(`Failed to connect to RabbitMQ: ${redactAmqpCredentials(message)}`);\r\n }\r\n }\r\n\r\n /**\r\n * Build connection URL from options\r\n */\r\n private buildConnectionUrl(): string {\r\n if (this.options.uri) {\r\n return this.options.uri;\r\n }\r\n\r\n const protocol = \"amqp\";\r\n const host = this.options.host ?? \"localhost\";\r\n const port = this.options.port ?? 5672;\r\n const vhost = this.options.vhost ?? \"/\";\r\n // URI-encoded so a credential containing a reserved URL character\r\n // (`@`, `:`, `/`, whitespace — common in generated secrets) can't produce\r\n // a malformed URL whose parser error echoes the raw credential back.\r\n const username = encodeURIComponent(this.options.username ?? \"guest\");\r\n const password = encodeURIComponent(this.options.password ?? \"guest\");\r\n\r\n const encodedVhost = encodeURIComponent(vhost);\r\n\r\n return `${protocol}://${username}:${password}@${host}:${port}/${encodedVhost}`;\r\n }\r\n\r\n /**\r\n * Handle reconnection\r\n */\r\n private async handleReconnect(): Promise<void> {\r\n const delay = this.options.reconnectDelay ?? 5000;\r\n let attempt = 0;\r\n\r\n const tryReconnect = async () => {\r\n attempt++;\r\n this.events.emit(\"reconnecting\", attempt);\r\n\r\n try {\r\n await this.connect();\r\n } catch {\r\n setTimeout(tryReconnect, delay);\r\n }\r\n };\r\n\r\n setTimeout(tryReconnect, delay);\r\n }\r\n\r\n /**\r\n * Disconnect from RabbitMQ\r\n */\r\n public async disconnect(): Promise<void> {\r\n if (this.amqpChannel) {\r\n try {\r\n await this.amqpChannel.close();\r\n } catch {\r\n // Ignore close errors\r\n }\r\n this.amqpChannel = null;\r\n }\r\n\r\n if (this.connection) {\r\n try {\r\n await this.connection.close();\r\n } catch {\r\n // Ignore close errors\r\n }\r\n this.connection = null;\r\n }\r\n\r\n this._isConnected = false;\r\n this.events.emit(\"disconnected\");\r\n }\r\n\r\n /**\r\n * Register event listener\r\n */\r\n public on(event: BrokerEvent, listener: BrokerEventListener): void {\r\n this.events.on(event, listener as any);\r\n }\r\n\r\n /**\r\n * Remove event listener\r\n */\r\n public off(event: BrokerEvent, listener: BrokerEventListener): void {\r\n this.events.off(event, listener as any);\r\n }\r\n\r\n /**\r\n * Get or create a channel\r\n */\r\n public channel<TPayload = unknown>(\r\n name: string,\r\n options?: ChannelOptions<TPayload>,\r\n ): ChannelContract<TPayload> {\r\n // Check cache\r\n const existing = this.channels.get(name);\r\n if (existing) {\r\n return existing as ChannelContract<TPayload>;\r\n }\r\n\r\n // Create new channel\r\n const channel = new RabbitMQChannel<TPayload>(name, this.amqpChannel, options);\r\n\r\n this.channels.set(name, channel);\r\n return channel;\r\n }\r\n\r\n /**\r\n * Start consuming messages\r\n */\r\n public async startConsuming(): Promise<void> {\r\n // Channels start consuming when subscribe() is called\r\n // This method is for batch start if needed\r\n }\r\n\r\n /**\r\n * Stop consuming messages from all subscribed channels.\r\n * Gracefully cancels all active consumers.\r\n */\r\n public async stopConsuming(): Promise<void> {\r\n const stops = Array.from(this.channels.values()).map(channel =>\r\n (channel as RabbitMQChannel<any>).stopConsuming(),\r\n );\r\n await Promise.all(stops);\r\n }\r\n\r\n /**\r\n * Health check\r\n */\r\n public async healthCheck(): Promise<HealthCheckResult> {\r\n if (!this._isConnected || !this.connection) {\r\n return {\r\n healthy: false,\r\n error: \"Not connected to RabbitMQ\",\r\n };\r\n }\r\n\r\n const start = Date.now();\r\n\r\n try {\r\n // Simple check - verify channel is open\r\n await this.amqpChannel.checkQueue(\"amq.rabbitmq.reply-to\").catch(() => {\r\n // Queue might not exist, but if we get here, connection is alive\r\n });\r\n\r\n return {\r\n healthy: true,\r\n latency: Date.now() - start,\r\n };\r\n } catch (error) {\r\n return {\r\n healthy: false,\r\n error: error instanceof Error ? error.message : String(error),\r\n latency: Date.now() - start,\r\n };\r\n }\r\n }\r\n\r\n /**\r\n * Get all channel names\r\n */\r\n public getChannelNames(): string[] {\r\n return Array.from(this.channels.keys());\r\n }\r\n\r\n /**\r\n * Close a specific channel\r\n */\r\n public async closeChannel(name: string): Promise<void> {\r\n const channel = this.channels.get(name);\r\n if (channel) {\r\n await channel.delete();\r\n this.channels.delete(name);\r\n }\r\n }\r\n\r\n /**\r\n * Get the raw AMQP channel (for advanced use)\r\n */\r\n public getRawChannel(): any {\r\n return this.amqpChannel;\r\n }\r\n\r\n /**\r\n * Get the raw connection (for advanced use)\r\n */\r\n public getRawConnection(): any {\r\n return this.connection;\r\n }\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;AAkCA,IAAI;;;;AAKJ,MAAM,+BAA+B;;;;;;;;;;;EAWnC,KAAK;;;;;;;;;;AAWP,SAAS,sBAAsB,SAAyB;CACtD,OAAO,QAAQ,QAAQ,4BAA4B,cAAc;AACnE;;;;;;AAOA,SAAS,oBAAwD;CAC/D,IAAI,CAAC,sBACH,uBAAuB,OAAO,UAAU,CAAC,YAAY,MAAS;CAGhE,OAAO;AACT;;;;;;;;;;;;;;;;;;;;;;;AA4BA,IAAa,iBAAb,MAA4D;;;;;;CAkB1D,AAAO,YAAY,SAAoC;cAjBhC;mBAE2B,CAAC;gBAGzB,IAAI,aAAa;kCACf,IAAI,IAAkC;oBAExC;qBACC;sBACJ;EAQrB,KAAK,UAAU;CACjB;;;;CAKA,IAAW,cAAuB;EAChC,OAAO,KAAK;CACd;;;;;;;;;;;CAYA,AAAO,UAAU,UAA8B;EAC7C,IAAI,KAAK,aACP,KAAK,QAAQ,SAAS,SAAS,CAAC,CAAC,UAC/B,4BAA4B,WAAW,OAAO,cAAc;GAC1D,KAAK,OAAO,KAAK,SAAS,OAAO,SAAS;EAC5C,CAAC,GACD,EACE,YAAY,SAAS,WACvB,CACF;OAEA,KAAK,UAAU,KAAK,QAAQ;EAG9B,aAAa;GACX,KAAK,YAAY,QAAQ;EAC3B;CACF;;;;CAKA,AAAO,YAAY,UAAoC;EACrD,IAAI,KAAK,aACP,KAAK,QAAQ,SAAS,SAAS,CAAC,CAAC,gBAAgB,SAAS,UAAU;EAEtE,MAAM,QAAQ,KAAK,UAAU,QAAQ,QAAQ;EAC7C,IAAI,QAAQ,IACV,KAAK,UAAU,OAAO,OAAO,CAAC;CAElC;;;;;CAMA,AAAO,QAAwC,OAAqC;EAClF,KAAK,QAAQ,MAAM,SAAS,CAAC,CAAC,QAAQ,MAAM,UAAU,CAAC;CACzD;;;;CAKA,MAAa,UAAyB;EACpC,MAAM,UAAU,MAAM,kBAAkB;EAExC,IAAI,CAAC,SACH,MAAM,IAAI,MAAM,gCAAgC,8BAA8B;EAGhF,IAAI;GAEF,MAAM,MAAM,KAAK,mBAAmB;GAGpC,MAAM,iBAAiB;IACrB,WAAW,KAAK,QAAQ,aAAa;IACrC,SAAS,KAAK,QAAQ;IAEtB,GAAG,KAAK,QAAQ;GAClB;GAGA,KAAK,aAAa,MAAM,QAAQ,QAAQ,KAAK,cAAc;GAG3D,KAAK,cAAc,MAAM,KAAK,WAAW,cAAc;GAGvD,IAAI,KAAK,QAAQ,UACf,MAAM,KAAK,YAAY,SAAS,KAAK,QAAQ,QAAQ;GAGvD,KAAK,eAAe;GACpB,KAAK,OAAO,KAAK,WAAW;GAE5B,KAAK,MAAM,YAAY,KAAK,WAC1B,KAAK,UAAU,QAAQ;GAGzB,KAAK,UAAU,SAAS;GAGxB,KAAK,WAAW,GAAG,eAAe;IAChC,KAAK,eAAe;IACpB,KAAK,OAAO,KAAK,cAAc;IAE/B,IAAI,KAAK,QAAQ,cAAc,OAC7B,KAAK,gBAAgB;GAEzB,CAAC;GAGD,KAAK,WAAW,GAAG,UAAU,UAAiB;IAC5C,KAAK,OAAO,KAAK,SAAS,KAAK;GACjC,CAAC;EACH,SAAS,OAAO;GACd,KAAK,eAAe;GACpB,MAAM,UAAU,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;GACrE,MAAM,IAAI,MAAM,kCAAkC,sBAAsB,OAAO,GAAG;EACpF;CACF;;;;CAKA,AAAQ,qBAA6B;EACnC,IAAI,KAAK,QAAQ,KACf,OAAO,KAAK,QAAQ;EAGtB,MAAM,WAAW;EACjB,MAAM,OAAO,KAAK,QAAQ,QAAQ;EAClC,MAAM,OAAO,KAAK,QAAQ,QAAQ;EAClC,MAAM,QAAQ,KAAK,QAAQ,SAAS;EASpC,OAAO,GAAG,SAAS,KALF,mBAAmB,KAAK,QAAQ,YAAY,OAK9B,EAAE,GAJhB,mBAAmB,KAAK,QAAQ,YAAY,OAIlB,EAAE,GAAG,KAAK,GAAG,KAAK,GAFxC,mBAAmB,KAEmC;CAC7E;;;;CAKA,MAAc,kBAAiC;EAC7C,MAAM,QAAQ,KAAK,QAAQ,kBAAkB;EAC7C,IAAI,UAAU;EAEd,MAAM,eAAe,YAAY;GAC/B;GACA,KAAK,OAAO,KAAK,gBAAgB,OAAO;GAExC,IAAI;IACF,MAAM,KAAK,QAAQ;GACrB,QAAQ;IACN,WAAW,cAAc,KAAK;GAChC;EACF;EAEA,WAAW,cAAc,KAAK;CAChC;;;;CAKA,MAAa,aAA4B;EACvC,IAAI,KAAK,aAAa;GACpB,IAAI;IACF,MAAM,KAAK,YAAY,MAAM;GAC/B,QAAQ,CAER;GACA,KAAK,cAAc;EACrB;EAEA,IAAI,KAAK,YAAY;GACnB,IAAI;IACF,MAAM,KAAK,WAAW,MAAM;GAC9B,QAAQ,CAER;GACA,KAAK,aAAa;EACpB;EAEA,KAAK,eAAe;EACpB,KAAK,OAAO,KAAK,cAAc;CACjC;;;;CAKA,AAAO,GAAG,OAAoB,UAAqC;EACjE,KAAK,OAAO,GAAG,OAAO,QAAe;CACvC;;;;CAKA,AAAO,IAAI,OAAoB,UAAqC;EAClE,KAAK,OAAO,IAAI,OAAO,QAAe;CACxC;;;;CAKA,AAAO,QACL,MACA,SAC2B;EAE3B,MAAM,WAAW,KAAK,SAAS,IAAI,IAAI;EACvC,IAAI,UACF,OAAO;EAIT,MAAM,UAAU,IAAI,gBAA0B,MAAM,KAAK,aAAa,OAAO;EAE7E,KAAK,SAAS,IAAI,MAAM,OAAO;EAC/B,OAAO;CACT;;;;CAKA,MAAa,iBAAgC,CAG7C;;;;;CAMA,MAAa,gBAA+B;EAC1C,MAAM,QAAQ,MAAM,KAAK,KAAK,SAAS,OAAO,CAAC,CAAC,CAAC,KAAI,YAClD,QAAiC,cAAc,CAClD;EACA,MAAM,QAAQ,IAAI,KAAK;CACzB;;;;CAKA,MAAa,cAA0C;EACrD,IAAI,CAAC,KAAK,gBAAgB,CAAC,KAAK,YAC9B,OAAO;GACL,SAAS;GACT,OAAO;EACT;EAGF,MAAM,QAAQ,KAAK,IAAI;EAEvB,IAAI;GAEF,MAAM,KAAK,YAAY,WAAW,uBAAuB,CAAC,CAAC,YAAY,CAEvE,CAAC;GAED,OAAO;IACL,SAAS;IACT,SAAS,KAAK,IAAI,IAAI;GACxB;EACF,SAAS,OAAO;GACd,OAAO;IACL,SAAS;IACT,OAAO,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IAC5D,SAAS,KAAK,IAAI,IAAI;GACxB;EACF;CACF;;;;CAKA,AAAO,kBAA4B;EACjC,OAAO,MAAM,KAAK,KAAK,SAAS,KAAK,CAAC;CACxC;;;;CAKA,MAAa,aAAa,MAA6B;EACrD,MAAM,UAAU,KAAK,SAAS,IAAI,IAAI;EACtC,IAAI,SAAS;GACX,MAAM,QAAQ,OAAO;GACrB,KAAK,SAAS,OAAO,IAAI;EAC3B;CACF;;;;CAKA,AAAO,gBAAqB;EAC1B,OAAO,KAAK;CACd;;;;CAKA,AAAO,mBAAwB;EAC7B,OAAO,KAAK;CACd;AACF"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prepare-consumer-subscription.mjs","names":[],"sources":["../../../../../../../herald/src/message-managers/prepare-consumer-subscription.ts"],"sourcesContent":["import type { MessageHandler } from \"./../types\";\nimport { EventConsumerClass } from \"./types\";\n\nexport function prepareConsumerSubscription(\n Consumer: EventConsumerClass,\n onError?: (error: unknown, consumerName: string) => void,\n) {\n const callback: MessageHandler<any> = async (message, ctx) => {\n const envelope = message.payload;\n let eventPayload = envelope.payload;\n\n if (envelope.version) {\n if (!Consumer.isAcceptedVersion(Number(envelope.version))) {\n ctx.ack(); // Acknowledge but don't process\n return;\n }\n }\n\n const consumer = new Consumer();\n\n if (consumer.schema) {\n const result = await consumer.validate(eventPayload);\n if (!result || result.isValid === false) {\n ctx.nack();\n return;\n }\n\n eventPayload = result.data;\n }\n try {\n await consumer.handle(eventPayload, {\n payload: eventPayload,\n eventName: Consumer.eventName,\n messageId: message.metadata.messageId!,\n occurredAt: envelope.occurredAt ? new Date(envelope.occurredAt) : undefined,\n metadata: envelope.metadata,\n version: envelope.version,\n message,\n });\n ctx.ack(); // Auto-ack on success?\n } catch (error) {\n ctx.
|
|
1
|
+
{"version":3,"file":"prepare-consumer-subscription.mjs","names":[],"sources":["../../../../../../../herald/src/message-managers/prepare-consumer-subscription.ts"],"sourcesContent":["import type { MessageHandler } from \"./../types\";\r\nimport { EventConsumerClass } from \"./types\";\r\n\r\nexport function prepareConsumerSubscription(\r\n Consumer: EventConsumerClass,\r\n onError?: (error: unknown, consumerName: string) => void,\r\n) {\r\n const callback: MessageHandler<any> = async (message, ctx) => {\r\n const envelope = message.payload;\r\n let eventPayload = envelope.payload;\r\n\r\n if (envelope.version) {\r\n if (!Consumer.isAcceptedVersion(Number(envelope.version))) {\r\n ctx.ack(); // Acknowledge but don't process\r\n return;\r\n }\r\n }\r\n\r\n const consumer = new Consumer();\r\n\r\n if (consumer.schema) {\r\n const result = await consumer.validate(eventPayload);\r\n if (!result || result.isValid === false) {\r\n ctx.nack();\r\n return;\r\n }\r\n\r\n eventPayload = result.data;\r\n }\r\n try {\r\n await consumer.handle(eventPayload, {\r\n payload: eventPayload,\r\n eventName: Consumer.eventName,\r\n messageId: message.metadata.messageId!,\r\n occurredAt: envelope.occurredAt ? new Date(envelope.occurredAt) : undefined,\r\n metadata: envelope.metadata,\r\n version: envelope.version,\r\n message,\r\n });\r\n ctx.ack(); // Auto-ack on success?\r\n } catch (error) {\r\n // Bounded retry instead of an unconditional requeue: a message that\r\n // reliably throws (bad payload, a handler bug, a downstream outage)\r\n // would otherwise be nack+requeued forever, pinning the consumer in a\r\n // hot loop. `ctx.retry()` caps redelivery and dead-letters/drops (with\r\n // a loud log) once the cap is hit.\r\n await ctx.retry();\r\n if (onError) {\r\n onError(error, Consumer.eventName);\r\n }\r\n }\r\n };\r\n\r\n return callback;\r\n}\r\n"],"mappings":";AAGA,SAAgB,4BACd,UACA,SACA;CACA,MAAM,WAAgC,OAAO,SAAS,QAAQ;EAC5D,MAAM,WAAW,QAAQ;EACzB,IAAI,eAAe,SAAS;EAE5B,IAAI,SAAS,SACX;OAAI,CAAC,SAAS,kBAAkB,OAAO,SAAS,OAAO,CAAC,GAAG;IACzD,IAAI,IAAI;IACR;GACF;;EAGF,MAAM,WAAW,IAAI,SAAS;EAE9B,IAAI,SAAS,QAAQ;GACnB,MAAM,SAAS,MAAM,SAAS,SAAS,YAAY;GACnD,IAAI,CAAC,UAAU,OAAO,YAAY,OAAO;IACvC,IAAI,KAAK;IACT;GACF;GAEA,eAAe,OAAO;EACxB;EACA,IAAI;GACF,MAAM,SAAS,OAAO,cAAc;IAClC,SAAS;IACT,WAAW,SAAS;IACpB,WAAW,QAAQ,SAAS;IAC5B,YAAY,SAAS,aAAa,IAAI,KAAK,SAAS,UAAU,IAAI;IAClE,UAAU,SAAS;IACnB,SAAS,SAAS;IAClB;GACF,CAAC;GACD,IAAI,IAAI;EACV,SAAS,OAAO;GAMd,MAAM,IAAI,MAAM;GAChB,IAAI,SACF,QAAQ,OAAO,SAAS,SAAS;EAErC;CACF;CAEA,OAAO;AACT"}
|
package/llms-full.txt
CHANGED
|
@@ -91,7 +91,7 @@ retry: {
|
|
|
91
91
|
}
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
`maxRetries` is the part that does the work: when a handler **throws**, herald reads the message's `x-retry-count` header and, while it's under `maxRetries`,
|
|
94
|
+
`maxRetries` is the part that does the work: when a handler **throws**, herald reads the message's `x-retry-count` header and, while it's under `maxRetries`, republishes with the header incremented so the count actually advances (a plain requeue never advances it — the broker doesn't add that header). Once `x-retry-count` reaches `maxRetries`, it dead-letters (if `deadLetter` is configured) or drops the message with a loud `log.error` — a poison message can no longer ping-pong forever. This is shared by the automatic throw path and the explicit `ctx.retry()` call, so both honor the same cap.
|
|
95
95
|
|
|
96
96
|
**Caveat on `delay`.** `RetryOptions.delay` (number or `(attempt) => number`) is **not applied on the automatic throw path** — a thrown handler requeues immediately, with no wait. The only place a delay takes effect is the explicit `ctx.retry(delayMs)` call, which republishes the message with an `x-delay` header — and even that needs the RabbitMQ delayed-message-exchange plugin installed, or the delay is ignored. So if you need real backoff, call `ctx.retry(ms)` from inside the handler and install the plugin; don't rely on the channel-level `retry.delay` for timing.
|
|
97
97
|
|
|
@@ -130,12 +130,14 @@ export class UserCreatedConsumer extends EventConsumer<{ id: number; email: stri
|
|
|
130
130
|
// handle(payload, event) — NOT (message, ctx). No ctx.ack() here.
|
|
131
131
|
public async handle(payload: { id: number; email: string }, event: ConsumedEventMessage) {
|
|
132
132
|
await sendWelcomeEmail(payload.email);
|
|
133
|
-
// return cleanly → herald acks. throw → herald
|
|
133
|
+
// return cleanly → herald acks. throw → herald retries (bounded — see below).
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
The handler receives the **already-unwrapped payload** as the first argument and a `ConsumedEventMessage` as the second — `{ messageId, eventName, payload, version?, occurredAt?, metadata?, message }` (where `message` is the raw `Message` if you need `message.metadata.headers`). There is **no `ctx`**: the framework auto-acks when `handle` resolves
|
|
138
|
+
The handler receives the **already-unwrapped payload** as the first argument and a `ConsumedEventMessage` as the second — `{ messageId, eventName, payload, version?, occurredAt?, metadata?, message }` (where `message` is the raw `Message` if you need `message.metadata.headers`). There is **no `ctx`**: the framework auto-acks when `handle` resolves, so you never call `ack`/`nack` yourself in this style.
|
|
139
|
+
|
|
140
|
+
**A throw is a bounded retry, not an infinite requeue.** `handle` throwing routes through the same bounded-retry routine the raw `.subscribe()` throw path uses (below) — republishes with an incremented `x-retry-count`, capped at `maxRetries: 3` (there's no per-consumer `retry`/`deadLetter` config yet, so the cap is always the default). Once the cap is hit, the message is dropped with a loud `log.error` — never a silent, unbounded ack/nack loop.
|
|
139
141
|
|
|
140
142
|
Wiring: the channel name comes from `static eventName`, and `@Consumable` self-registers the moment the class module is **imported** — if a broker is already connected it subscribes immediately, otherwise it buffers and subscribes once `connectToBroker` fires. So the only wiring you need is to import the consumer file on the boot path (e.g. your module's `main.ts`).
|
|
141
143
|
|
|
@@ -253,6 +255,7 @@ yarn add @warlock.js/herald amqplib # amqplib for RabbitMQ
|
|
|
253
255
|
5. **`@warlock.js/seal` schemas validate on publish + receive.** Pass `{ schema }` to `.channel(name, { schema })`.
|
|
254
256
|
6. **Subscribers control message flow** via `ctx.ack()` / `ctx.nack()` / `ctx.reject()` / `ctx.retry(ms)`.
|
|
255
257
|
7. **Smart auto-ack is the default** (`autoAck` unset/`false`). The consumer runs with manual-ack enabled, but herald acks for you when the handler returns cleanly and nacks-with-requeue when it throws — so a crash mid-handling re-delivers, and a clean handler that forgot `ctx.ack()` is still acked. Call `ctx` methods explicitly only when you need a non-default outcome (reject, DLQ, delayed retry). `autoAck: true` is the dangerous mode: the broker acks on delivery, so a crash loses the message.
|
|
258
|
+
8. **`username`/`password` never leak into thrown or logged errors.** They're URI-encoded when building the internal `amqp://` connection URL, and any connect failure — including one built from a caller-supplied `uri` — has `user:pass@` redacted before it's re-thrown/logged. Safe to log a connection error as-is; it will never contain a plaintext broker credential.
|
|
256
259
|
|
|
257
260
|
## Minimal example
|
|
258
261
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warlock.js/herald",
|
|
3
3
|
"description": "Message bus/brokers for RabbitMQ, Kafka, and more",
|
|
4
|
+
"dependencies": {
|
|
5
|
+
"@mongez/copper": "^2.1.2",
|
|
6
|
+
"@mongez/events": "^2.2.7",
|
|
7
|
+
"@mongez/reinforcements": "^4.0.1",
|
|
8
|
+
"@warlock.js/logger": "4.16.0",
|
|
9
|
+
"@warlock.js/seal": "4.16.0"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "https://github.com/warlockjs/herald"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"amqplib": "^0.10.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependenciesMeta": {
|
|
19
|
+
"amqplib": {
|
|
20
|
+
"optional": true
|
|
21
|
+
}
|
|
22
|
+
},
|
|
4
23
|
"keywords": [
|
|
5
24
|
"message-bus",
|
|
6
25
|
"rabbitmq",
|
|
@@ -14,21 +33,7 @@
|
|
|
14
33
|
],
|
|
15
34
|
"author": "hassanzohdy",
|
|
16
35
|
"license": "MIT",
|
|
17
|
-
"
|
|
18
|
-
"type": "git",
|
|
19
|
-
"url": "https://github.com/warlockjs/herald"
|
|
20
|
-
},
|
|
21
|
-
"dependencies": {
|
|
22
|
-
"@mongez/copper": "^2.1.2",
|
|
23
|
-
"@mongez/events": "^2.2.6",
|
|
24
|
-
"@mongez/reinforcements": "^3.3.0",
|
|
25
|
-
"@warlock.js/logger": "4.14.0",
|
|
26
|
-
"@warlock.js/seal": "4.14.0"
|
|
27
|
-
},
|
|
28
|
-
"peerDependencies": {
|
|
29
|
-
"amqplib": "^0.10.0"
|
|
30
|
-
},
|
|
31
|
-
"version": "4.14.0",
|
|
36
|
+
"version": "4.16.0",
|
|
32
37
|
"main": "./cjs/index.cjs",
|
|
33
38
|
"module": "./esm/index.mjs",
|
|
34
39
|
"types": "./esm/index.d.mts",
|
|
@@ -83,7 +83,7 @@ retry: {
|
|
|
83
83
|
}
|
|
84
84
|
```
|
|
85
85
|
|
|
86
|
-
`maxRetries` is the part that does the work: when a handler **throws**, herald reads the message's `x-retry-count` header and, while it's under `maxRetries`,
|
|
86
|
+
`maxRetries` is the part that does the work: when a handler **throws**, herald reads the message's `x-retry-count` header and, while it's under `maxRetries`, republishes with the header incremented so the count actually advances (a plain requeue never advances it — the broker doesn't add that header). Once `x-retry-count` reaches `maxRetries`, it dead-letters (if `deadLetter` is configured) or drops the message with a loud `log.error` — a poison message can no longer ping-pong forever. This is shared by the automatic throw path and the explicit `ctx.retry()` call, so both honor the same cap.
|
|
87
87
|
|
|
88
88
|
**Caveat on `delay`.** `RetryOptions.delay` (number or `(attempt) => number`) is **not applied on the automatic throw path** — a thrown handler requeues immediately, with no wait. The only place a delay takes effect is the explicit `ctx.retry(delayMs)` call, which republishes the message with an `x-delay` header — and even that needs the RabbitMQ delayed-message-exchange plugin installed, or the delay is ignored. So if you need real backoff, call `ctx.retry(ms)` from inside the handler and install the plugin; don't rely on the channel-level `retry.delay` for timing.
|
|
89
89
|
|
|
@@ -122,12 +122,14 @@ export class UserCreatedConsumer extends EventConsumer<{ id: number; email: stri
|
|
|
122
122
|
// handle(payload, event) — NOT (message, ctx). No ctx.ack() here.
|
|
123
123
|
public async handle(payload: { id: number; email: string }, event: ConsumedEventMessage) {
|
|
124
124
|
await sendWelcomeEmail(payload.email);
|
|
125
|
-
// return cleanly → herald acks. throw → herald
|
|
125
|
+
// return cleanly → herald acks. throw → herald retries (bounded — see below).
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
-
The handler receives the **already-unwrapped payload** as the first argument and a `ConsumedEventMessage` as the second — `{ messageId, eventName, payload, version?, occurredAt?, metadata?, message }` (where `message` is the raw `Message` if you need `message.metadata.headers`). There is **no `ctx`**: the framework auto-acks when `handle` resolves
|
|
130
|
+
The handler receives the **already-unwrapped payload** as the first argument and a `ConsumedEventMessage` as the second — `{ messageId, eventName, payload, version?, occurredAt?, metadata?, message }` (where `message` is the raw `Message` if you need `message.metadata.headers`). There is **no `ctx`**: the framework auto-acks when `handle` resolves, so you never call `ack`/`nack` yourself in this style.
|
|
131
|
+
|
|
132
|
+
**A throw is a bounded retry, not an infinite requeue.** `handle` throwing routes through the same bounded-retry routine the raw `.subscribe()` throw path uses (below) — republishes with an incremented `x-retry-count`, capped at `maxRetries: 3` (there's no per-consumer `retry`/`deadLetter` config yet, so the cap is always the default). Once the cap is hit, the message is dropped with a loud `log.error` — never a silent, unbounded ack/nack loop.
|
|
131
133
|
|
|
132
134
|
Wiring: the channel name comes from `static eventName`, and `@Consumable` self-registers the moment the class module is **imported** — if a broker is already connected it subscribes immediately, otherwise it buffers and subscribes once `connectToBroker` fires. So the only wiring you need is to import the consumer file on the boot path (e.g. your module's `main.ts`).
|
|
133
135
|
|
|
@@ -24,6 +24,7 @@ yarn add @warlock.js/herald amqplib # amqplib for RabbitMQ
|
|
|
24
24
|
5. **`@warlock.js/seal` schemas validate on publish + receive.** Pass `{ schema }` to `.channel(name, { schema })`.
|
|
25
25
|
6. **Subscribers control message flow** via `ctx.ack()` / `ctx.nack()` / `ctx.reject()` / `ctx.retry(ms)`.
|
|
26
26
|
7. **Smart auto-ack is the default** (`autoAck` unset/`false`). The consumer runs with manual-ack enabled, but herald acks for you when the handler returns cleanly and nacks-with-requeue when it throws — so a crash mid-handling re-delivers, and a clean handler that forgot `ctx.ack()` is still acked. Call `ctx` methods explicitly only when you need a non-default outcome (reject, DLQ, delayed retry). `autoAck: true` is the dangerous mode: the broker acks on delivery, so a crash loses the message.
|
|
27
|
+
8. **`username`/`password` never leak into thrown or logged errors.** They're URI-encoded when building the internal `amqp://` connection URL, and any connect failure — including one built from a caller-supplied `uri` — has `user:pass@` redacted before it's re-thrown/logged. Safe to log a connection error as-is; it will never contain a plaintext broker credential.
|
|
27
28
|
|
|
28
29
|
## Minimal example
|
|
29
30
|
|