UNPKG

@golevelup/nestjs-rabbitmq

Version:
613 lines 29.2 kB
"use strict"; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.AmqpConnection = void 0; const common_1 = require("@nestjs/common"); const amqp_connection_manager_1 = require("amqp-connection-manager"); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const crypto_1 = require("crypto"); const __1 = require(".."); const errorBehaviors_1 = require("./errorBehaviors"); const errors_1 = require("./errors"); const handlerResponses_1 = require("./handlerResponses"); const lodash_1 = require("lodash"); const utils_1 = require("./utils"); const DIRECT_REPLY_QUEUE = 'amq.rabbitmq.reply-to'; const defaultConfig = { name: 'default', prefetchCount: 10, defaultExchangeType: 'topic', defaultRpcErrorHandler: (0, errorBehaviors_1.getHandlerForLegacyBehavior)(errorBehaviors_1.MessageHandlerErrorBehavior.REQUEUE), defaultSubscribeErrorBehavior: errorBehaviors_1.MessageHandlerErrorBehavior.REQUEUE, exchanges: [], exchangeBindings: [], queues: [], defaultRpcTimeout: 10000, connectionInitOptions: { wait: true, timeout: 5000, reject: true, skipConnectionFailedLogging: false, skipDisconnectFailedLogging: false, }, connectionManagerOptions: {}, registerHandlers: true, enableDirectReplyTo: true, channels: {}, handlers: {}, defaultHandler: '', enableControllerDiscovery: false, }; class AmqpConnection { constructor(config) { this.messageSubject = new rxjs_1.Subject(); this.initialized = new rxjs_1.Subject(); this._managedChannels = {}; this._channels = {}; this._consumers = {}; this.outstandingMessageProcessing = new Set(); this.config = Object.assign(Object.assign({ deserializer: (message) => JSON.parse(message.toString()), serializer: (value) => Buffer.from(JSON.stringify(value)), logger: (config === null || config === void 0 ? void 0 : config.logger) || new common_1.Logger(AmqpConnection.name) }, defaultConfig), config); this.logger = this.config.logger; } get channel() { if (!this._channel) throw new errors_1.ChannelNotAvailableError(); return this._channel; } get connection() { if (!this._connection) throw new errors_1.ConnectionNotAvailableError(); return this._connection; } get managedChannel() { return this._managedChannel; } get managedConnection() { return this._managedConnection; } get configuration() { return this.config; } get channels() { return this._channels; } get managedChannels() { return this._managedChannels; } get connected() { return this._managedConnection.isConnected(); } async init() { const options = Object.assign(Object.assign({}, defaultConfig.connectionInitOptions), this.config.connectionInitOptions); const { skipConnectionFailedLogging, skipDisconnectFailedLogging, wait, timeout: timeoutInterval, reject, } = options; const p = this.initCore(wait, skipConnectionFailedLogging, skipDisconnectFailedLogging); if (!wait) { this.logger.log(`Skipping connection health checks as 'wait' is disabled. The application will proceed without verifying a healthy RabbitMQ connection.`); return p; } return (0, rxjs_1.lastValueFrom)(this.initialized.pipe((0, operators_1.take)(1), (0, operators_1.timeout)({ each: timeoutInterval, with: () => (0, rxjs_1.throwError)(() => new Error(`Failed to connect to a RabbitMQ broker within a timeout of ${timeoutInterval}ms`)), }), (0, operators_1.catchError)((err) => (reject ? (0, rxjs_1.throwError)(() => err) : rxjs_1.EMPTY)))); } async initCore(wait = false, skipConnectionFailedLogging = false, skipDisconnectFailedLogging = false) { this.logger.log(`Trying to connect to RabbitMQ broker (${this.config.name})`); this._managedConnection = (0, amqp_connection_manager_1.connect)(Array.isArray(this.config.uri) ? this.config.uri : [this.config.uri], this.config.connectionManagerOptions); this._managedConnection.on('connect', ({ connection }) => { this._connection = connection; this.logger.log(`Successfully connected to RabbitMQ broker (${this.config.name})`); }); // Logging disconnections should only be able if consumers // do not skip it. We may be able to merge with the `skipConnectionFailedLogging` // option in the future. if (!skipDisconnectFailedLogging) { this._managedConnection.on('disconnect', ({ err }) => { this.logger.error(`Disconnected from RabbitMQ broker (${this.config.name})`, err === null || err === void 0 ? void 0 : err.stack); }); } // Certain consumers might want to skip "connectionFailed" logging // therefore this option will allow us to conditionally register this event consumption if (!skipConnectionFailedLogging) { this._managedConnection.on('connectFailed', ({ err }) => { var _a, _b; const message = `Connection Failed: Unable to establish a connection to the broker (${this.config.name}). Check the broker's availability, network connectivity, and configuration.`; if (!wait) { // Lower the log severity if 'wait' is disabled, as the application continues to function. this.logger.warn(message); if (err === null || err === void 0 ? void 0 : err.stack) { (_b = (_a = this.logger).debug) === null || _b === void 0 ? void 0 : _b.call(_a, `Stack trace: ${err.stack}`); } } else { // Log as an error if 'wait' is enabled, as this impacts the connection health. this.logger.error(message, err === null || err === void 0 ? void 0 : err.stack); } }); } const defaultChannel = { name: AmqpConnection.name, config: { prefetchCount: this.config.prefetchCount, default: true, }, }; await Promise.all([ Promise.all(Object.keys(this.config.channels).map(async (channelName) => { const config = this.config.channels[channelName]; // Only takes the first channel specified as default so other ones get created. if (defaultChannel.name === AmqpConnection.name && config.default) { defaultChannel.name = channelName; defaultChannel.config.prefetchCount = config.prefetchCount || this.config.prefetchCount; return; } return this.setupManagedChannel(channelName, Object.assign(Object.assign({}, config), { default: false })); })), this.setupManagedChannel(defaultChannel.name, defaultChannel.config), ]); } async setupInitChannel(channel, name, config) { this._channels[name] = channel; await channel.prefetch(config.prefetchCount || this.config.prefetchCount); if (config.default) { this._channel = channel; // Always assert exchanges & rpc queue in default channel. await Promise.all(this.config.exchanges.map((x) => { const { createExchangeIfNotExists = true } = x; if (createExchangeIfNotExists) { return channel.assertExchange(x.name, x.type || this.config.defaultExchangeType, x.options); } return channel.checkExchange(x.name); })); await Promise.all(this.config.exchangeBindings.map((exchangeBinding) => channel.bindExchange(exchangeBinding.destination, exchangeBinding.source, exchangeBinding.pattern, exchangeBinding.args))); await this.setupQueuesWithBindings(channel, this.config.queues); if (this.config.enableDirectReplyTo) { await this.initDirectReplyQueue(channel); } this.initialized.next(); } } async setupQueuesWithBindings(channel, queues) { await Promise.all(queues.map(async (configuredQueue) => { const { name, options, bindQueueArguments } = configuredQueue, rest = __rest(configuredQueue, ["name", "options", "bindQueueArguments"]); const queueOptions = Object.assign(Object.assign({}, options), (bindQueueArguments !== undefined && { bindQueueArguments })); await this.setupQueue(Object.assign(Object.assign(Object.assign({}, rest), (name !== undefined && { queue: name })), { queueOptions }), channel); })); } async initDirectReplyQueue(channel) { // Set up a consumer on the Direct Reply-To queue to facilitate RPC functionality await channel.consume(DIRECT_REPLY_QUEUE, (msg) => { var _a, _b, _c; if (msg == null) { return; } // Check that the Buffer has content, before trying to parse it const message = msg.content.length > 0 ? this.config.deserializer(msg.content, msg) : undefined; const correlationMessage = { correlationId: msg.properties.correlationId.toString(), requestId: (_c = (_b = (_a = msg.properties) === null || _a === void 0 ? void 0 : _a.headers) === null || _b === void 0 ? void 0 : _b['X-Request-ID']) === null || _c === void 0 ? void 0 : _c.toString(), message: message, }; this.messageSubject.next(correlationMessage); }, { noAck: true, }); } async request(requestOptions) { var _a; const correlationId = requestOptions.correlationId || (0, crypto_1.randomUUID)(); const requestId = (_a = requestOptions === null || requestOptions === void 0 ? void 0 : requestOptions.headers) === null || _a === void 0 ? void 0 : _a['X-Request-ID']; const timeout = requestOptions.timeout || this.config.defaultRpcTimeout; const payload = requestOptions.payload || {}; const response$ = this.messageSubject.pipe((0, operators_1.filter)((x) => requestId ? x.correlationId === correlationId && x.requestId === requestId : x.correlationId === correlationId), (0, operators_1.map)((x) => x.message), (0, operators_1.first)()); const timeout$ = (0, rxjs_1.interval)(timeout).pipe((0, operators_1.first)(), (0, operators_1.map)(() => { throw new errors_1.RpcTimeoutError(timeout, requestOptions.exchange, requestOptions.routingKey); })); // Wrapped lastValueFrom(race(response$, timeout$)) in a Promise to properly catch // timeout errors. Without this, the timeout could trigger while publish() was // still running, causing an unhandled rejection and crashing the application. const [result] = await Promise.all([ (0, rxjs_1.lastValueFrom)((0, rxjs_1.race)(response$, timeout$)), this.publish(requestOptions.exchange, requestOptions.routingKey, payload, Object.assign(Object.assign({}, requestOptions.publishOptions), { replyTo: DIRECT_REPLY_QUEUE, correlationId, headers: requestOptions.headers, expiration: requestOptions.expiration })), ]); return result; } async createSubscriber(handler, msgOptions, originalHandlerName, consumeOptions) { return this.consumerFactory(msgOptions, (channel, channelMsgOptions) => this.setupSubscriberChannel(handler, channelMsgOptions, channel, originalHandlerName, consumeOptions)); } async createBatchSubscriber(handler, msgOptions, consumeOptions) { return this.consumerFactory(msgOptions, (channel, channelMsgOptions) => this.setupBatchSubscriberChannel(handler, channelMsgOptions, channel, consumeOptions)); } async consumerFactory(msgOptions, setupFunction) { return new Promise((res) => { // Use globally configured consumer tag. // See https://github.com/golevelup/nestjs/issues/904 var _a; const queueConfig = this.config.queues.find((q) => q.name === msgOptions.queue); const consumerTagConfig = (queueConfig === null || queueConfig === void 0 ? void 0 : queueConfig.consumerTag) ? { queueOptions: { consumerOptions: { consumerTag: queueConfig.consumerTag, }, }, } : {}; this.selectManagedChannel((_a = msgOptions === null || msgOptions === void 0 ? void 0 : msgOptions.queueOptions) === null || _a === void 0 ? void 0 : _a.channel).addSetup(async (channel) => { const consumerTag = await setupFunction(channel, // Override global configuration by merging the global/default // tag configuration with the parametized msgOption. (0, lodash_1.merge)(consumerTagConfig, msgOptions)); res({ consumerTag }); }); }); } /** * Wrap a consumer with logic that tracks the outstanding message processing to * be able to wait for them on shutdown. */ wrapConsumer(consumer) { return (msg) => { const messageProcessingPromise = Promise.resolve(consumer(msg)); this.outstandingMessageProcessing.add(messageProcessingPromise); messageProcessingPromise.finally(() => this.outstandingMessageProcessing.delete(messageProcessingPromise)); }; } async setupSubscriberChannel(handler, msgOptions, channel, originalHandlerName = 'unknown', consumeOptions) { const queue = await this.setupQueue(msgOptions, channel); const { consumerTag } = await channel.consume(queue, this.wrapConsumer(async (msg) => { try { if ((0, lodash_1.isNull)(msg)) { throw new errors_1.NullMessageError(); } const result = this.deserializeMessage(msg, msgOptions); const response = await handler(result.message, msg, result.headers); if (response instanceof handlerResponses_1.Nack) { channel.nack(msg, false, response.requeue); return; } // developers should be responsible to avoid subscribers that return therefore // the request will be acknowledged if (response) { this.logger.warn(`Received response: [${this.config.serializer(response)}] from subscribe handler [${originalHandlerName}]. Subscribe handlers should only return void`); } channel.ack(msg); } catch (e) { if ((0, lodash_1.isNull)(msg)) { return; } else { const errorHandler = msgOptions.errorHandler || (0, errorBehaviors_1.getHandlerForLegacyBehavior)(msgOptions.errorBehavior || this.config.defaultSubscribeErrorBehavior); await errorHandler(channel, msg, e); } } }), consumeOptions); this.registerConsumerForQueue({ type: 'subscribe', consumerTag, handler, msgOptions, channel, }); return consumerTag; } async setupBatchSubscriberChannel(handler, msgOptions, channel, consumeOptions) { var _a, _b; let batchSize = (_a = msgOptions.batchOptions) === null || _a === void 0 ? void 0 : _a.size; let batchTimeout = (_b = msgOptions.batchOptions) === null || _b === void 0 ? void 0 : _b.timeout; let batchMsgs = []; let batchTimer; let inflightBatchHandler; // Normalize batch values but warn consumer about this adjusts if (!batchSize || batchSize < 2) { this.logger.warn(`batch size too low/not defined, received: ${batchSize}. Adjusting to 10`); batchSize = 10; } if (!batchTimeout || batchTimeout < 1) { this.logger.warn(`batch timeout too low/not defined, received: ${batchTimeout}. Setting timeout to 200ms`); batchTimeout = 200; } const queue = await this.setupQueue(msgOptions, channel); const { consumerTag } = await channel.consume(queue, this.wrapConsumer(async (msg) => { if ((0, lodash_1.isNull)(msg)) { return; } batchMsgs.push(msg); if (batchMsgs.length === 1) { // Wrapped in a Promise to ensure outstanding message logic is aware. await new Promise((resolve) => { const batchHandler = async () => { const batchMsgsToProcess = batchMsgs; batchMsgs = []; await this.handleBatchedMessages(handler, msgOptions, channel, batchMsgsToProcess); resolve(); }; batchTimer = setTimeout(batchHandler, batchTimeout); inflightBatchHandler = batchHandler; }); } else if (batchMsgs.length === batchSize) { clearTimeout(batchTimer); await inflightBatchHandler(); } else { batchTimer.refresh(); } }), consumeOptions); this.registerConsumerForQueue({ type: 'subscribe-batch', consumerTag, handler, msgOptions, channel, }); return consumerTag; } async handleBatchedMessages(handler, msgOptions, channel, batchMsgs) { var _a; try { const messages = []; const headers = []; for (const msg of batchMsgs) { const result = this.deserializeMessage(msg, msgOptions); messages.push(result.message); headers.push(result.headers); } const response = await handler(messages, batchMsgs, headers); if (response instanceof handlerResponses_1.Nack) { for (const msg of batchMsgs) { channel.nack(msg, false, response.requeue); } return; } for (const msg of batchMsgs) { channel.ack(msg); } } catch (e) { const batchErrorHandler = (_a = msgOptions.batchOptions) === null || _a === void 0 ? void 0 : _a.errorHandler; const errorHandler = msgOptions.errorHandler; const defaultErrorHandler = (0, errorBehaviors_1.getHandlerForLegacyBehavior)(msgOptions.errorBehavior || this.config.defaultSubscribeErrorBehavior); if (batchErrorHandler) { await batchErrorHandler(channel, batchMsgs, e); } else if (errorHandler) { for (const msg of batchMsgs) { await errorHandler(channel, msg, e); } } else { await defaultErrorHandler(channel, batchMsgs, e); } } } async createRpc(handler, rpcOptions) { return this.consumerFactory(rpcOptions, (channel, channelRpcOptions) => this.setupRpcChannel(handler, channelRpcOptions, channel)); } async setupRpcChannel(handler, rpcOptions, channel) { var _a; const queue = await this.setupQueue(rpcOptions, channel); const { consumerTag } = await channel.consume(queue, this.wrapConsumer(async (msg) => { var _a; try { if (msg == null) { throw new errors_1.NullMessageError(); } if (!(0, utils_1.matchesRoutingKey)(msg.fields.routingKey, rpcOptions.routingKey)) { channel.nack(msg, false, false); this.logger.error('Received message with invalid routing key: ' + msg.fields.routingKey); return; } const result = this.deserializeMessage(msg, rpcOptions); const response = await handler(result.message, msg, result.headers); if (response instanceof handlerResponses_1.Nack) { channel.nack(msg, false, response.requeue); return; } const { replyTo, correlationId, expiration, headers } = msg.properties; if (replyTo) { await this.publish('', replyTo, response, { correlationId, expiration, headers, persistent: (_a = rpcOptions.usePersistentReplyTo) !== null && _a !== void 0 ? _a : false, }); } channel.ack(msg); } catch (e) { if (msg == null) { return; } else { const errorHandler = rpcOptions.errorHandler || this.config.defaultRpcErrorHandler || (0, errorBehaviors_1.getHandlerForLegacyBehavior)(rpcOptions.errorBehavior || this.config.defaultSubscribeErrorBehavior); await errorHandler(channel, msg, e); } } }), (_a = rpcOptions === null || rpcOptions === void 0 ? void 0 : rpcOptions.queueOptions) === null || _a === void 0 ? void 0 : _a.consumerOptions); this.registerConsumerForQueue({ type: 'rpc', consumerTag, handler, msgOptions: rpcOptions, channel, }); return consumerTag; } publish(exchange, routingKey, message, options) { let buffer; if (message instanceof Buffer) { buffer = message; } else if (message instanceof Uint8Array) { buffer = Buffer.from(message); } else if (message != null) { buffer = this.config.serializer(message); } else { buffer = Buffer.alloc(0); } return this._managedChannel.publish(exchange, routingKey, buffer, options); } deserializeMessage(msg, options) { let message = undefined; let headers = undefined; const deserializer = options.deserializer || this.config.deserializer; if (msg.content) { if (options.allowNonJsonMessages) { try { message = deserializer(msg.content, msg); } catch (_a) { // Pass raw message since flag `allowNonJsonMessages` is set // Casting to `any` first as T doesn't have a type message = msg.content.toString(); } } else { message = deserializer(msg.content, msg); } } if (msg.properties && msg.properties.headers) { headers = msg.properties.headers; } return { message, headers }; } async setupQueue(subscriptionOptions, channel) { const { exchange, routingKey, createQueueIfNotExists = true, assertQueueErrorHandler = __1.defaultAssertQueueErrorHandler, queueOptions, queue: queueName = '', } = subscriptionOptions; let actualQueue; if (createQueueIfNotExists) { try { const { queue } = await channel.assertQueue(queueName, queueOptions); actualQueue = queue; } catch (error) { actualQueue = await assertQueueErrorHandler(channel, queueName, queueOptions, error); } } else { const { queue } = await channel.checkQueue(subscriptionOptions.queue || ''); actualQueue = queue; } let bindQueueArguments; if (queueOptions) { bindQueueArguments = queueOptions.bindQueueArguments; } const routingKeys = Array.isArray(routingKey) ? routingKey : [routingKey]; if (exchange && routingKeys) { await Promise.all(routingKeys.map((routingKey) => { if (routingKey != null) { return channel.bindQueue(actualQueue, exchange, routingKey, bindQueueArguments); } })); } return actualQueue; } setupManagedChannel(name, config) { const channel = this._managedConnection.createChannel({ name, }); this._managedChannels[name] = channel; if (config.default) { this._managedChannel = channel; } channel.on('connect', () => this.logger.log(`Successfully connected a RabbitMQ channel "${name}"`)); channel.on('error', (err, { name }) => this.logger.error(`Failed to setup a RabbitMQ channel - name: ${name} / error: ${err.message} ${err.stack}`)); channel.on('close', () => this.logger.log(`Successfully closed a RabbitMQ channel "${name}"`)); return channel.addSetup((c) => this.setupInitChannel(c, name, config)); } /** * Selects managed channel based on name, if not found uses default. * @param name name of the channel * @returns channel wrapper */ selectManagedChannel(name) { if (!name) return this._managedChannel; const channel = this._managedChannels[name]; if (!channel) { this.logger.warn(`Channel "${name}" does not exist, using default channel: ${this._managedChannel.name}.`); return this._managedChannel; } return channel; } registerConsumerForQueue(consumer) { this._consumers[consumer.consumerTag] = consumer; } unregisterConsumerForQueue(consumerTag) { delete this._consumers[consumerTag]; } getConsumer(consumerTag) { return this._consumers[consumerTag]; } get consumerTags() { return Object.keys(this._consumers); } async cancelConsumer(consumerTag) { const consumer = this.getConsumer(consumerTag); if (consumer && consumer.channel) { this.logger.log(`Canceling consumer with tag: ${consumerTag}`); await consumer.channel.cancel(consumerTag); } } async resumeConsumer(consumerTag) { const consumer = this.getConsumer(consumerTag); if (!consumer) { return null; } let newConsumerTag; if (consumer.type === 'rpc') { newConsumerTag = await this.setupRpcChannel(consumer.handler, consumer.msgOptions, consumer.channel); } else if (consumer.type === 'subscribe') { newConsumerTag = await this.setupSubscriberChannel(consumer.handler, consumer.msgOptions, consumer.channel); } else if (consumer.type === 'subscribe-batch') { newConsumerTag = await this.setupBatchSubscriberChannel(consumer.handler, consumer.msgOptions, consumer.channel); } else { throw new Error(`Unable to resume consumer tag ${consumerTag}. Unexpected consumer type ${consumer.type}.`); } // A new consumerTag was created, remove old this.unregisterConsumerForQueue(consumerTag); return newConsumerTag; } async close() { const managedChannels = Object.values(this._managedChannels); // First cancel all consumers so they stop getting new messages await Promise.all(managedChannels.map((channel) => channel.cancelAll())); // Wait for all the outstanding messages to be processed if (this.outstandingMessageProcessing.size) { this.logger.log(`Waiting for outstanding consumers, outstanding message count: ${this.outstandingMessageProcessing.size}`); } await Promise.all(this.outstandingMessageProcessing); // Close all channels await Promise.all(managedChannels.map((channel) => channel.close())); await this.managedConnection.close(); } } exports.AmqpConnection = AmqpConnection; //# sourceMappingURL=connection.js.map