UNPKG

@nestjs/microservices

Version:

Nest - modern, fast, powerful node.js web framework (@microservices)

383 lines (382 loc) • 17.1 kB
/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ import { createRequire } from 'module'; import { EventEmitter } from 'events'; import { EmptyError, firstValueFrom, fromEvent, merge, ReplaySubject, } from 'rxjs'; import { first, map, retryWhen, scan, skip, switchMap } from 'rxjs/operators'; import { BLOCKED_RMQ_MESSAGE, DISCONNECTED_RMQ_MESSAGE, RMQ_DEFAULT_IS_GLOBAL_PREFETCH_COUNT, RMQ_DEFAULT_NO_ASSERT, RMQ_DEFAULT_NOACK, RMQ_DEFAULT_PERSISTENT, RMQ_DEFAULT_PREFETCH_COUNT, RMQ_DEFAULT_QUEUE, RMQ_DEFAULT_QUEUE_OPTIONS, RMQ_DEFAULT_URL, UNBLOCKED_RMQ_MESSAGE, } from '../constants.js'; import { RmqRecordSerializer } from '../serializers/rmq-record.serializer.js'; import { ClientProxy } from './client-proxy.js'; import { Logger } from '@nestjs/common'; import { loadPackageSync, randomStringGenerator, isFunction, isString, } from '@nestjs/common/internal'; let rmqPackage = {}; // typeof import('amqp-connection-manager'); const REPLY_QUEUE = 'amq.rabbitmq.reply-to'; /** * @publicApi */ export class ClientRMQ extends ClientProxy { options; logger = new Logger(ClientProxy.name); connection$; connectionPromise; client = null; channel = null; pendingEventListeners = []; isInitialConnect = true; responseEmitter; queue; queueOptions; replyQueue; noAssert; constructor(options) { super(); this.options = options; this.queue = this.getOptionsProp(this.options, 'queue', RMQ_DEFAULT_QUEUE); this.queueOptions = this.getOptionsProp(this.options, 'queueOptions', RMQ_DEFAULT_QUEUE_OPTIONS); this.replyQueue = this.getOptionsProp(this.options, 'replyQueue', REPLY_QUEUE); this.noAssert = this.getOptionsProp(this.options, 'noAssert') ?? this.queueOptions.noAssert ?? RMQ_DEFAULT_NO_ASSERT; loadPackageSync('amqplib', ClientRMQ.name, () => createRequire(import.meta.url)('amqplib')); rmqPackage = loadPackageSync('amqp-connection-manager', ClientRMQ.name, () => createRequire(import.meta.url)('amqp-connection-manager')); this.initializeSerializer(options); this.initializeDeserializer(options); } async close() { this.handleClose(); try { // The connection has to be closed even when the channel close fails, // otherwise it stays open after close() reported the error. const results = await Promise.allSettled([ this.channel?.close(), this.client?.close(), ]); const [failure, ...otherFailures] = results.filter((result) => result.status === 'rejected'); otherFailures.forEach(({ reason }) => this.logger.error(reason)); if (failure) { throw failure.reason; } } finally { // The state must be reset even when a close fails, otherwise a later // connect() reuses the stale channel and connection. this.channel = null; this.client = null; this.isInitialConnect = true; this.pendingEventListeners = []; } } handleClose() { if (this.routingMap.size > 0) { const err = new Error('Connection closed'); const callbacks = [...this.routingMap.values()]; this.routingMap.clear(); for (const callback of callbacks) { try { callback({ err }); } catch (callbackErr) { // A failing callback must not keep the remaining requests pending // nor prevent the connection from being closed. this.logger.error(callbackErr); } } } // The listeners expect a message to parse, so they cannot carry the error. this.responseEmitter?.removeAllListeners(); } async connect() { if (this.client) { return this.connectionPromise; } this.client = this.createClient(); this.registerErrorListener(this.client); this.registerDisconnectListener(this.client); this.registerConnectListener(this.client); this.registerBlockedListener(this.client); this.registerUnblockedListener(this.client); this.pendingEventListeners.forEach(({ event, callback }) => this.client.on(event, callback)); this.responseEmitter = new EventEmitter(); this.responseEmitter.setMaxListeners(0); const connect$ = this.connect$(this.client); const withDisconnect$ = this.mergeDisconnectEvent(this.client, connect$).pipe(switchMap(() => this.createChannel())); const withReconnect$ = fromEvent(this.client, "connect" /* RmqEventsMap.CONNECT */).pipe(skip(1)); const source$ = merge(withDisconnect$, withReconnect$); this.connection$ = new ReplaySubject(1); source$.subscribe(this.connection$); const client = this.client; const connectionPromise = this.convertConnectionToPromise().catch(async (err) => { // A rejected attempt must not be cached: the next `connect()` call // has to try again once the broker is reachable, instead of failing // forever with the error of the first attempt. await this.discardPartialConnection(client); throw err; }); this.connectionPromise = connectionPromise; return connectionPromise; } /** * Tears down whatever a failed connection attempt managed to create, so the * connection manager it spawned does not keep retrying in the background * while the next attempt creates a new one. The client's state is only reset * if no newer attempt has replaced it in the meantime (e.g., `close()` * followed by `connect()` while the failed attempt was still pending). */ async discardPartialConnection(client) { let channel = null; // Compared by client, not by promise: the disconnect listener replaces // "connectionPromise" when the broker drops the connection during the setup. if (this.client === client) { channel = this.channel; this.client = null; this.channel = null; this.connectionPromise = null; this.isInitialConnect = true; } await Promise.allSettled([client.close(), channel?.close()]); } createChannel() { return new Promise((resolve, reject) => { this.channel = this.client.createChannel({ json: false, setup: (channel) => this.setupChannel(channel, resolve), }); // The wrapper emits "error" when the setup throws, and an emitter with // no listener throws, which crashes the process. Rejecting is a no-op // once the channel is set up, so later errors are only logged. this.channel.on("error" /* RmqEventsMap.ERROR */, (err) => { this.logger.error(err); reject(err); }); }); } createClient() { const socketOptions = this.getOptionsProp(this.options, 'socketOptions'); const urls = this.getOptionsProp(this.options, 'urls') || [RMQ_DEFAULT_URL]; return rmqPackage.connect(urls, socketOptions); } mergeDisconnectEvent(instance, source$) { const eventToError = (eventType) => fromEvent(instance, eventType).pipe(map((err) => { throw err; })); const disconnect$ = eventToError("disconnect" /* RmqEventsMap.DISCONNECT */); const urls = this.getOptionsProp(this.options, 'urls', []); const connectFailedEventKey = 'connectFailed'; const connectFailed$ = eventToError(connectFailedEventKey).pipe(retryWhen(e => e.pipe(scan((errorCount, error) => { if (urls.indexOf(error.url) >= urls.length - 1) { throw error; } return errorCount + 1; }, 0)))); // If we ever decide to propagate all disconnect errors & re-emit them through // the "connection" stream then comment out "first()" operator. return merge(source$, disconnect$, connectFailed$).pipe(first()); } async convertConnectionToPromise() { try { return await firstValueFrom(this.connection$); } catch (err) { if (err instanceof EmptyError) { return; } throw err; } } async setupChannel(channel, resolve) { const prefetchCount = this.getOptionsProp(this.options, 'prefetchCount') || RMQ_DEFAULT_PREFETCH_COUNT; const isGlobalPrefetchCount = this.getOptionsProp(this.options, 'isGlobalPrefetchCount') || RMQ_DEFAULT_IS_GLOBAL_PREFETCH_COUNT; if (!this.options.wildcards && this.options.exchangeType !== 'fanout') { if (!this.noAssert) { await channel.assertQueue(this.queue, this.queueOptions); } if (this.options.exchange && this.options.routingKey) { await channel.bindQueue(this.queue, this.options.exchange, this.options.exchangeType === 'fanout' ? '' : this.options.routingKey); } } else { const exchange = this.getOptionsProp(this.options, 'exchange', this.options.queue); const exchangeType = this.getOptionsProp(this.options, 'exchangeType', 'topic'); await channel.assertExchange(exchange, exchangeType, { durable: true, arguments: this.getOptionsProp(this.options, 'exchangeArguments', {}), }); } await channel.prefetch(prefetchCount, isGlobalPrefetchCount); await this.consumeChannel(channel); resolve(); } async consumeChannel(channel) { const noAck = this.getOptionsProp(this.options, 'noAck', RMQ_DEFAULT_NOACK); await channel.consume(this.replyQueue, (msg) => this.responseEmitter.emit(msg.properties.correlationId, msg), { noAck, }); } registerErrorListener(client) { client.addListener("error" /* RmqEventsMap.ERROR */, (err) => this.logger.error(err)); } registerDisconnectListener(client) { client.addListener("disconnect" /* RmqEventsMap.DISCONNECT */, (err) => { this._status$.next("disconnected" /* RmqStatus.DISCONNECTED */); if (!this.isInitialConnect) { this.connectionPromise = Promise.reject('Error: Connection lost. Trying to reconnect...'); // Prevent unhandled promise rejection this.connectionPromise.catch(() => { }); } this.logger.error(DISCONNECTED_RMQ_MESSAGE); this.logger.error(err); }); } registerConnectListener(client) { client.addListener("connect" /* RmqEventsMap.CONNECT */, () => { this._status$.next("connected" /* RmqStatus.CONNECTED */); this.logger.log('Successfully connected to RMQ broker'); if (this.isInitialConnect) { this.isInitialConnect = false; } else { this.connectionPromise = Promise.resolve(); } }); } registerBlockedListener(client) { client.addListener("blocked" /* RmqEventsMap.BLOCKED */, ({ reason }) => { this._status$.next("blocked" /* RmqStatus.BLOCKED */); this.logger.warn(BLOCKED_RMQ_MESSAGE(reason)); }); } registerUnblockedListener(client) { client.addListener("unblocked" /* RmqEventsMap.UNBLOCKED */, () => { this._status$.next("unblocked" /* RmqStatus.UNBLOCKED */); this.logger.log(UNBLOCKED_RMQ_MESSAGE); }); } on(event, callback) { // Kept until `close()`, so the clients created later (e.g., after a // failed connect) get it as well this.pendingEventListeners.push({ event, callback }); if (this.client) { this.client.addListener(event, callback); } } unwrap() { if (!this.client) { throw new Error('Not initialized. Please call the "connect" method first.'); } return this.client; } async handleMessage(packet, options, callback) { if (isFunction(options)) { callback = options; options = undefined; } let incomingResponse; try { incomingResponse = await this.deserializer.deserialize(packet, options); } catch (error) { // Nobody awaits this listener, so a rejection would crash the process. return callback?.({ err: error, isDisposed: true }); } const { err, response, isDisposed } = incomingResponse; if (isDisposed || err) { return callback?.({ err, response, isDisposed: true, }); } callback?.({ err, response, }); } publish(message, callback) { let cleanup = () => { }; try { const correlationId = randomStringGenerator(); const listener = ({ content, options, }) => this.handleMessage(this.parseMessageContent(content), options, callback); Object.assign(message, { id: correlationId }); const serializedPacket = this.serializer.serialize(message); const options = serializedPacket.options; delete serializedPacket.options; this.responseEmitter.on(correlationId, listener); this.routingMap.set(correlationId, callback); cleanup = () => { this.routingMap.delete(correlationId); this.responseEmitter.removeListener(correlationId, listener); }; const content = Buffer.from(JSON.stringify(serializedPacket)); const sendOptions = { replyTo: this.replyQueue, persistent: this.getOptionsProp(this.options, 'persistent', RMQ_DEFAULT_PERSISTENT), ...options, headers: this.mergeHeaders(options?.headers), correlationId, }; if (this.options.wildcards || this.options.exchangeType === 'fanout') { const stringifiedPattern = isString(message.pattern) ? message.pattern : JSON.stringify(message.pattern); // The exchange is the same as the queue when wildcards are enabled // and the exchange is not explicitly set const exchange = this.getOptionsProp(this.options, 'exchange', this.queue); this.channel.publish(exchange, stringifiedPattern, content, sendOptions).catch(err => callback({ err })); } else { this.channel.sendToQueue(this.queue, content, sendOptions).catch(err => callback({ err })); } return cleanup; } catch (err) { cleanup(); callback({ err }); return () => { }; } } dispatchEvent(packet) { const serializedPacket = this.serializer.serialize(packet); const options = serializedPacket.options; delete serializedPacket.options; return new Promise((resolve, reject) => { const content = Buffer.from(JSON.stringify(serializedPacket)); const sendOptions = { persistent: this.getOptionsProp(this.options, 'persistent', RMQ_DEFAULT_PERSISTENT), ...options, headers: this.mergeHeaders(options?.headers), }; const errorCallback = (err) => err ? reject(err) : resolve(); return this.options.wildcards || this.options.exchangeType === 'fanout' ? this.channel.publish( // The exchange is the same as the queue when wildcards are enabled // and the exchange is not explicitly set this.getOptionsProp(this.options, 'exchange', this.queue), isString(packet.pattern) ? packet.pattern : JSON.stringify(packet.pattern), content, sendOptions, errorCallback) : this.channel.sendToQueue(this.queue, content, sendOptions, errorCallback); }); } initializeSerializer(options) { this.serializer = options?.serializer ?? new RmqRecordSerializer(); } mergeHeaders(requestHeaders) { if (!requestHeaders && !this.options?.headers) { return undefined; } return { ...this.options?.headers, ...requestHeaders, }; } parseMessageContent(content) { const rawContent = content.toString(); try { return JSON.parse(rawContent); } catch { return rawContent; } } }