From 3dfc7fc68eaa161aae712024e712eb292797b2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Fri, 15 Nov 2024 10:03:05 +0100 Subject: [PATCH 01/10] feat(microservices): add status, unwrap, on, and other features --- .../interfaces/nest-microservice.interface.ts | 42 ++++- packages/microservices/client/client-grpc.ts | 44 ++++- packages/microservices/client/client-kafka.ts | 137 +++++++++++---- packages/microservices/client/client-mqtt.ts | 138 ++++++++++++--- packages/microservices/client/client-nats.ts | 97 ++++++++++- .../client/client-proxy-factory.ts | 29 ++-- packages/microservices/client/client-proxy.ts | 68 +++++++- packages/microservices/client/client-redis.ts | 161 +++++++++++++++--- packages/microservices/client/client-rmq.ts | 144 +++++++++++----- packages/microservices/client/client-tcp.ts | 105 ++++++++---- packages/microservices/client/constants.ts | 2 - packages/microservices/constants.ts | 47 ++--- packages/microservices/container.ts | 9 +- packages/microservices/events/index.ts | 6 + packages/microservices/events/kafka.events.ts | 7 + packages/microservices/events/mqtt.events.ts | 39 +++++ packages/microservices/events/nats.events.ts | 29 ++++ packages/microservices/events/redis.events.ts | 34 ++++ packages/microservices/events/rmq.events.ts | 24 +++ packages/microservices/events/tcp.events.ts | 39 +++++ .../external/mqtt-client.interface.ts | 141 --------------- .../external/nats-client.interface.ts | 93 ---------- .../external/nats-codec.interface.ts | 9 + packages/microservices/helpers/tcp-socket.ts | 29 ++-- packages/microservices/index.ts | 1 + .../interfaces/client-grpc.interface.ts | 10 ++ .../client-kafka-proxy.interface.ts | 33 ++++ .../interfaces/closeable.interface.ts | 3 - .../custom-transport-strategy.interface.ts | 10 ++ packages/microservices/interfaces/index.ts | 2 +- .../microservice-configuration.interface.ts | 4 +- .../microservices/listeners-controller.ts | 33 ++-- .../microservices/microservices-module.ts | 12 +- .../microservices/module/clients.module.ts | 3 +- packages/microservices/nest-microservice.ts | 120 +++++++++++-- .../record-builders/mqtt.record-builder.ts | 4 +- .../microservices/server/server-factory.ts | 32 ++-- packages/microservices/server/server-grpc.ts | 55 ++++-- packages/microservices/server/server-kafka.ts | 58 ++++++- packages/microservices/server/server-mqtt.ts | 89 ++++++++-- packages/microservices/server/server-nats.ts | 75 +++++++- packages/microservices/server/server-redis.ts | 133 ++++++++++++--- packages/microservices/server/server-rmq.ts | 124 ++++++++++---- packages/microservices/server/server-tcp.ts | 96 ++++++++--- packages/microservices/server/server.ts | 52 +++++- .../test/client/client-kafka.spec.ts | 86 +++++++--- .../test/client/client-mqtt.spec.ts | 65 +++++-- .../test/client/client-nats.spec.ts | 4 +- .../test/client/client-proxy.spec.ts | 6 + .../test/client/client-redis.spec.ts | 79 ++++++--- .../test/client/client-rmq.spec.ts | 25 +-- .../test/client/client-tcp.spec.ts | 78 ++++++--- .../test/json-socket/connection.spec.ts | 6 +- .../microservices/test/json-socket/helpers.ts | 6 +- .../json-socket/listener-chaining.spec.ts | 8 +- .../test/json-socket/message-parsing.spec.ts | 20 ++- .../test/server/server-grpc.spec.ts | 11 +- .../test/server/server-kafka.spec.ts | 73 +++++--- .../test/server/server-mqtt.spec.ts | 18 +- .../test/server/server-redis.spec.ts | 4 +- .../test/server/server-rmq.spec.ts | 44 ++--- .../microservices/test/server/server.spec.ts | 9 +- 62 files changed, 2113 insertions(+), 851 deletions(-) delete mode 100644 packages/microservices/client/constants.ts create mode 100644 packages/microservices/events/index.ts create mode 100644 packages/microservices/events/kafka.events.ts create mode 100644 packages/microservices/events/mqtt.events.ts create mode 100644 packages/microservices/events/nats.events.ts create mode 100644 packages/microservices/events/redis.events.ts create mode 100644 packages/microservices/events/rmq.events.ts create mode 100644 packages/microservices/events/tcp.events.ts delete mode 100644 packages/microservices/external/mqtt-client.interface.ts delete mode 100644 packages/microservices/external/nats-client.interface.ts create mode 100644 packages/microservices/external/nats-codec.interface.ts create mode 100644 packages/microservices/interfaces/client-kafka-proxy.interface.ts delete mode 100644 packages/microservices/interfaces/closeable.interface.ts diff --git a/packages/common/interfaces/nest-microservice.interface.ts b/packages/common/interfaces/nest-microservice.interface.ts index 025c331bc33..efcaa0761f5 100644 --- a/packages/common/interfaces/nest-microservice.interface.ts +++ b/packages/common/interfaces/nest-microservice.interface.ts @@ -1,3 +1,4 @@ +import { Observable } from 'rxjs'; import { ExceptionFilter } from './exceptions/exception-filter.interface'; import { CanActivate } from './features/can-activate.interface'; import { NestInterceptor } from './features/nest-interceptor.interface'; @@ -19,8 +20,8 @@ export interface INestMicroservice extends INestApplicationContext { listen(): Promise; /** - * Register Ws Adapter which will be used inside Gateways. - * Use when you want to override default `socket.io` library. + * Registers a web socket adapter that will be used for Gateways. + * Use to override the default `socket.io` library. * * @param {WebSocketAdapter} adapter * @returns {this} @@ -28,37 +29,64 @@ export interface INestMicroservice extends INestApplicationContext { useWebSocketAdapter(adapter: WebSocketAdapter): this; /** - * Registers exception filters as global filters (will be used within every message pattern handler) + * Registers global exception filters (will be used for every pattern handler). * * @param {...ExceptionFilter} filters */ useGlobalFilters(...filters: ExceptionFilter[]): this; /** - * Registers pipes as global pipes (will be used within every message pattern handler) + * Registers global pipes (will be used for every pattern handler). * * @param {...PipeTransform} pipes */ useGlobalPipes(...pipes: PipeTransform[]): this; /** - * Registers interceptors as global interceptors (will be used within every message pattern handler) + * Registers global interceptors (will be used for every pattern handler). * * @param {...NestInterceptor} interceptors */ useGlobalInterceptors(...interceptors: NestInterceptor[]): this; /** - * Registers guards as global guards (will be used within every message pattern handler) + * Registers global guards (will be used for every pattern handler). * * @param {...CanActivate} guards */ useGlobalGuards(...guards: CanActivate[]): this; /** - * Terminates the application + * Terminates the application. * * @returns {Promise} */ close(): Promise; + + /** + * Returns an observable that emits status changes. + * + * @returns {Observable} + */ + status: Observable; + + /** + * Registers an event listener for the given event. + * @param event Event name + * @param callback Callback to be executed when the event is emitted + */ + on< + EventsMap extends Record = Record, + EventKey extends keyof EventsMap = keyof EventsMap, + EventCallback extends EventsMap[EventKey] = EventsMap[EventKey], + >( + event: EventKey, + callback: EventCallback, + ): void; + + /** + * Returns an instance of the underlying server/broker instance, + * or a group of servers if there are more than one. + */ + unwrap(): T; } diff --git a/packages/microservices/client/client-grpc.ts b/packages/microservices/client/client-grpc.ts index d567f253b5d..8d502f21af4 100644 --- a/packages/microservices/client/client-grpc.ts +++ b/packages/microservices/client/client-grpc.ts @@ -6,23 +6,42 @@ import { GRPC_DEFAULT_PROTO_LOADER, GRPC_DEFAULT_URL } from '../constants'; import { InvalidGrpcPackageException } from '../errors/invalid-grpc-package.exception'; import { InvalidGrpcServiceException } from '../errors/invalid-grpc-service.exception'; import { InvalidProtoDefinitionException } from '../errors/invalid-proto-definition.exception'; -import { ClientGrpc, GrpcOptions } from '../interfaces'; -import { ClientProxy } from './client-proxy'; -import { GRPC_CANCELLED } from './constants'; import { ChannelOptions } from '../external/grpc-options.interface'; import { getGrpcPackageDefinition } from '../helpers'; +import { ClientGrpc, GrpcOptions } from '../interfaces'; +import { ClientProxy } from './client-proxy'; -let grpcPackage: any = {}; -let grpcProtoLoaderPackage: any = {}; +const GRPC_CANCELLED = 'Cancelled'; + +// To enable type safety for gRPC. This cant be uncommented by default +// because it would require the user to install the @grpc/grpc-js package even if they dont use gRPC +// Otherwise, TypeScript would fail to compile the code. +// +// type GrpcClient = import('@grpc/grpc-js').Client; +// let grpcPackage = {} as typeof import('@grpc/grpc-js'); +// let grpcProtoLoaderPackage = {} as typeof import('@grpc/proto-loader'); + +type GrpcClient = any; +let grpcPackage = {} as any; +let grpcProtoLoaderPackage = {} as any; /** * @publicApi */ -export class ClientGrpcProxy extends ClientProxy implements ClientGrpc { +export class ClientGrpcProxy + extends ClientProxy + implements ClientGrpc +{ protected readonly logger = new Logger(ClientProxy.name); protected readonly clients = new Map(); protected readonly url: string; - protected grpcClients = []; + protected grpcClients: GrpcClient[] = []; + + get status(): never { + throw new Error( + 'The "status" attribute is not supported by the gRPC transport', + ); + } constructor(protected readonly options: GrpcOptions['options']) { super(); @@ -367,4 +386,15 @@ export class ClientGrpcProxy extends ClientProxy implements ClientGrpc { 'Method is not supported in gRPC mode. Use ClientGrpc instead (learn more in the documentation).', ); } + + public on( + event: EventKey, + callback: EventCallback, + ) { + throw new Error('Method is not supported in gRPC mode.'); + } + + public unwrap(): T { + throw new Error('Method is not supported in gRPC mode.'); + } } diff --git a/packages/microservices/client/client-kafka.ts b/packages/microservices/client/client-kafka.ts index e9969ff1a7e..7fe912f13f9 100644 --- a/packages/microservices/client/client-kafka.ts +++ b/packages/microservices/client/client-kafka.ts @@ -9,6 +9,7 @@ import { import { KafkaResponseDeserializer } from '../deserializers/kafka-response.deserializer'; import { KafkaHeaders } from '../enums'; import { InvalidKafkaClientTopicException } from '../errors/invalid-kafka-client-topic.exception'; +import { KafkaStatus } from '../events'; import { BrokersFunction, Consumer, @@ -27,7 +28,9 @@ import { KafkaReplyPartitionAssigner, } from '../helpers'; import { + ClientKafkaProxy, KafkaOptions, + MsPattern, OutgoingEvent, ReadPacket, WritePacket, @@ -43,11 +46,12 @@ let kafkaPackage: any = {}; /** * @publicApi */ -export class ClientKafka extends ClientProxy { +export class ClientKafka + extends ClientProxy + implements ClientKafkaProxy +{ protected logger = new Logger(ClientKafka.name); protected client: Kafka | null = null; - protected consumer: Consumer | null = null; - protected producer: Producer | null = null; protected parser: KafkaParser | null = null; protected initialized: Promise | null = null; protected responsePatterns: string[] = []; @@ -56,6 +60,26 @@ export class ClientKafka extends ClientProxy { protected clientId: string; protected groupId: string; protected producerOnlyMode: boolean; + protected _consumer: Consumer | null = null; + protected _producer: Producer | null = null; + + get consumer(): Consumer { + if (!this._consumer) { + throw new Error( + 'No consumer initialized. Please, call the "connect" method first.', + ); + } + return this._consumer; + } + + get producer(): Producer { + if (!this._consumer) { + throw new Error( + 'No producer initialized. Please, call the "connect" method first.', + ); + } + return this._producer; + } constructor(protected readonly options: KafkaOptions['options']) { super(); @@ -95,28 +119,27 @@ export class ClientKafka extends ClientProxy { this.initializeDeserializer(options); } - public subscribeToResponseOf(pattern: any): void { - const request = this.normalizePattern(pattern); + public subscribeToResponseOf(pattern: unknown): void { + const request = this.normalizePattern(pattern as MsPattern); this.responsePatterns.push(this.getResponsePatternName(request)); } public async close(): Promise { - this.producer && (await this.producer.disconnect()); - this.consumer && (await this.consumer.disconnect()); - this.producer = null; - this.consumer = null; + this._producer && (await this._producer.disconnect()); + this._consumer && (await this._consumer.disconnect()); + this._producer = null; + this._consumer = null; this.initialized = null; this.client = null; } public async connect(): Promise { if (this.initialized) { - return this.initialized.then(() => this.producer); + return this.initialized.then(() => this._producer); } this.initialized = new Promise(async (resolve, reject) => { try { this.client = this.createClient(); - if (!this.producerOnlyMode) { const partitionAssigners = [ ( @@ -136,42 +159,45 @@ export class ClientKafka extends ClientProxy { }, ); - this.consumer = this.client.consumer(consumerOptions); - // set member assignments on join and rebalance - this.consumer.on( - this.consumer.events.GROUP_JOIN, + this._consumer = this.client.consumer(consumerOptions); + this.registerConsumerEventListeners(); + + // Set member assignments on join and rebalance + this._consumer.on( + this._consumer.events.GROUP_JOIN, this.setConsumerAssignments.bind(this), ); - await this.consumer.connect(); + await this._consumer.connect(); await this.bindTopics(); } - this.producer = this.client.producer(this.options.producer || {}); - await this.producer.connect(); + this._producer = this.client.producer(this.options.producer || {}); + this.registerProducerEventListeners(); + await this._producer.connect(); resolve(); } catch (err) { reject(err); } }); - return this.initialized.then(() => this.producer); + return this.initialized.then(() => this._producer); } public async bindTopics(): Promise { - if (!this.consumer) { + if (!this._consumer) { throw Error('No consumer initialized'); } const consumerSubscribeOptions = this.options.subscribe || {}; if (this.responsePatterns.length > 0) { - await this.consumer.subscribe({ + await this._consumer.subscribe({ ...consumerSubscribeOptions, topics: this.responsePatterns, }); } - await this.consumer.run( + await this._consumer.run( Object.assign(this.options.run || {}, { eachMessage: this.createResponseCallback(), }), @@ -223,6 +249,52 @@ export class ClientKafka extends ClientProxy { return this.consumerAssignments; } + public commitOffsets( + topicPartitions: TopicPartitionOffsetAndMetadata[], + ): Promise { + if (this._consumer) { + return this._consumer.commitOffsets(topicPartitions); + } else { + throw new Error('No consumer initialized'); + } + } + + public unwrap(): T { + if (!this.client) { + throw new Error( + 'Not initialized. Please call the "connect" method first.', + ); + } + return this.client as T; + } + + protected registerConsumerEventListeners() { + this._consumer.on(this._consumer.events.CONNECT, () => + this._status$.next(KafkaStatus.CONNECTED), + ); + this._consumer.on(this._consumer.events.DISCONNECT, () => + this._status$.next(KafkaStatus.DISCONNECTED), + ); + this._consumer.on(this._consumer.events.REBALANCING, () => + this._status$.next(KafkaStatus.REBALANCING), + ); + this._consumer.on(this._consumer.events.STOP, () => + this._status$.next(KafkaStatus.STOPPED), + ); + this.consumer.on(this._consumer.events.CRASH, () => + this._status$.next(KafkaStatus.CRASHED), + ); + } + + protected registerProducerEventListeners() { + this._producer.on(this._producer.events.CONNECT, () => + this._status$.next(KafkaStatus.CONNECTED), + ); + this._producer.on(this._producer.events.DISCONNECT, () => + this._status$.next(KafkaStatus.DISCONNECTED), + ); + } + protected async dispatchEvent(packet: OutgoingEvent): Promise { const pattern = this.normalizePattern(packet.pattern); const outgoingEvent = await this.serializer.serialize(packet.data, { @@ -236,7 +308,7 @@ export class ClientKafka extends ClientProxy { this.options.send || {}, ); - return this.producer.send(message); + return this._producer.send(message); } protected getReplyTopicPartition(topic: string): string { @@ -245,7 +317,7 @@ export class ClientKafka extends ClientProxy { throw new InvalidKafkaClientTopicException(topic); } - // get the minimum partition + // Get the minimum partition return minimumPartition.toString(); } @@ -282,7 +354,7 @@ export class ClientKafka extends ClientProxy { this.options.send || {}, ); - return this.producer.send(message); + return this._producer.send(message); }) .catch(err => errorCallback(err)); @@ -299,7 +371,7 @@ export class ClientKafka extends ClientProxy { protected setConsumerAssignments(data: ConsumerGroupJoinEvent): void { const consumerAssignments: { [key: string]: number } = {}; - // only need to set the minimum + // Only need to set the minimum Object.keys(data.payload.memberAssignment).forEach(topic => { const memberPartitions = data.payload.memberAssignment[topic]; @@ -321,13 +393,10 @@ export class ClientKafka extends ClientProxy { (options && options.deserializer) || new KafkaResponseDeserializer(); } - public commitOffsets( - topicPartitions: TopicPartitionOffsetAndMetadata[], - ): Promise { - if (this.consumer) { - return this.consumer.commitOffsets(topicPartitions); - } else { - throw new Error('No consumer initialized'); - } + public on< + EventKey extends string | number | symbol = string | number | symbol, + EventCallback = any, + >(event: EventKey, callback: EventCallback) { + throw new Error('Method is not supported for Kafka client'); } } diff --git a/packages/microservices/client/client-mqtt.ts b/packages/microservices/client/client-mqtt.ts index c2e7c6cf394..8ed3a75103a 100644 --- a/packages/microservices/client/client-mqtt.ts +++ b/packages/microservices/client/client-mqtt.ts @@ -2,14 +2,8 @@ import { Logger } from '@nestjs/common/services/logger.service'; import { loadPackage } from '@nestjs/common/utils/load-package.util'; import { EmptyError, fromEvent, lastValueFrom, merge, Observable } from 'rxjs'; import { first, map, share, tap } from 'rxjs/operators'; -import { - CLOSE_EVENT, - ECONNREFUSED, - ERROR_EVENT, - MESSAGE_EVENT, - MQTT_DEFAULT_URL, -} from '../constants'; -import { MqttClient } from '../external/mqtt-client.interface'; +import { ECONNREFUSED, MQTT_DEFAULT_URL } from '../constants'; +import { MqttEvents, MqttEventsMap, MqttStatus } from '../events/mqtt.events'; import { MqttOptions, ReadPacket, WritePacket } from '../interfaces'; import { MqttRecord, @@ -20,19 +14,32 @@ import { ClientProxy } from './client-proxy'; let mqttPackage: any = {}; +// To enable type safety for MQTT. This cant be uncommented by default +// because it would require the user to install the mqtt package even if they dont use MQTT +// Otherwise, TypeScript would fail to compile the code. +// +type MqttClient = import('mqtt').MqttClient; +// type MqttClient = any; + /** * @publicApi */ -export class ClientMqtt extends ClientProxy { +export class ClientMqtt extends ClientProxy { protected readonly logger = new Logger(ClientProxy.name); protected readonly subscriptionsCount = new Map(); protected readonly url: string; protected mqttClient: MqttClient; - protected connection: Promise; + protected connectionPromise: Promise; + protected isInitialConnection = false; + protected isReconnecting = false; + protected pendingEventListeners: Array<{ + event: keyof MqttEvents; + callback: MqttEvents[keyof MqttEvents]; + }> = []; constructor(protected readonly options: MqttOptions['options']) { super(); - this.url = this.getOptionsProp(this.options, 'url') || MQTT_DEFAULT_URL; + this.url = this.getOptionsProp(this.options, 'url') ?? MQTT_DEFAULT_URL; mqttPackage = loadPackage('mqtt', ClientMqtt.name, () => require('mqtt')); @@ -51,38 +58,49 @@ export class ClientMqtt extends ClientProxy { public close() { this.mqttClient && this.mqttClient.end(); this.mqttClient = null; - this.connection = null; + this.connectionPromise = null; + this.pendingEventListeners = []; } public connect(): Promise { if (this.mqttClient) { - return this.connection; + return this.connectionPromise; } this.mqttClient = this.createClient(); - this.handleError(this.mqttClient); + this.registerErrorListener(this.mqttClient); + this.registerOfflineListener(this.mqttClient); + this.registerReconnectListener(this.mqttClient); + this.registerConnectListener(this.mqttClient); + this.registerDisconnectListener(this.mqttClient); + this.registerCloseListener(this.mqttClient); + + this.pendingEventListeners.forEach(({ event, callback }) => + this.mqttClient.on(event, callback), + ); + this.pendingEventListeners = []; const connect$ = this.connect$(this.mqttClient); - this.connection = lastValueFrom( - this.mergeCloseEvent(this.mqttClient, connect$).pipe( - tap(() => - this.mqttClient.on(MESSAGE_EVENT, this.createResponseCallback()), - ), - share(), - ), + this.connectionPromise = lastValueFrom( + this.mergeCloseEvent(this.mqttClient, connect$).pipe(share()), ).catch(err => { if (err instanceof EmptyError) { return; } throw err; }); - return this.connection; + return this.connectionPromise; } public mergeCloseEvent( instance: MqttClient, source$: Observable, ): Observable { - const close$ = fromEvent(instance, CLOSE_EVENT).pipe( + const close$ = fromEvent(instance, MqttEventsMap.CLOSE).pipe( + tap({ + next: () => { + this._status$.next(MqttStatus.CLOSED); + }, + }), map((err: any) => { throw err; }), @@ -94,13 +112,81 @@ export class ClientMqtt extends ClientProxy { return mqttPackage.connect(this.url, this.options as MqttOptions); } - public handleError(client: MqttClient) { - client.addListener( - ERROR_EVENT, + public registerErrorListener(client: MqttClient) { + client.on( + MqttEventsMap.ERROR, (err: any) => err.code !== ECONNREFUSED && this.logger.error(err), ); } + public registerOfflineListener(client: MqttClient) { + client.on(MqttEventsMap.OFFLINE, () => { + this.connectionPromise = Promise.reject( + 'Error: Connection lost. Trying to reconnect...', + ); + + // Prevent unhandled rejections + this.connectionPromise.catch(() => {}); + this.logger.error('MQTT broker went offline.'); + }); + } + + public registerReconnectListener(client: MqttClient) { + client.on(MqttEventsMap.RECONNECT, () => { + this.isReconnecting = true; + this._status$.next(MqttStatus.RECONNECTING); + + this.logger.log('MQTT connection lost. Trying to reconnect...'); + }); + } + + public registerDisconnectListener(client: MqttClient) { + client.on(MqttEventsMap.DISCONNECT, () => { + this._status$.next(MqttStatus.DISCONNECTED); + }); + } + + public registerCloseListener(client: MqttClient) { + client.on(MqttEventsMap.CLOSE, () => { + this._status$.next(MqttStatus.CLOSED); + }); + } + + public registerConnectListener(client: MqttClient) { + client.on(MqttEventsMap.CONNECT, () => { + this.isReconnecting = false; + this._status$.next(MqttStatus.CONNECTED); + + this.logger.log('Connected to MQTT broker'); + this.connectionPromise = Promise.resolve(); + + if (!this.isInitialConnection) { + this.isInitialConnection = true; + client.on('message', this.createResponseCallback()); + } + }); + } + + public on< + EventKey extends keyof MqttEvents = keyof MqttEvents, + EventCallback extends MqttEvents[EventKey] = MqttEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.mqttClient) { + this.mqttClient.on(event, callback as any); + } else { + this.pendingEventListeners.push({ event, callback }); + } + } + + public unwrap(): T { + if (!this.mqttClient) { + throw new Error( + 'Not initialized. Please call the "connect" method first.', + ); + } + return this.mqttClient as T; + } + public createResponseCallback(): (channel: string, buffer: Buffer) => any { return async (channel: string, buffer: Buffer) => { const packet = JSON.parse(buffer.toString()); diff --git a/packages/microservices/client/client-nats.ts b/packages/microservices/client/client-nats.ts index 384fa4e8ed4..2a9696e8a4e 100644 --- a/packages/microservices/client/client-nats.ts +++ b/packages/microservices/client/client-nats.ts @@ -1,10 +1,11 @@ import { Logger } from '@nestjs/common/services/logger.service'; import { loadPackage } from '@nestjs/common/utils/load-package.util'; import { isObject } from '@nestjs/common/utils/shared.utils'; +import { EventEmitter } from 'stream'; import { NATS_DEFAULT_URL } from '../constants'; import { NatsResponseJSONDeserializer } from '../deserializers/nats-response-json.deserializer'; import { EmptyResponseException } from '../errors/empty-response.exception'; -import { Client, NatsMsg } from '../external/nats-client.interface'; +import { NatsEvents, NatsEventsMap, NatsStatus } from '../events/nats.events'; import { NatsOptions, PacketId, ReadPacket, WritePacket } from '../interfaces'; import { NatsRecord } from '../record-builders'; import { NatsRecordSerializer } from '../serializers/nats-record.serializer'; @@ -12,13 +13,27 @@ import { ClientProxy } from './client-proxy'; let natsPackage = {} as any; +// To enable type safety for Nats. This cant be uncommented by default +// because it would require the user to install the nats package even if they dont use Nats +// Otherwise, TypeScript would fail to compile the code. +// +// type Client = import('nats').NatsConnection; +// type NatsMsg = import('nats').Msg; + +type Client = any; +type NatsMsg = any; + /** * @publicApi */ -export class ClientNats extends ClientProxy { +export class ClientNats extends ClientProxy { protected readonly logger = new Logger(ClientNats.name); + protected natsClient: Client; - protected clientConnectionPromise: Promise; + protected connectionPromise: Promise; + protected statusEventEmitter = new EventEmitter<{ + [key in keyof NatsEvents]: Parameters; + }>(); constructor(protected readonly options: NatsOptions['options']) { super(); @@ -30,22 +45,29 @@ export class ClientNats extends ClientProxy { public async close() { await this.natsClient?.close(); + this.statusEventEmitter.removeAllListeners(); + this.natsClient = null; - this.clientConnectionPromise = null; + this.connectionPromise = null; } public async connect(): Promise { - if (this.clientConnectionPromise) { - return this.clientConnectionPromise; + if (this.connectionPromise) { + return this.connectionPromise; } - this.clientConnectionPromise = this.createClient(); - this.natsClient = await this.clientConnectionPromise; + this.connectionPromise = this.createClient(); + this.natsClient = await this.connectionPromise.catch(err => { + this.connectionPromise = null; + throw err; + }); + + this._status$.next(NatsStatus.CONNECTED); this.handleStatusUpdates(this.natsClient); return this.natsClient; } public createClient(): Promise { - const options: any = this.options || ({} as NatsOptions); + const options = this.options || ({} as NatsOptions); return natsPackage.connect({ servers: NATS_DEFAULT_URL, ...options, @@ -61,10 +83,44 @@ export class ClientNats extends ClientProxy { switch (status.type) { case 'error': + this.logger.error( + `NatsError: type: "${status.type}", data: "${data}".`, + ); + break; + case 'disconnect': + this.connectionPromise = Promise.reject( + 'Error: Connection lost. Trying to reconnect...', + ); + // Prevent unhandled promise rejection + this.connectionPromise.catch(() => {}); + this.logger.error( `NatsError: type: "${status.type}", data: "${data}".`, ); + + this._status$.next(NatsStatus.DISCONNECTED); + this.statusEventEmitter.emit( + NatsEventsMap.DISCONNECT, + status.data as string, + ); + break; + + case 'reconnecting': + this._status$.next(NatsStatus.RECONNECTING); + break; + + case 'reconnect': + this.connectionPromise = Promise.resolve(client); + this.logger.log( + `NatsStatus: type: "${status.type}", data: "${data}".`, + ); + + this._status$.next(NatsStatus.CONNECTED); + this.statusEventEmitter.emit( + NatsEventsMap.RECONNECT, + status.data as string, + ); break; case 'pingTimer': @@ -75,6 +131,13 @@ export class ClientNats extends ClientProxy { } break; + case 'update': + this.logger.log( + `NatsStatus: type: "${status.type}", data: "${data}".`, + ); + this.statusEventEmitter.emit(NatsEventsMap.UPDATE, status.data); + break; + default: this.logger.log( `NatsStatus: type: "${status.type}", data: "${data}".`, @@ -84,6 +147,22 @@ export class ClientNats extends ClientProxy { } } + public on< + EventKey extends keyof NatsEvents = keyof NatsEvents, + EventCallback extends NatsEvents[EventKey] = NatsEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + this.statusEventEmitter.on(event, callback as any); + } + + public unwrap(): T { + if (!this.natsClient) { + throw new Error( + 'Not initialized. Please call the "connect" method first.', + ); + } + return this.natsClient as T; + } + public createSubscriptionHandler( packet: ReadPacket & PacketId, callback: (packet: WritePacket) => any, diff --git a/packages/microservices/client/client-proxy-factory.ts b/packages/microservices/client/client-proxy-factory.ts index a1920099058..9d17ab83c5d 100644 --- a/packages/microservices/client/client-proxy-factory.ts +++ b/packages/microservices/client/client-proxy-factory.ts @@ -1,10 +1,10 @@ import { Transport } from '../enums/transport.enum'; +import { ClientKafkaProxy } from '../interfaces'; import { ClientOptions, CustomClientOptions, TcpClientOptions, } from '../interfaces/client-metadata.interface'; -import { Closeable } from '../interfaces/closeable.interface'; import { GrpcOptions, KafkaOptions, @@ -23,7 +23,7 @@ import { ClientRMQ } from './client-rmq'; import { ClientTCP } from './client-tcp'; export interface IClientProxyFactory { - create(clientOptions: ClientOptions): ClientProxy & Closeable; + create(clientOptions: ClientOptions): ClientProxy; } /** @@ -33,33 +33,38 @@ export class ClientProxyFactory { public static create( clientOptions: { transport: Transport.GRPC } & ClientOptions, ): ClientGrpcProxy; - public static create(clientOptions: ClientOptions): ClientProxy & Closeable; public static create( - clientOptions: CustomClientOptions, - ): ClientProxy & Closeable; + clientOptions: { transport: Transport.KAFKA } & ClientOptions, + ): ClientKafkaProxy; + public static create(clientOptions: ClientOptions): ClientProxy; + public static create(clientOptions: CustomClientOptions): ClientProxy; public static create( clientOptions: ClientOptions | CustomClientOptions, - ): ClientProxy & Closeable { + ): ClientProxy | ClientGrpcProxy | ClientKafkaProxy { if (this.isCustomClientOptions(clientOptions)) { const { customClass, options } = clientOptions; return new customClass(options); } - const { transport, options } = clientOptions || {}; + const { transport, options = {} } = clientOptions ?? { options: {} }; switch (transport) { case Transport.REDIS: - return new ClientRedis(options as RedisOptions['options']); + return new ClientRedis( + options as RedisOptions['options'], + ) as ClientProxy; case Transport.NATS: - return new ClientNats(options as NatsOptions['options']); + return new ClientNats(options as NatsOptions['options']) as ClientProxy; case Transport.MQTT: - return new ClientMqtt(options as MqttOptions['options']); + return new ClientMqtt(options as MqttOptions['options']) as ClientProxy; case Transport.GRPC: return new ClientGrpcProxy(options as GrpcOptions['options']); case Transport.RMQ: - return new ClientRMQ(options as RmqOptions['options']); + return new ClientRMQ(options as RmqOptions['options']) as ClientProxy; case Transport.KAFKA: return new ClientKafka(options as KafkaOptions['options']); default: - return new ClientTCP(options as TcpClientOptions['options']); + return new ClientTCP( + options as TcpClientOptions['options'], + ) as ClientProxy; } } diff --git a/packages/microservices/client/client-proxy.ts b/packages/microservices/client/client-proxy.ts index 3950cf3dbf2..b937fe98329 100644 --- a/packages/microservices/client/client-proxy.ts +++ b/packages/microservices/client/client-proxy.ts @@ -1,17 +1,17 @@ import { randomStringGenerator } from '@nestjs/common/utils/random-string-generator.util'; import { isNil } from '@nestjs/common/utils/shared.utils'; import { + throwError as _throw, connectable, defer, fromEvent, merge, Observable, Observer, + ReplaySubject, Subject, - throwError as _throw, } from 'rxjs'; -import { map, mergeMap, take } from 'rxjs/operators'; -import { CONNECT_EVENT, ERROR_EVENT } from '../constants'; +import { distinctUntilChanged, map, mergeMap, take } from 'rxjs/operators'; import { IncomingResponseDeserializer } from '../deserializers/incoming-response.deserializer'; import { InvalidMessageException } from '../errors/invalid-message.exception'; import { @@ -32,14 +32,57 @@ import { ProducerSerializer } from '../interfaces/serializer.interface'; import { IdentitySerializer } from '../serializers/identity.serializer'; import { transformPatternToRoute } from '../utils'; -export abstract class ClientProxy { - public abstract connect(): Promise; - public abstract close(): any; - +/** + * @publicApi + */ +export abstract class ClientProxy< + EventsMap extends Record = Record, + Status extends string = string, +> { protected routingMap = new Map(); protected serializer: ProducerSerializer; protected deserializer: ProducerDeserializer; + protected _status$ = new ReplaySubject(1); + + /** + * Returns an observable that emits status changes. + */ + public get status(): Observable { + return this._status$.asObservable().pipe(distinctUntilChanged()); + } + + /** + * Establishes the connection to the underlying server/broker. + */ + public abstract connect(): Promise; + /** + * Closes the underlying connection to the server/broker. + */ + public abstract close(): any; + /** + * Registers an event listener for the given event. + * @param event Event name + * @param callback Callback to be executed when the event is emitted + */ + public on< + EventKey extends keyof EventsMap = keyof EventsMap, + EventCallback extends EventsMap[EventKey] = EventsMap[EventKey], + >(event: EventKey, callback: EventCallback) { + throw new Error('Method not implemented.'); + } + /** + * Returns an instance of the underlying server/broker instance, + * or a group of servers if there are more than one. + */ + public abstract unwrap(): T; + /** + * Send a message to the server/broker. + * Used for message-driven communication style between microservices. + * @param pattern Pattern to identify the message + * @param data Data to be sent + * @returns Observable with the result + */ public send( pattern: any, data: TInput, @@ -58,6 +101,13 @@ export abstract class ClientProxy { ); } + /** + * Emits an event to the server/broker. + * Used for event-driven communication style between microservices. + * @param pattern Pattern to identify the event + * @param data Data to be sent + * @returns Observable that completes when the event is successfully emitted + */ public emit( pattern: any, data: TInput, @@ -114,8 +164,8 @@ export abstract class ClientProxy { protected connect$( instance: any, - errorEvent = ERROR_EVENT, - connectEvent = CONNECT_EVENT, + errorEvent = 'error', + connectEvent = 'connect', ): Observable { const error$ = fromEvent(instance, errorEvent).pipe( map((err: any) => { diff --git a/packages/microservices/client/client-redis.ts b/packages/microservices/client/client-redis.ts index 9148a450ff1..00542d05120 100644 --- a/packages/microservices/client/client-redis.ts +++ b/packages/microservices/client/client-redis.ts @@ -1,14 +1,19 @@ import { Logger } from '@nestjs/common/services/logger.service'; import { loadPackage } from '@nestjs/common/utils/load-package.util'; +import { REDIS_DEFAULT_HOST, REDIS_DEFAULT_PORT } from '../constants'; import { - ERROR_EVENT, - MESSAGE_EVENT, - REDIS_DEFAULT_HOST, - REDIS_DEFAULT_PORT, -} from '../constants'; + RedisEvents, + RedisEventsMap, + RedisStatus, +} from '../events/redis.events'; import { ReadPacket, RedisOptions, WritePacket } from '../interfaces'; import { ClientProxy } from './client-proxy'; +// To enable type safety for Redis. This cant be uncommented by default +// because it would require the user to install the ioredis package even if they dont use Redis +// Otherwise, TypeScript would fail to compile the code. +// +// type Redis = import('ioredis').Redis; type Redis = any; let redisPackage = {} as any; @@ -16,13 +21,18 @@ let redisPackage = {} as any; /** * @publicApi */ -export class ClientRedis extends ClientProxy { +export class ClientRedis extends ClientProxy { protected readonly logger = new Logger(ClientProxy.name); protected readonly subscriptionsCount = new Map(); protected pubClient: Redis; protected subClient: Redis; - protected connection: Promise; - protected isExplicitlyTerminated = false; + protected connectionPromise: Promise; + protected isManuallyClosed = false; + protected wasInitialConnectionSuccessful = false; + protected pendingEventListeners: Array<{ + event: keyof RedisEvents; + callback: RedisEvents[keyof RedisEvents]; + }> = []; constructor(protected readonly options: RedisOptions['options']) { super(); @@ -47,26 +57,37 @@ export class ClientRedis extends ClientProxy { this.pubClient && this.pubClient.quit(); this.subClient && this.subClient.quit(); this.pubClient = this.subClient = null; - this.isExplicitlyTerminated = true; + this.isManuallyClosed = true; + this.pendingEventListeners = []; } public async connect(): Promise { if (this.pubClient && this.subClient) { - return this.connection; + return this.connectionPromise; } this.pubClient = this.createClient(); this.subClient = this.createClient(); - this.handleError(this.pubClient); - this.handleError(this.subClient); - this.connection = Promise.all([ + [this.pubClient, this.subClient].forEach((client, index) => { + const type = index === 0 ? 'pub' : 'sub'; + this.registerErrorListener(client); + this.registerReconnectListener(client); + this.registerReadyListener(client); + this.registerEndListener(client); + this.pendingEventListeners.forEach(({ event, callback }) => + client.on(event, (...args: [any]) => callback(type, ...args)), + ); + }); + this.pendingEventListeners = []; + + this.connectionPromise = Promise.all([ this.subClient.connect(), this.pubClient.connect(), ]); - await this.connection; + await this.connectionPromise; - this.subClient.on(MESSAGE_EVENT, this.createResponseCallback()); - return this.connection; + this.subClient.on('message', this.createResponseCallback()); + return this.connectionPromise; } public createClient(): Redis { @@ -78,8 +99,76 @@ export class ClientRedis extends ClientProxy { }); } - public handleError(client: Redis) { - client.addListener(ERROR_EVENT, (err: any) => this.logger.error(err)); + public registerErrorListener(client: Redis) { + client.addListener(RedisEventsMap.ERROR, (err: any) => + this.logger.error(err), + ); + } + + public registerReconnectListener(client: { + on: (event: string, fn: () => void) => void; + }) { + client.on(RedisEventsMap.RECONNECTING, () => { + if (this.isManuallyClosed) { + return; + } + + this.connectionPromise = Promise.reject( + 'Error: Connection lost. Trying to reconnect...', + ); + + // Prevent unhandled rejections + this.connectionPromise.catch(() => {}); + + this._status$.next(RedisStatus.RECONNECTING); + + if (this.wasInitialConnectionSuccessful) { + this.logger.log('Reconnecting to Redis...'); + } + }); + } + + public registerReadyListener(client: { + on: (event: string, fn: () => void) => void; + }) { + client.on(RedisEventsMap.READY, () => { + this.connectionPromise = Promise.resolve(); + this._status$.next(RedisStatus.CONNECTED); + + this.logger.log('Connected to Redis. Subscribing to channels...'); + + if (!this.wasInitialConnectionSuccessful) { + this.wasInitialConnectionSuccessful = true; + this.subClient.on('message', this.createResponseCallback()); + } + }); + } + + public registerEndListener(client: { + on: (event: string, fn: () => void) => void; + }) { + client.on('end', () => { + if (this.isManuallyClosed) { + return; + } + this._status$.next(RedisStatus.DISCONNECTED); + + if (this.getOptionsProp(this.options, 'retryAttempts') === undefined) { + // When retryAttempts is not specified, the connection will not be re-established + this.logger.error('Disconnected from Redis.'); + + // Clean up client instances and just recreate them when connect is called + this.pubClient = this.subClient = null; + } else { + this.logger.error('Disconnected from Redis.'); + this.connectionPromise = Promise.reject( + 'Error: Connection lost. Trying to reconnect...', + ); + + // Prevent unhandled rejections + this.connectionPromise.catch(() => {}); + } + }); } public getClientOptions(): Partial { @@ -91,18 +180,42 @@ export class ClientRedis extends ClientProxy { }; } + public on< + EventKey extends keyof RedisEvents = keyof RedisEvents, + EventCallback extends RedisEvents[EventKey] = RedisEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.subClient && this.pubClient) { + this.subClient.on(event, (...args: [any]) => callback('sub', ...args)); + this.pubClient.on(event, (...args: [any]) => callback('pub', ...args)); + } else { + this.pendingEventListeners.push({ event, callback }); + } + } + + public unwrap(): T { + if (!this.pubClient || !this.subClient) { + throw new Error( + 'Not initialized. Please call the "connect" method first.', + ); + } + return [this.pubClient, this.subClient] as T; + } + public createRetryStrategy(times: number): undefined | number { - if (this.isExplicitlyTerminated) { + if (this.isManuallyClosed) { return undefined; } - if ( - !this.getOptionsProp(this.options, 'retryAttempts') || - times > this.getOptionsProp(this.options, 'retryAttempts') - ) { + if (!this.getOptionsProp(this.options, 'retryAttempts')) { + this.logger.error( + 'Redis connection closed and retry attempts not specified', + ); + return; + } + if (times > this.getOptionsProp(this.options, 'retryAttempts')) { this.logger.error('Retry time exhausted'); return; } - return this.getOptionsProp(this.options, 'retryDelay') || 0; + return this.getOptionsProp(this.options, 'retryDelay') ?? 5000; } public createResponseCallback(): ( diff --git a/packages/microservices/client/client-rmq.ts b/packages/microservices/client/client-rmq.ts index 5c8824f57af..4e0b0401039 100644 --- a/packages/microservices/client/client-rmq.ts +++ b/packages/microservices/client/client-rmq.ts @@ -13,11 +13,7 @@ import { } from 'rxjs'; import { first, map, retryWhen, scan, skip, switchMap } from 'rxjs/operators'; import { - CONNECT_EVENT, - CONNECT_FAILED_EVENT, - DISCONNECT_EVENT, DISCONNECTED_RMQ_MESSAGE, - ERROR_EVENT, RQM_DEFAULT_IS_GLOBAL_PREFETCH_COUNT, RQM_DEFAULT_NO_ASSERT, RQM_DEFAULT_NOACK, @@ -27,47 +23,53 @@ import { RQM_DEFAULT_QUEUE_OPTIONS, RQM_DEFAULT_URL, } from '../constants'; -import { RmqUrl } from '../external/rmq-url.interface'; +import { RmqEvents, RmqEventsMap, RmqStatus } from '../events/rmq.events'; import { ReadPacket, RmqOptions, WritePacket } from '../interfaces'; import { RmqRecord } from '../record-builders'; import { RmqRecordSerializer } from '../serializers/rmq-record.serializer'; import { ClientProxy } from './client-proxy'; -// import type { -// AmqpConnectionManager, -// ChannelWrapper, -// } from 'amqp-connection-manager'; -// import type { Channel, ConsumeMessage } from 'amqplib'; +// To enable type safety for RMQ. This cant be uncommented by default +// because it would require the user to install the amqplib package even if they dont use RabbitMQ +// Otherwise, TypeScript would fail to compile the code. +// +// type AmqpConnectionManager = +// import('amqp-connection-manager').AmqpConnectionManager; +// type ChannelWrapper = import('amqp-connection-manager').ChannelWrapper; +// type Channel = import('amqplib').Channel; +// type ConsumeMessage = import('amqplib').ConsumeMessage; type Channel = any; type ChannelWrapper = any; type ConsumeMessage = any; type AmqpConnectionManager = any; -let rmqPackage: any = {}; +let rmqPackage = {} as any; // typeof import('amqp-connection-manager'); const REPLY_QUEUE = 'amq.rabbitmq.reply-to'; /** * @publicApi */ -export class ClientRMQ extends ClientProxy { +export class ClientRMQ extends ClientProxy { protected readonly logger = new Logger(ClientProxy.name); protected connection$: ReplaySubject; - protected connection: Promise; + protected connectionPromise: Promise; protected client: AmqpConnectionManager = null; protected channel: ChannelWrapper = null; - protected urls: string[] | RmqUrl[]; + protected pendingEventListeners: Array<{ + event: keyof RmqEvents; + callback: RmqEvents[keyof RmqEvents]; + }> = []; + protected isInitialConnect = true; + protected responseEmitter: EventEmitter; protected queue: string; protected queueOptions: Record; - protected responseEmitter: EventEmitter; protected replyQueue: string; - protected persistent: boolean; protected noAssert: boolean; constructor(protected readonly options: RmqOptions['options']) { super(); - this.urls = this.getOptionsProp(this.options, 'urls') || [RQM_DEFAULT_URL]; this.queue = this.getOptionsProp(this.options, 'queue') || RQM_DEFAULT_QUEUE; this.queueOptions = @@ -75,8 +77,6 @@ export class ClientRMQ extends ClientProxy { RQM_DEFAULT_QUEUE_OPTIONS; this.replyQueue = this.getOptionsProp(this.options, 'replyQueue') || REPLY_QUEUE; - this.persistent = - this.getOptionsProp(this.options, 'persistent') || RQM_DEFAULT_PERSISTENT; this.noAssert = this.getOptionsProp(this.options, 'noAssert') ?? this.queueOptions.noAssert ?? @@ -96,15 +96,22 @@ export class ClientRMQ extends ClientProxy { this.client && this.client.close(); this.channel = null; this.client = null; + this.pendingEventListeners = []; } public connect(): Promise { if (this.client) { - return this.convertConnectionToPromise(); + return this.connectionPromise; } this.client = this.createClient(); - this.handleError(this.client); - this.handleDisconnectError(this.client); + + this.registerErrorListener(this.client); + this.registerDisconnectListener(this.client); + this.registerConnectListener(this.client); + this.pendingEventListeners.forEach(({ event, callback }) => + this.client.on(event, callback), + ); + this.pendingEventListeners = []; this.responseEmitter = new EventEmitter(); this.responseEmitter.setMaxListeners(0); @@ -115,13 +122,16 @@ export class ClientRMQ extends ClientProxy { connect$, ).pipe(switchMap(() => this.createChannel())); - const withReconnect$ = fromEvent(this.client, CONNECT_EVENT).pipe(skip(1)); + const withReconnect$ = fromEvent(this.client, RmqEventsMap.CONNECT).pipe( + skip(1), + ); const source$ = merge(withDisconnect$, withReconnect$); this.connection$ = new ReplaySubject(1); source$.subscribe(this.connection$); + this.connectionPromise = this.convertConnectionToPromise(); - return this.convertConnectionToPromise(); + return this.connectionPromise; } public createChannel(): Promise { @@ -135,9 +145,8 @@ export class ClientRMQ extends ClientProxy { public createClient(): AmqpConnectionManager { const socketOptions = this.getOptionsProp(this.options, 'socketOptions'); - return rmqPackage.connect(this.urls, { - connectionOptions: socketOptions?.connectionOptions, - }); + const urls = this.getOptionsProp(this.options, 'urls') || [RQM_DEFAULT_URL]; + return rmqPackage.connect(urls, socketOptions); } public mergeDisconnectEvent( @@ -150,10 +159,11 @@ export class ClientRMQ extends ClientProxy { throw err; }), ); - const disconnect$ = eventToError(DISCONNECT_EVENT); + const disconnect$ = eventToError(RmqEventsMap.DISCONNECT); const urls = this.getOptionsProp(this.options, 'urls', []); - const connectFailed$ = eventToError(CONNECT_FAILED_EVENT).pipe( + const connectFailedEventKey = 'connectFailed'; + const connectFailed$ = eventToError(connectFailedEventKey).pipe( retryWhen(e => e.pipe( scan((errorCount, error: any) => { @@ -209,31 +219,81 @@ export class ClientRMQ extends ClientProxy { ); } - public handleError(client: AmqpConnectionManager): void { - client.addListener(ERROR_EVENT, (err: any) => this.logger.error(err)); + public registerErrorListener(client: AmqpConnectionManager): void { + client.addListener(RmqEventsMap.ERROR, (err: any) => + this.logger.error(err), + ); } - public handleDisconnectError(client: AmqpConnectionManager): void { - client.addListener(DISCONNECT_EVENT, (err: any) => { + public registerDisconnectListener(client: AmqpConnectionManager): void { + client.addListener(RmqEventsMap.DISCONNECT, (err: any) => { + this._status$.next(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); }); } + private registerConnectListener(client: AmqpConnectionManager): void { + client.addListener(RmqEventsMap.CONNECT, () => { + this._status$.next(RmqStatus.CONNECTED); + this.logger.log('Successfully connected to RMQ broker'); + + if (this.isInitialConnect) { + this.isInitialConnect = false; + + if (!this.channel) { + this.connectionPromise = this.createChannel(); + } + } else { + this.connectionPromise = Promise.resolve(); + } + }); + } + + public on< + EventKey extends keyof RmqEvents = keyof RmqEvents, + EventCallback extends RmqEvents[EventKey] = RmqEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.client) { + this.client.addListener(event, callback); + } else { + this.pendingEventListeners.push({ event, callback }); + } + } + + public unwrap(): T { + if (!this.client) { + throw new Error( + 'Not initialized. Please call the "connect" method first.', + ); + } + return this.client as T; + } + public async handleMessage( packet: unknown, callback: (packet: WritePacket) => any, - ); + ): Promise; public async handleMessage( packet: unknown, options: Record, callback: (packet: WritePacket) => any, - ); + ): Promise; public async handleMessage( packet: unknown, options: Record | ((packet: WritePacket) => any), callback?: (packet: WritePacket) => any, - ) { + ): Promise { if (isFunction(options)) { callback = options as (packet: WritePacket) => any; options = undefined; @@ -289,7 +349,11 @@ export class ClientRMQ extends ClientProxy { Buffer.from(JSON.stringify(serializedPacket)), { replyTo: this.replyQueue, - persistent: this.persistent, + persistent: this.getOptionsProp( + this.options, + 'persistent', + RQM_DEFAULT_PERSISTENT, + ), ...options, headers: this.mergeHeaders(options?.headers), correlationId, @@ -314,7 +378,11 @@ export class ClientRMQ extends ClientProxy { this.queue, Buffer.from(JSON.stringify(serializedPacket)), { - persistent: this.persistent, + persistent: this.getOptionsProp( + this.options, + 'persistent', + RQM_DEFAULT_PERSISTENT, + ), ...options, headers: this.mergeHeaders(options?.headers), }, diff --git a/packages/microservices/client/client-tcp.ts b/packages/microservices/client/client-tcp.ts index e0b1ddb91cb..c410d03d65d 100644 --- a/packages/microservices/client/client-tcp.ts +++ b/packages/microservices/client/client-tcp.ts @@ -2,17 +2,10 @@ import { Logger, Type } from '@nestjs/common'; import * as net from 'net'; import { EmptyError, lastValueFrom } from 'rxjs'; import { share, tap } from 'rxjs/operators'; -import { ConnectionOptions } from 'tls'; -import { - CLOSE_EVENT, - ECONNREFUSED, - ERROR_EVENT, - MESSAGE_EVENT, - TCP_DEFAULT_HOST, - TCP_DEFAULT_PORT, -} from '../constants'; +import { ConnectionOptions, connect as tlsConnect, TLSSocket } from 'tls'; +import { ECONNREFUSED, TCP_DEFAULT_HOST, TCP_DEFAULT_PORT } from '../constants'; +import { TcpEvents, TcpEventsMap, TcpStatus } from '../events/tcp.events'; import { JsonSocket, TcpSocket } from '../helpers'; -import { connect as tlsConnect, TLSSocket } from 'tls'; import { PacketId, ReadPacket, WritePacket } from '../interfaces'; import { TcpClientOptions } from '../interfaces/client-metadata.interface'; import { ClientProxy } from './client-proxy'; @@ -20,15 +13,18 @@ import { ClientProxy } from './client-proxy'; /** * @publicApi */ -export class ClientTCP extends ClientProxy { - protected connection: Promise; - private readonly logger = new Logger(ClientTCP.name); - private readonly port: number; - private readonly host: string; - private readonly socketClass: Type; - private isConnected = false; - private socket: TcpSocket; - public tlsOptions?: ConnectionOptions; +export class ClientTCP extends ClientProxy { + protected readonly logger = new Logger(ClientTCP.name); + protected readonly port: number; + protected readonly host: string; + protected readonly socketClass: Type; + protected readonly tlsOptions?: ConnectionOptions; + protected socket: TcpSocket; + protected connectionPromise: Promise; + protected pendingEventListeners: Array<{ + event: keyof TcpEvents; + callback: TcpEvents[keyof TcpEvents]; + }> = []; constructor(options: TcpClientOptions['options']) { super(); @@ -43,16 +39,22 @@ export class ClientTCP extends ClientProxy { } public connect(): Promise { - if (this.connection) { - return this.connection; + if (this.connectionPromise) { + return this.connectionPromise; } this.socket = this.createSocket(); - this.bindEvents(this.socket); + this.registerConnectListener(this.socket); + this.registerCloseListener(this.socket); + this.registerErrorListener(this.socket); + + this.pendingEventListeners.forEach(({ event, callback }) => + this.socket.on(event, callback as any), + ); + this.pendingEventListeners = []; const source$ = this.connect$(this.socket.netSocket).pipe( tap(() => { - this.isConnected = true; - this.socket.on(MESSAGE_EVENT, (buffer: WritePacket & PacketId) => + this.socket.on('message', (buffer: WritePacket & PacketId) => this.handleResponse(buffer), ); }), @@ -63,14 +65,14 @@ export class ClientTCP extends ClientProxy { if (!this.tlsOptions) { this.socket.connect(this.port, this.host); } - this.connection = lastValueFrom(source$).catch(err => { + this.connectionPromise = lastValueFrom(source$).catch(err => { if (err instanceof EmptyError) { return; } throw err; }); - return this.connection; + return this.connectionPromise; } public async handleResponse(buffer: unknown): Promise { @@ -114,14 +116,30 @@ export class ClientTCP extends ClientProxy { public close() { this.socket && this.socket.end(); this.handleClose(); + this.pendingEventListeners = []; } - public bindEvents(socket: TcpSocket) { - socket.on( - ERROR_EVENT, - (err: any) => err.code !== ECONNREFUSED && this.handleError(err), - ); - socket.on(CLOSE_EVENT, () => this.handleClose()); + public registerConnectListener(socket: TcpSocket) { + socket.on(TcpEventsMap.CONNECT, () => { + this._status$.next(TcpStatus.CONNECTED); + }); + } + + public registerErrorListener(socket: TcpSocket) { + socket.on(TcpEventsMap.ERROR, err => { + if (err.code !== ECONNREFUSED) { + this.handleError(err); + } else { + this._status$.next(TcpStatus.DISCONNECTED); + } + }); + } + + public registerCloseListener(socket: TcpSocket) { + socket.on(TcpEventsMap.CLOSE, () => { + this._status$.next(TcpStatus.DISCONNECTED); + this.handleClose(); + }); } public handleError(err: any) { @@ -129,9 +147,8 @@ export class ClientTCP extends ClientProxy { } public handleClose() { - this.isConnected = false; this.socket = null; - this.connection = undefined; + this.connectionPromise = undefined; if (this.routingMap.size > 0) { const err = new Error('Connection closed'); @@ -142,6 +159,26 @@ export class ClientTCP extends ClientProxy { } } + public on< + EventKey extends keyof TcpEvents = keyof TcpEvents, + EventCallback extends TcpEvents[EventKey] = TcpEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.socket) { + this.socket.on(event, callback as any); + } else { + this.pendingEventListeners.push({ event, callback }); + } + } + + public unwrap(): T { + if (!this.socket) { + throw new Error( + 'Not initialized. Please call the "connect" method first.', + ); + } + return this.socket.netSocket as T; + } + protected publish( partialPacket: ReadPacket, callback: (packet: WritePacket) => any, diff --git a/packages/microservices/client/constants.ts b/packages/microservices/client/constants.ts deleted file mode 100644 index 4975f18d89c..00000000000 --- a/packages/microservices/client/constants.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const GRPC_CANCELLED = 'Cancelled'; -export const RABBITMQ_REPLY_QUEUE = 'amq.rabbitmq.reply-to'; diff --git a/packages/microservices/constants.ts b/packages/microservices/constants.ts index c358776dd20..502933289eb 100644 --- a/packages/microservices/constants.ts +++ b/packages/microservices/constants.ts @@ -2,25 +2,29 @@ import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; export const TCP_DEFAULT_PORT = 3000; export const TCP_DEFAULT_HOST = 'localhost'; - export const REDIS_DEFAULT_PORT = 6379; export const REDIS_DEFAULT_HOST = 'localhost'; - export const NATS_DEFAULT_URL = 'nats://localhost:4222'; export const MQTT_DEFAULT_URL = 'mqtt://localhost:1883'; export const GRPC_DEFAULT_URL = 'localhost:5000'; export const RQM_DEFAULT_URL = 'amqp://localhost'; export const KAFKA_DEFAULT_BROKER = 'localhost:9092'; +export const KAFKA_DEFAULT_CLIENT = 'nestjs-consumer'; +export const KAFKA_DEFAULT_GROUP = 'nestjs-group'; +export const MQTT_SEPARATOR = '/'; +export const MQTT_WILDCARD_SINGLE = '+'; +export const MQTT_WILDCARD_ALL = '#'; +export const RQM_DEFAULT_QUEUE = 'default'; +export const RQM_DEFAULT_PREFETCH_COUNT = 0; +export const RQM_DEFAULT_IS_GLOBAL_PREFETCH_COUNT = false; +export const RQM_DEFAULT_QUEUE_OPTIONS = {}; +export const RQM_DEFAULT_NOACK = true; +export const RQM_DEFAULT_PERSISTENT = false; +export const RQM_DEFAULT_NO_ASSERT = false; -export const CONNECT_EVENT = 'connect'; -export const DISCONNECT_EVENT = 'disconnect'; -export const CONNECT_FAILED_EVENT = 'connectFailed'; -export const MESSAGE_EVENT = 'message'; -export const DATA_EVENT = 'data'; -export const ERROR_EVENT = 'error'; -export const CLOSE_EVENT = 'close'; -export const SUBSCRIBE = 'subscribe'; -export const CANCEL_EVENT = 'cancelled'; +export const ECONNREFUSED = 'ECONNREFUSED'; +export const CONN_ERR = 'CONN_ERR'; +export const EADDRINUSE = 'EADDRINUSE'; export const PATTERN_METADATA = 'microservices:pattern'; export const PATTERN_EXTRAS_METADATA = 'microservices:pattern_extras'; @@ -29,17 +33,9 @@ export const CLIENT_CONFIGURATION_METADATA = 'microservices:client'; export const PATTERN_HANDLER_METADATA = 'microservices:handler_type'; export const CLIENT_METADATA = 'microservices:is_client_instance'; export const PARAM_ARGS_METADATA = ROUTE_ARGS_METADATA; - export const REQUEST_PATTERN_METADATA = 'microservices:request_pattern'; export const REPLY_PATTERN_METADATA = 'microservices:reply_pattern'; -export const RQM_DEFAULT_QUEUE = 'default'; -export const RQM_DEFAULT_PREFETCH_COUNT = 0; -export const RQM_DEFAULT_IS_GLOBAL_PREFETCH_COUNT = false; -export const RQM_DEFAULT_QUEUE_OPTIONS = {}; -export const RQM_DEFAULT_NOACK = true; -export const RQM_DEFAULT_PERSISTENT = false; -export const RQM_DEFAULT_NO_ASSERT = false; export const RQM_NO_EVENT_HANDLER = ( text: TemplateStringsArray, pattern: string, @@ -55,19 +51,6 @@ export const GRPC_DEFAULT_PROTO_LOADER = '@grpc/proto-loader'; export const NO_EVENT_HANDLER = (text: TemplateStringsArray, pattern: string) => `There is no matching event handler defined in the remote service. Event pattern: ${pattern}`; export const NO_MESSAGE_HANDLER = `There is no matching message handler defined in the remote service.`; - export const DISCONNECTED_RMQ_MESSAGE = `Disconnected from RMQ. Trying to reconnect.`; - -export const KAFKA_DEFAULT_CLIENT = 'nestjs-consumer'; -export const KAFKA_DEFAULT_GROUP = 'nestjs-group'; - -export const MQTT_SEPARATOR = '/'; -export const MQTT_WILDCARD_SINGLE = '+'; -export const MQTT_WILDCARD_ALL = '#'; - -export const ECONNREFUSED = 'ECONNREFUSED'; -export const CONN_ERR = 'CONN_ERR'; -export const EADDRINUSE = 'EADDRINUSE'; - export const CONNECTION_FAILED_MESSAGE = 'Connection to transport failed. Trying to reconnect...'; diff --git a/packages/microservices/container.ts b/packages/microservices/container.ts index 8ab9cb325f2..87812833d94 100644 --- a/packages/microservices/container.ts +++ b/packages/microservices/container.ts @@ -1,16 +1,13 @@ import { ClientProxy } from './client/client-proxy'; -import { Closeable } from './interfaces/closeable.interface'; - -export type CloseableClient = Closeable & ClientProxy; export class ClientsContainer { - private clients: CloseableClient[] = []; + private clients: ClientProxy[] = []; - public getAllClients(): CloseableClient[] { + public getAllClients(): ClientProxy[] { return this.clients; } - public addClient(client: CloseableClient) { + public addClient(client: ClientProxy) { this.clients.push(client); } diff --git a/packages/microservices/events/index.ts b/packages/microservices/events/index.ts new file mode 100644 index 00000000000..df8d4959f07 --- /dev/null +++ b/packages/microservices/events/index.ts @@ -0,0 +1,6 @@ +export { KafkaStatus } from './kafka.events'; +export { MqttEvents, MqttStatus } from './mqtt.events'; +export { NatsEvents, NatsStatus } from './nats.events'; +export { RedisEvents, RedisStatus } from './redis.events'; +export { RmqEvents, RmqStatus } from './rmq.events'; +export { TcpEvents, TcpStatus } from './tcp.events'; diff --git a/packages/microservices/events/kafka.events.ts b/packages/microservices/events/kafka.events.ts new file mode 100644 index 00000000000..614964f8752 --- /dev/null +++ b/packages/microservices/events/kafka.events.ts @@ -0,0 +1,7 @@ +export const enum KafkaStatus { + DISCONNECTED = 'disconnected', + CONNECTED = 'connected', + CRASHED = 'crashed', + STOPPED = 'stopped', + REBALANCING = 'rebalancing', +} diff --git a/packages/microservices/events/mqtt.events.ts b/packages/microservices/events/mqtt.events.ts new file mode 100644 index 00000000000..f1d935e3fb4 --- /dev/null +++ b/packages/microservices/events/mqtt.events.ts @@ -0,0 +1,39 @@ +type VoidCallback = () => void; +type OnPacketCallback = (packet: any) => void; +type OnErrorCallback = (error: Error) => void; + +export const enum MqttStatus { + DISCONNECTED = 'disconnected', + RECONNECTING = 'reconnecting', + CONNECTED = 'connected', + CLOSED = 'closed', +} + +export const enum MqttEventsMap { + CONNECT = 'connect', + RECONNECT = 'reconnect', + DISCONNECT = 'disconnect', + CLOSE = 'close', + OFFLINE = 'offline', + END = 'end', + ERROR = 'error', + PACKETRECEIVE = 'packetreceive', + PACKETSEND = 'packetsend', +} + +/** + * MQTT events map for the MQTT client. + * Key is the event name and value is the corresponding callback function. + * @publicApi + */ +export type MqttEvents = { + connect: OnPacketCallback; + reconnect: VoidCallback; + disconnect: OnPacketCallback; + close: VoidCallback; + offline: VoidCallback; + end: VoidCallback; + error: OnErrorCallback; + packetreceive: OnPacketCallback; + packetsend: OnPacketCallback; +}; diff --git a/packages/microservices/events/nats.events.ts b/packages/microservices/events/nats.events.ts new file mode 100644 index 00000000000..30cc734e8bb --- /dev/null +++ b/packages/microservices/events/nats.events.ts @@ -0,0 +1,29 @@ +type DefaultCallback = (data?: string | number) => any; + +export type ServersChangedEvent = { + added: string[]; + deleted: string[]; +}; + +export const enum NatsStatus { + DISCONNECTED = 'disconnected', + RECONNECTING = 'reconnecting', + CONNECTED = 'connected', +} + +export const enum NatsEventsMap { + DISCONNECT = 'disconnect', + RECONNECT = 'reconnect', + UPDATE = 'update', +} + +/** + * Nats events map for the Nats client. + * Key is the event name and value is the corresponding callback function. + * @publicApi + */ +export type NatsEvents = { + disconnect: DefaultCallback; + reconnect: DefaultCallback; + update: (data?: string | number | ServersChangedEvent) => any; +}; diff --git a/packages/microservices/events/redis.events.ts b/packages/microservices/events/redis.events.ts new file mode 100644 index 00000000000..c89b46955ab --- /dev/null +++ b/packages/microservices/events/redis.events.ts @@ -0,0 +1,34 @@ +type VoidCallback = (client: 'pub' | 'sub') => void; +type OnErrorCallback = (client: 'pub' | 'sub', error: Error) => void; +type OnWarningCallback = (client: 'pub' | 'sub', warning: any) => void; + +export const enum RedisStatus { + DISCONNECTED = 'disconnected', + RECONNECTING = 'reconnecting', + CONNECTED = 'connected', +} + +export const enum RedisEventsMap { + CONNECT = 'connect', + READY = 'ready', + ERROR = 'error', + CLOSE = 'close', + RECONNECTING = 'reconnecting', + END = 'end', + WARNING = 'warning', +} + +/** + * Redis events map for the Redis client. + * Key is the event name and value is the corresponding callback function. + * @publicApi + */ +export type RedisEvents = { + connect: VoidCallback; + ready: VoidCallback; + error: OnErrorCallback; + close: VoidCallback; + reconnecting: VoidCallback; + end: VoidCallback; + warning: OnWarningCallback; +}; diff --git a/packages/microservices/events/rmq.events.ts b/packages/microservices/events/rmq.events.ts new file mode 100644 index 00000000000..aba888901a6 --- /dev/null +++ b/packages/microservices/events/rmq.events.ts @@ -0,0 +1,24 @@ +type VoidCallback = () => void; +type OnErrorCallback = (error: Error) => void; + +export const enum RmqStatus { + DISCONNECTED = 'disconnected', + CONNECTED = 'connected', +} + +export const enum RmqEventsMap { + ERROR = 'error', + DISCONNECT = 'disconnect', + CONNECT = 'connect', +} + +/** + * RabbitMQ events map for the ampqlip client. + * Key is the event name and value is the corresponding callback function. + * @publicApi + */ +export type RmqEvents = { + error: OnErrorCallback; + disconnect: VoidCallback; + connect: VoidCallback; +}; diff --git a/packages/microservices/events/tcp.events.ts b/packages/microservices/events/tcp.events.ts new file mode 100644 index 00000000000..3ba328855a2 --- /dev/null +++ b/packages/microservices/events/tcp.events.ts @@ -0,0 +1,39 @@ +type VoidCallback = () => void; +type OnErrorCallback = (error: Error) => void; +type OnLookupCallback = ( + err: Error, + address: string, + family: string, + host: string, +) => void; + +export const enum TcpStatus { + DISCONNECTED = 'disconnected', + CONNECTED = 'connected', +} + +export const enum TcpEventsMap { + ERROR = 'error', + CONNECT = 'connect', + END = 'end', + CLOSE = 'close', + TIMEOUT = 'timeout', + DRAIN = 'drain', + LOOKUP = 'lookup', + LISTENING = 'listening', +} + +/** + * TCP events map for the net TCP socket. + * Key is the event name and value is the corresponding callback function. + * @publicApi + */ +export type TcpEvents = { + error: OnErrorCallback; + connect: VoidCallback; + end: VoidCallback; + close: VoidCallback; + timeout: VoidCallback; + drain: VoidCallback; + lookup: OnLookupCallback; +}; diff --git a/packages/microservices/external/mqtt-client.interface.ts b/packages/microservices/external/mqtt-client.interface.ts deleted file mode 100644 index 0429c850df4..00000000000 --- a/packages/microservices/external/mqtt-client.interface.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { EventEmitter } from 'events'; - -/** - * @see https://github.com/mqttjs/MQTT.js/ - * - * @publicApi - * - */ -export declare class MqttClient extends EventEmitter { - public connected: boolean; - public disconnecting: boolean; - public disconnected: boolean; - public reconnecting: boolean; - public incomingStore: any; - public outgoingStore: any; - public options: any; - public queueQoSZero: boolean; - - constructor(streamBuilder: (client: MqttClient) => any, options: any); - - public on(event: 'message', cb: any): this; - public on(event: 'packetsend' | 'packetreceive', cb: any): this; - public on(event: 'error', cb: any): this; - public on(event: string, cb: Function): this; - - public once(event: 'message', cb: any): this; - public once(event: 'packetsend' | 'packetreceive', cb: any): this; - public once(event: 'error', cb: any): this; - public once(event: string, cb: Function): this; - - /** - * publish - publish to - * - * @param {String} topic - topic to publish to - * @param {(String|Buffer)} message - message to publish - * - * @param {Object} [opts] - publish options, includes: - * @param {Number} [opts.qos] - qos level to publish on - * @param {Boolean} [opts.retain] - whether or not to retain the message - * - * @param {Function} [callback] - function(err){} - * called when publish succeeds or fails - * @returns {Client} this - for chaining - * @api public - * - * @example client.publish('topic', 'message') - * @example - * client.publish('topic', 'message', {qos: 1, retain: true}) - * @example client.publish('topic', 'message', console.log) - */ - public publish( - topic: string, - message: string | Buffer, - opts: any, - callback?: any, - ): this; - public publish(topic: string, message: string | Buffer, callback?: any): this; - - /** - * subscribe - subscribe to - * - * @param {String, Array, Object} topic - topic(s) to subscribe to, supports objects in the form {'topic': qos} - * @param {Object} [opts] - optional subscription options, includes: - * @param {Number} [opts.qos] - subscribe qos level - * @param {Function} [callback] - function(err, granted){} where: - * {Error} err - subscription error (none at the moment!) - * {Array} granted - array of {topic: 't', qos: 0} - * @returns {MqttClient} this - for chaining - * @api public - * @example client.subscribe('topic') - * @example client.subscribe('topic', {qos: 1}) - * @example client.subscribe({'topic': 0, 'topic2': 1}, console.log) - * @example client.subscribe('topic', console.log) - */ - public subscribe(topic: string | string[], opts: any, callback?: any): this; - public subscribe(topic: string | string[] | any, callback?: any): this; - - /** - * unsubscribe - unsubscribe from topic(s) - * - * @param {string|Array} topic - topics to unsubscribe from - * @param {Function} [callback] - callback fired on unsuback - * @returns {MqttClient} this - for chaining - * @api public - * @example client.unsubscribe('topic') - * @example client.unsubscribe('topic', console.log) - */ - public unsubscribe(topic: string | string[], callback?: any): this; - - /** - * end - close connection - * - * @returns {MqttClient} this - for chaining - * @param {Boolean} force - do not wait for all in-flight messages to be acked - * @param {Function} cb - called when the client has been closed - * - * @api public - */ - public end(force?: boolean, cb?: any): this; - - /** - * removeOutgoingMessage - remove a message in outgoing store - * the outgoing callback will be called withe Error('Message removed') if the message is removed - * - * @param {Number} mid - messageId to remove message - * @returns {MqttClient} this - for chaining - * @api public - * - * @example client.removeOutgoingMessage(client.getLastMessageId()); - */ - public removeOutgoingMessage(mid: number): this; - - /** - * reconnect - connect again using the same options as connect() - * - * @param {Object} [opts] - optional reconnect options, includes: - * {any} incomingStore - a store for the incoming packets - * {any} outgoingStore - a store for the outgoing packets - * if opts is not given, current stores are used - * - * @returns {MqttClient} this - for chaining - * - * @api public - */ - public reconnect(opts?: any): this; - - /** - * Handle messages with backpressure support, one at a time. - * Override at will. - * - * @param packet packet the packet - * @param callback callback call when finished - * @api public - */ - public handleMessage(packet: any, callback: any): void; - - /** - * getLastMessageId - */ - public getLastMessageId(): number; -} diff --git a/packages/microservices/external/nats-client.interface.ts b/packages/microservices/external/nats-client.interface.ts deleted file mode 100644 index 637883a42f8..00000000000 --- a/packages/microservices/external/nats-client.interface.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @see https://github.com/nats-io/nats.js - * - * @publicApi - */ -export interface NatsCodec { - encode(d: T): Uint8Array; - decode(a: Uint8Array): T; -} - -interface RequestOptions { - timeout: number; - headers?: any; - noMux?: boolean; - reply?: string; -} -interface PublishOptions { - reply?: string; - headers?: any; -} -interface SubOpts { - queue?: string; - max?: number; - timeout?: number; - callback?: (err: object | null, msg: T) => void; -} - -declare type SubscriptionOptions = SubOpts; - -export interface NatsMsg { - subject: string; - sid: number; - reply?: string; - data: Uint8Array; - headers?: any; - respond(data?: Uint8Array, opts?: PublishOptions): boolean; -} - -interface Sub extends AsyncIterable { - unsubscribe(max?: number): void; - drain(): Promise; - isDraining(): boolean; - isClosed(): boolean; - callback(err: object | null, msg: NatsMsg): void; - getSubject(): string; - getReceived(): number; - getProcessed(): number; - getPending(): number; - getID(): number; - getMax(): number | undefined; -} - -declare type Subscription = Sub; - -declare enum Events { - Disconnect = 'disconnect', - Reconnect = 'reconnect', - Update = 'update', - LDM = 'ldm', - Error = 'error', -} -interface Status { - type: Events | DebugEvents; - data: string | number; -} - -declare enum DebugEvents { - Reconnecting = 'reconnecting', - PingTimer = 'pingTimer', - StaleConnection = 'staleConnection', -} - -export declare class Client { - info?: Record; - closed(): Promise; - close(): Promise; - publish(subject: string, data?: Uint8Array, options?: PublishOptions): void; - subscribe(subject: string, opts?: SubscriptionOptions): Subscription; - request( - subject: string, - data?: Uint8Array, - opts?: RequestOptions, - ): Promise; - flush(): Promise; - drain(): Promise; - isClosed(): boolean; - isDraining(): boolean; - getServer(): string; - status(): AsyncIterable; - stats(): Record; - jetstreamManager(opts?: Record): Promise; - jetstream(opts?: Record): any; -} diff --git a/packages/microservices/external/nats-codec.interface.ts b/packages/microservices/external/nats-codec.interface.ts new file mode 100644 index 00000000000..76a3b8d55e8 --- /dev/null +++ b/packages/microservices/external/nats-codec.interface.ts @@ -0,0 +1,9 @@ +/** + * @see https://github.com/nats-io/nats.js + * + * @publicApi + */ +export interface NatsCodec { + encode(d: T): Uint8Array; + decode(a: Uint8Array): T; +} diff --git a/packages/microservices/helpers/tcp-socket.ts b/packages/microservices/helpers/tcp-socket.ts index cca8fe94dfb..cd4988fc1ce 100644 --- a/packages/microservices/helpers/tcp-socket.ts +++ b/packages/microservices/helpers/tcp-socket.ts @@ -1,14 +1,8 @@ import { Buffer } from 'buffer'; import { Socket } from 'net'; -import { - CLOSE_EVENT, - CONNECT_EVENT, - DATA_EVENT, - ERROR_EVENT, - MESSAGE_EVENT, -} from '../constants'; -import { NetSocketClosedException } from '../errors/net-socket-closed.exception'; import { InvalidJSONFormatException } from '../errors/invalid-json-format.exception'; +import { NetSocketClosedException } from '../errors/net-socket-closed.exception'; +import { TcpEventsMap } from '../events/tcp.events'; export abstract class TcpSocket { private isClosed = false; @@ -18,10 +12,10 @@ export abstract class TcpSocket { } constructor(public readonly socket: Socket) { - this.socket.on(DATA_EVENT, this.onData.bind(this)); - this.socket.on(CONNECT_EVENT, () => (this.isClosed = false)); - this.socket.on(CLOSE_EVENT, () => (this.isClosed = true)); - this.socket.on(ERROR_EVENT, () => (this.isClosed = true)); + this.socket.on('data', this.onData.bind(this)); + this.socket.on(TcpEventsMap.CONNECT, () => (this.isClosed = false)); + this.socket.on(TcpEventsMap.CLOSE, () => (this.isClosed = true)); + this.socket.on(TcpEventsMap.ERROR, () => (this.isClosed = true)); } public connect(port: number, host: string) { @@ -52,18 +46,21 @@ export abstract class TcpSocket { this.handleSend(message, callback); } - protected abstract handleSend(message: any, callback?: (err?: any) => void); + protected abstract handleSend( + message: any, + callback?: (err?: any) => void, + ): any; private onData(data: Buffer) { try { this.handleData(data); } catch (e) { - this.socket.emit(ERROR_EVENT, e.message); + this.socket.emit(TcpEventsMap.ERROR, e.message); this.socket.end(); } } - protected abstract handleData(data: Buffer | string); + protected abstract handleData(data: Buffer | string): any; protected emitMessage(data: string) { let message: Record; @@ -73,6 +70,6 @@ export abstract class TcpSocket { throw new InvalidJSONFormatException(e, data); } message = message || {}; - this.socket.emit(MESSAGE_EVENT, message); + this.socket.emit('message', message); } } diff --git a/packages/microservices/index.ts b/packages/microservices/index.ts index bf44f34f71c..02bbed3f6d0 100644 --- a/packages/microservices/index.ts +++ b/packages/microservices/index.ts @@ -10,6 +10,7 @@ export * from './client'; export * from './ctx-host'; export * from './decorators'; export * from './enums'; +export * from './events'; export * from './exceptions'; export * from './helpers'; export * from './interfaces'; diff --git a/packages/microservices/interfaces/client-grpc.interface.ts b/packages/microservices/interfaces/client-grpc.interface.ts index 95815ef0fc0..2f8bf6509e8 100644 --- a/packages/microservices/interfaces/client-grpc.interface.ts +++ b/packages/microservices/interfaces/client-grpc.interface.ts @@ -2,6 +2,16 @@ * @publicApi */ export interface ClientGrpc { + /** + * Returns an instance of the given gRPC service. + * @param name Service name + * @returns gRPC service + */ getService(name: string): T; + /** + * Returns an instance of the given gRPC client. + * @param name Service name + * @returns gRPC client + */ getClientByServiceName(name: string): T; } diff --git a/packages/microservices/interfaces/client-kafka-proxy.interface.ts b/packages/microservices/interfaces/client-kafka-proxy.interface.ts new file mode 100644 index 00000000000..6047ecf2deb --- /dev/null +++ b/packages/microservices/interfaces/client-kafka-proxy.interface.ts @@ -0,0 +1,33 @@ +import { ClientProxy } from '../client'; +import { KafkaStatus } from '../events'; +import { + Consumer, + Producer, + TopicPartitionOffsetAndMetadata, +} from '../external/kafka.interface'; + +export interface ClientKafkaProxy + extends Omit, 'on'> { + /** + * Reference to the Kafka consumer instance. + */ + consumer: Consumer | null; + /** + * Reference to the Kafka producer instance. + */ + producer: Producer | null; + /** + * Subscribes to messages that match the pattern. + * Required for message-driven communication style between microservices. + * You can't use `send` without subscribing to the message pattern first. + * @param pattern Pattern to subscribe to + */ + subscribeToResponseOf(pattern: unknown): void; + /** + * Commits the given offsets. + * @param topicPartitions Array of topic partitions with their offsets and metadata + */ + commitOffsets( + topicPartitions: TopicPartitionOffsetAndMetadata[], + ): Promise; +} diff --git a/packages/microservices/interfaces/closeable.interface.ts b/packages/microservices/interfaces/closeable.interface.ts deleted file mode 100644 index a5cc2c694db..00000000000 --- a/packages/microservices/interfaces/closeable.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface Closeable { - close(): void; -} diff --git a/packages/microservices/interfaces/custom-transport-strategy.interface.ts b/packages/microservices/interfaces/custom-transport-strategy.interface.ts index c7b4e9e44f8..56886df414e 100644 --- a/packages/microservices/interfaces/custom-transport-strategy.interface.ts +++ b/packages/microservices/interfaces/custom-transport-strategy.interface.ts @@ -4,7 +4,17 @@ import { Transport } from '../enums'; * @publicApi */ export interface CustomTransportStrategy { + /** + * Unique transport identifier. + */ readonly transportId?: Transport | symbol; + /** + * Method called when the transport is being initialized. + * @param callback Function to be called upon initialization + */ listen(callback: (...optionalParams: unknown[]) => any): any; + /** + * Method called when the transport is being terminated. + */ close(): any; } diff --git a/packages/microservices/interfaces/index.ts b/packages/microservices/interfaces/index.ts index 6916a6c10e2..71718b8e1b1 100644 --- a/packages/microservices/interfaces/index.ts +++ b/packages/microservices/interfaces/index.ts @@ -1,6 +1,6 @@ export * from './client-grpc.interface'; +export * from './client-kafka-proxy.interface'; export * from './client-metadata.interface'; -export * from './closeable.interface'; export * from './custom-transport-strategy.interface'; export * from './deserializer.interface'; export * from './message-handler.interface'; diff --git a/packages/microservices/interfaces/microservice-configuration.interface.ts b/packages/microservices/interfaces/microservice-configuration.interface.ts index b5850e1b236..2c7b76255ce 100644 --- a/packages/microservices/interfaces/microservice-configuration.interface.ts +++ b/packages/microservices/interfaces/microservice-configuration.interface.ts @@ -1,5 +1,5 @@ import { Type } from '@nestjs/common'; -import { ConnectionOptions } from 'tls'; +import { TlsOptions } from 'tls'; import { Transport } from '../enums/transport.enum'; import { ChannelOptions } from '../external/grpc-options.interface'; import { @@ -94,7 +94,7 @@ export interface TcpOptions { retryAttempts?: number; retryDelay?: number; serializer?: Serializer; - tlsOptions?: ConnectionOptions; + tlsOptions?: TlsOptions; deserializer?: Deserializer; socketClass?: Type; }; diff --git a/packages/microservices/listeners-controller.ts b/packages/microservices/listeners-controller.ts index cba398269c4..aa277d4ea94 100644 --- a/packages/microservices/listeners-controller.ts +++ b/packages/microservices/listeners-controller.ts @@ -34,12 +34,7 @@ import { } from './context/rpc-metadata-constants'; import { BaseRpcContext } from './ctx-host/base-rpc.context'; import { Transport } from './enums'; -import { - CustomTransportStrategy, - MessageHandler, - PatternMetadata, - RequestContext, -} from './interfaces'; +import { MessageHandler, PatternMetadata, RequestContext } from './interfaces'; import { MicroserviceEntrypointMetadata } from './interfaces/microservice-entrypoint-metadata.interface'; import { EventOrMessageListenerDefinition, @@ -66,7 +61,7 @@ export class ListenersController { public registerPatternHandlers( instanceWrapper: InstanceWrapper, - server: Server & CustomTransportStrategy, + serverInstance: Server, moduleKey: string, ) { const { instance } = instanceWrapper; @@ -75,7 +70,7 @@ export class ListenersController { const patternHandlers = this.metadataExplorer.explore(instance as object); const moduleRef = this.container.getModuleByKey(moduleKey); const defaultCallMetadata = - server instanceof ServerGrpc + serverInstance instanceof ServerGrpc ? DEFAULT_GRPC_CALLBACK_METADATA : DEFAULT_CALLBACK_METADATA; @@ -83,8 +78,8 @@ export class ListenersController { .filter( ({ transport }) => isUndefined(transport) || - isUndefined(server.transportId) || - transport === server.transportId, + isUndefined(serverInstance.transportId) || + transport === serverInstance.transportId, ) .reduce((acc, handler) => { handler.patterns.forEach(pattern => @@ -104,7 +99,7 @@ export class ListenersController { this.insertEntrypointDefinition( instanceWrapper, definition, - server.transportId, + serverInstance.transportId, ); if (isStatic) { @@ -131,14 +126,19 @@ export class ListenersController { eventHandler, ); }; - return server.addHandler( + return serverInstance.addHandler( pattern, eventHandler, isEventHandler, extras, ); } else { - return server.addHandler(pattern, proxy, isEventHandler, extras); + return serverInstance.addHandler( + pattern, + proxy, + isEventHandler, + extras, + ); } } const asyncHandler = this.createRequestScopedHandler( @@ -150,7 +150,12 @@ export class ListenersController { defaultCallMetadata, isEventHandler, ); - server.addHandler(pattern, asyncHandler, isEventHandler, extras); + serverInstance.addHandler( + pattern, + asyncHandler, + isEventHandler, + extras, + ); }); } diff --git a/packages/microservices/microservices-module.ts b/packages/microservices/microservices-module.ts index c99e404109e..65f1855feb6 100644 --- a/packages/microservices/microservices-module.ts +++ b/packages/microservices/microservices-module.ts @@ -17,7 +17,6 @@ import { ClientsContainer } from './container'; import { ExceptionFiltersContext } from './context/exception-filters-context'; import { RpcContextCreator } from './context/rpc-context-creator'; import { RpcProxy } from './context/rpc-proxy'; -import { CustomTransportStrategy } from './interfaces'; import { ListenersController } from './listeners-controller'; import { Server } from './server/server'; @@ -63,16 +62,13 @@ export class MicroservicesModule< ); } - public setupListeners( - container: NestContainer, - server: Server & CustomTransportStrategy, - ) { + public setupListeners(container: NestContainer, serverInstance: Server) { if (!this.listenersController) { throw new RuntimeException(); } const modules = container.getModules(); modules.forEach(({ controllers }, moduleRef) => - this.bindListeners(controllers, server, moduleRef), + this.bindListeners(controllers, serverInstance, moduleRef), ); } @@ -92,13 +88,13 @@ export class MicroservicesModule< public bindListeners( controllers: Map>, - server: Server & CustomTransportStrategy, + serverInstance: Server, moduleName: string, ) { controllers.forEach(wrapper => this.listenersController.registerPatternHandlers( wrapper, - server, + serverInstance, moduleName, ), ); diff --git a/packages/microservices/module/clients.module.ts b/packages/microservices/module/clients.module.ts index 4026bbab308..f6ca14b589e 100644 --- a/packages/microservices/module/clients.module.ts +++ b/packages/microservices/module/clients.module.ts @@ -5,7 +5,6 @@ import { Provider, } from '@nestjs/common'; import { ClientProxy, ClientProxyFactory } from '../client'; -import { Closeable } from '../interfaces'; import { ClientsModuleAsyncOptions, ClientsModuleOptions, @@ -101,7 +100,7 @@ export class ClientsModule { }; } - private static assignOnAppShutdownHook(client: ClientProxy & Closeable) { + private static assignOnAppShutdownHook(client: ClientProxy) { (client as unknown as OnApplicationShutdown).onApplicationShutdown = client.close; return client; diff --git a/packages/microservices/nest-microservice.ts b/packages/microservices/nest-microservice.ts index 7b0930e69dd..ab287a099d8 100644 --- a/packages/microservices/nest-microservice.ts +++ b/packages/microservices/nest-microservice.ts @@ -16,7 +16,6 @@ import { Injector } from '@nestjs/core/injector/injector'; import { GraphInspector } from '@nestjs/core/inspector/graph-inspector'; import { NestApplicationContext } from '@nestjs/core/nest-application-context'; import { Transport } from './enums/transport.enum'; -import { CustomTransportStrategy } from './interfaces/custom-transport-strategy.interface'; import { MicroserviceOptions } from './interfaces/microservice-configuration.interface'; import { MicroservicesModule } from './microservices-module'; import { Server } from './server/server'; @@ -27,6 +26,9 @@ const { SocketModule } = optionalRequire( () => require('@nestjs/websockets/socket-module'), ); +type CompleteMicroserviceOptions = NestMicroserviceOptions & + MicroserviceOptions; + export class NestMicroservice extends NestApplicationContext implements INestMicroservice @@ -36,14 +38,21 @@ export class NestMicroservice }); private readonly microservicesModule = new MicroservicesModule(); private readonly socketModule = SocketModule ? new SocketModule() : null; - private microserviceConfig: NestMicroserviceOptions & MicroserviceOptions; - private server: Server & CustomTransportStrategy; + private microserviceConfig: CompleteMicroserviceOptions; + private serverInstance: Server; private isTerminated = false; - private isInitHookCalled = false; + private wasInitHookCalled = false; + + /** + * Returns an observable that emits status changes. + */ + get status() { + return this.serverInstance.status; + } constructor( container: NestContainer, - config: NestMicroserviceOptions & MicroserviceOptions = {}, + config: CompleteMicroserviceOptions = {}, private readonly graphInspector: GraphInspector, private readonly applicationConfig: ApplicationConfig, ) { @@ -60,16 +69,21 @@ export class NestMicroservice this.selectContextModule(); } - public createServer(config: NestMicroserviceOptions & MicroserviceOptions) { + public createServer(config: CompleteMicroserviceOptions) { try { this.microserviceConfig = { transport: Transport.TCP, ...config, - } as any; - const { strategy } = config as any; - this.server = strategy - ? strategy - : ServerFactory.create(this.microserviceConfig); + } as CompleteMicroserviceOptions; + + if ('strategy' in config) { + this.serverInstance = config.strategy as Server; + return; + } else { + this.serverInstance = ServerFactory.create( + this.microserviceConfig, + ) as Server; + } } catch (e) { this.logger.error(e); throw e; @@ -92,21 +106,36 @@ export class NestMicroservice this.setIsInitialized(true); - if (!this.isInitHookCalled) { + if (!this.wasInitHookCalled) { await this.callInitHook(); await this.callBootstrapHook(); } } public registerListeners() { - this.microservicesModule.setupListeners(this.container, this.server); + this.microservicesModule.setupListeners( + this.container, + this.serverInstance, + ); } + /** + * Registers a web socket adapter that will be used for Gateways. + * Use to override the default `socket.io` library. + * + * @param {WebSocketAdapter} adapter + * @returns {this} + */ public useWebSocketAdapter(adapter: WebSocketAdapter): this { this.applicationConfig.setIoAdapter(adapter); return this; } + /** + * Registers global exception filters (will be used for every pattern handler). + * + * @param {...ExceptionFilter} filters + */ public useGlobalFilters(...filters: ExceptionFilter[]): this { this.applicationConfig.useGlobalFilters(...filters); filters.forEach(item => @@ -118,6 +147,11 @@ export class NestMicroservice return this; } + /** + * Registers global pipes (will be used for every pattern handler). + * + * @param {...PipeTransform} pipes + */ public useGlobalPipes(...pipes: PipeTransform[]): this { this.applicationConfig.useGlobalPipes(...pipes); pipes.forEach(item => @@ -129,6 +163,11 @@ export class NestMicroservice return this; } + /** + * Registers global interceptors (will be used for every pattern handler). + * + * @param {...NestInterceptor} interceptors + */ public useGlobalInterceptors(...interceptors: NestInterceptor[]): this { this.applicationConfig.useGlobalInterceptors(...interceptors); interceptors.forEach(item => @@ -160,12 +199,17 @@ export class NestMicroservice return this; } - public async listen() { + /** + * Starts the microservice. + * + * @returns {void} + */ + public async listen(): Promise { this.assertNotInPreviewMode('listen'); !this.isInitialized && (await this.registerModules()); return new Promise((resolve, reject) => { - this.server.listen((err, info) => { + this.serverInstance.listen((err, info) => { if (this.microserviceConfig?.autoFlushLogs ?? true) { this.flushLogs(); } @@ -178,8 +222,13 @@ export class NestMicroservice }); } + /** + * Terminates the application. + * + * @returns {Promise} + */ public async close(): Promise { - await this.server.close(); + await this.serverInstance.close(); if (this.isTerminated) { return; } @@ -187,16 +236,51 @@ export class NestMicroservice await this.closeApplication(); } + /** + * Sets the flag indicating that the application is initialized. + * @param isInitialized Value to set + */ public setIsInitialized(isInitialized: boolean) { this.isInitialized = isInitialized; } + /** + * Sets the flag indicating that the application is terminated. + * @param isTerminated Value to set + */ public setIsTerminated(isTerminated: boolean) { this.isTerminated = isTerminated; } + /** + * Sets the flag indicating that the init hook was called. + * @param isInitHookCalled Value to set + */ public setIsInitHookCalled(isInitHookCalled: boolean) { - this.isInitHookCalled = isInitHookCalled; + this.wasInitHookCalled = isInitHookCalled; + } + + /** + * Registers an event listener for the given event. + * @param event Event name + * @param callback Callback to be executed when the event is emitted + */ + public on(event: string | number | symbol, callback: Function) { + if ('on' in this.serverInstance) { + return this.serverInstance.on(event as string, callback); + } + throw new Error('"on" method not supported by the underlying server'); + } + + /** + * Returns an instance of the underlying server/broker instance, + * or a group of servers if there are more than one. + */ + public unwrap(): T { + if ('unwrap' in this.serverInstance) { + return this.serverInstance.unwrap(); + } + throw new Error('"unwrap" method not supported by the underlying server'); } protected async closeApplication(): Promise { @@ -211,7 +295,7 @@ export class NestMicroservice if (this.isTerminated) { return; } - await this.server.close(); + await this.serverInstance.close(); this.socketModule && (await this.socketModule.close()); this.microservicesModule && (await this.microservicesModule.close()); } diff --git a/packages/microservices/record-builders/mqtt.record-builder.ts b/packages/microservices/record-builders/mqtt.record-builder.ts index 41c0f25a21f..5754d8a108b 100644 --- a/packages/microservices/record-builders/mqtt.record-builder.ts +++ b/packages/microservices/record-builders/mqtt.record-builder.ts @@ -18,9 +18,9 @@ export interface MqttRecordOptions { * MQTT 5.0 properties object */ properties?: { - payloadFormatIndicator?: number; + payloadFormatIndicator?: boolean; messageExpiryInterval?: number; - topicAlias?: string; + topicAlias?: number; responseTopic?: string; correlationData?: Buffer; userProperties?: Record; diff --git a/packages/microservices/server/server-factory.ts b/packages/microservices/server/server-factory.ts index 6429a36e613..ac77a688791 100644 --- a/packages/microservices/server/server-factory.ts +++ b/packages/microservices/server/server-factory.ts @@ -1,34 +1,38 @@ import { Transport } from '../enums/transport.enum'; -import { CustomTransportStrategy, MicroserviceOptions } from '../interfaces'; -import { Server } from './server'; +import { + CustomStrategy, + MicroserviceOptions, + MqttOptions, +} from '../interfaces'; import { ServerGrpc } from './server-grpc'; import { ServerKafka } from './server-kafka'; import { ServerMqtt } from './server-mqtt'; import { ServerNats } from './server-nats'; import { ServerRedis } from './server-redis'; -import { ServerTCP } from './server-tcp'; import { ServerRMQ } from './server-rmq'; +import { ServerTCP } from './server-tcp'; export class ServerFactory { - public static create( - microserviceOptions: MicroserviceOptions, - ): Server & CustomTransportStrategy { - const { transport, options } = microserviceOptions as any; + public static create(microserviceOptions: MicroserviceOptions) { + const { transport, options } = microserviceOptions as Exclude< + MicroserviceOptions, + CustomStrategy + >; switch (transport) { case Transport.REDIS: - return new ServerRedis(options); + return new ServerRedis(options as ServerRedis['options']); case Transport.NATS: - return new ServerNats(options); + return new ServerNats(options as ServerNats['options']); case Transport.MQTT: - return new ServerMqtt(options); + return new ServerMqtt(options as MqttOptions['options']); case Transport.GRPC: - return new ServerGrpc(options); + return new ServerGrpc(options as ServerGrpc['options']); case Transport.KAFKA: - return new ServerKafka(options); + return new ServerKafka(options as ServerKafka['options']); case Transport.RMQ: - return new ServerRMQ(options); + return new ServerRMQ(options as ServerRMQ['options']); default: - return new ServerTCP(options); + return new ServerTCP(options as ServerTCP['options']); } } } diff --git a/packages/microservices/server/server-grpc.ts b/packages/microservices/server/server-grpc.ts index f27e0e7217e..966b76d8fd2 100644 --- a/packages/microservices/server/server-grpc.ts +++ b/packages/microservices/server/server-grpc.ts @@ -13,23 +13,30 @@ import { lastValueFrom, } from 'rxjs'; import { catchError, takeUntil } from 'rxjs/operators'; -import { - CANCEL_EVENT, - GRPC_DEFAULT_PROTO_LOADER, - GRPC_DEFAULT_URL, -} from '../constants'; +import { GRPC_DEFAULT_PROTO_LOADER, GRPC_DEFAULT_URL } from '../constants'; import { GrpcMethodStreamingType } from '../decorators'; import { Transport } from '../enums'; import { InvalidGrpcPackageException } from '../errors/invalid-grpc-package.exception'; import { InvalidProtoDefinitionException } from '../errors/invalid-proto-definition.exception'; import { ChannelOptions } from '../external/grpc-options.interface'; import { getGrpcPackageDefinition } from '../helpers'; -import { CustomTransportStrategy, MessageHandler } from '../interfaces'; +import { MessageHandler } from '../interfaces'; import { GrpcOptions } from '../interfaces/microservice-configuration.interface'; import { Server } from './server'; -let grpcPackage: any = {}; -let grpcProtoLoaderPackage: any = {}; +const CANCELLED_EVENT = 'cancelled'; + +// To enable type safety for gRPC. This cant be uncommented by default +// because it would require the user to install the @grpc/grpc-js package even if they dont use gRPC +// Otherwise, TypeScript would fail to compile the code. +// +// type GrpcServer = import('@grpc/grpc-js').Server; +// let grpcPackage = {} as typeof import('@grpc/grpc-js'); +// let grpcProtoLoaderPackage = {} as typeof import('@grpc/proto-loader'); + +type GrpcServer = any; +let grpcPackage = {} as any; +let grpcProtoLoaderPackage = {} as any; interface GrpcCall { request: TRequest; @@ -45,11 +52,16 @@ interface GrpcCall { /** * @publicApi */ -export class ServerGrpc extends Server implements CustomTransportStrategy { +export class ServerGrpc extends Server { public readonly transportId = Transport.GRPC; + protected readonly url: string; + protected grpcClient: GrpcServer; - private readonly url: string; - private grpcClient: any; + get status(): never { + throw new Error( + 'The "status" attribute is not supported by the gRPC transport', + ); + } constructor(private readonly options: GrpcOptions['options']) { super(); @@ -174,7 +186,7 @@ export class ServerGrpc extends Server implements CustomTransportStrategy { return service; } - getMessageHandler( + public getMessageHandler( serviceName: string, methodName: string, streaming: GrpcMethodStreamingType, @@ -264,6 +276,17 @@ export class ServerGrpc extends Server implements CustomTransportStrategy { }; } + public unwrap(): T { + throw new Error('Method is not supported for gRPC transport'); + } + + public on< + EventKey extends string | number | symbol = string | number | symbol, + EventCallback = any, + >(event: EventKey, callback: EventCallback) { + throw new Error('Method is not supported in gRPC mode.'); + } + /** * Writes an observable to a GRPC call. * @@ -296,8 +319,8 @@ export class ServerGrpc extends Server implements CustomTransportStrategy { // Calls that are cancelled by the client should be successfully resolved here resolve(); }; - call.on(CANCEL_EVENT, cancelHandler); - subscription.add(() => call.off(CANCEL_EVENT, cancelHandler)); + call.on(CANCELLED_EVENT, cancelHandler); + subscription.add(() => call.off(CANCELLED_EVENT, cancelHandler)); // In all cases, when we finalize, end the writable stream // being careful that errors and writes must be emitted _before_ this call is ended @@ -402,7 +425,7 @@ export class ServerGrpc extends Server implements CustomTransportStrategy { } else { const response = await lastValueFrom( res.pipe( - takeUntil(fromEvent(call as any, CANCEL_EVENT)), + takeUntil(fromEvent(call as any, CANCELLED_EVENT)), catchError(err => { callback(err, null); return EMPTY; @@ -469,7 +492,7 @@ export class ServerGrpc extends Server implements CustomTransportStrategy { this.messageHandlers.set(route, callback); } - public async createClient(): Promise { + public async createClient() { const channelOptions: ChannelOptions = this.options && this.options.channelOptions ? this.options.channelOptions diff --git a/packages/microservices/server/server-kafka.ts b/packages/microservices/server/server-kafka.ts index 8fcbb0e446b..4bc0a66d9c8 100644 --- a/packages/microservices/server/server-kafka.ts +++ b/packages/microservices/server/server-kafka.ts @@ -11,6 +11,7 @@ import { import { KafkaContext } from '../ctx-host'; import { KafkaRequestDeserializer } from '../deserializers/kafka-request.deserializer'; import { KafkaHeaders, Transport } from '../enums'; +import { KafkaStatus } from '../events'; import { KafkaRetriableException } from '../exceptions'; import { BrokersFunction, @@ -25,12 +26,7 @@ import { RecordMetadata, } from '../external/kafka.interface'; import { KafkaLogger, KafkaParser } from '../helpers'; -import { - CustomTransportStrategy, - KafkaOptions, - OutgoingResponse, - ReadPacket, -} from '../interfaces'; +import { KafkaOptions, OutgoingResponse, ReadPacket } from '../interfaces'; import { KafkaRequestSerializer } from '../serializers/kafka-request.serializer'; import { Server } from './server'; @@ -39,7 +35,7 @@ let kafkaPackage: any = {}; /** * @publicApi */ -export class ServerKafka extends Server implements CustomTransportStrategy { +export class ServerKafka extends Server { public readonly transportId = Transport.KAFKA; protected logger = new Logger(ServerKafka.name); @@ -47,7 +43,6 @@ export class ServerKafka extends Server implements CustomTransportStrategy { protected consumer: Consumer = null; protected producer: Producer = null; protected parser: KafkaParser = null; - protected brokers: string[] | BrokersFunction; protected clientId: string; protected groupId: string; @@ -64,7 +59,7 @@ export class ServerKafka extends Server implements CustomTransportStrategy { this.brokers = clientOptions.brokers || [KAFKA_DEFAULT_BROKER]; - // append a unique id to the clientId and groupId + // Append a unique id to the clientId and groupId // so they don't collide with a microservices client this.clientId = (clientOptions.clientId || KAFKA_DEFAULT_CLIENT) + postfixId; @@ -105,6 +100,8 @@ export class ServerKafka extends Server implements CustomTransportStrategy { }); this.consumer = this.client.consumer(consumerOptions); this.producer = this.client.producer(this.options.producer); + this.registerConsumerEventListeners(); + this.registerProducerEventListeners(); await this.consumer.connect(); await this.producer.connect(); @@ -112,6 +109,33 @@ export class ServerKafka extends Server implements CustomTransportStrategy { callback(); } + protected registerConsumerEventListeners() { + this.consumer.on(this.consumer.events.CONNECT, () => + this._status$.next(KafkaStatus.CONNECTED), + ); + this.consumer.on(this.consumer.events.DISCONNECT, () => + this._status$.next(KafkaStatus.DISCONNECTED), + ); + this.consumer.on(this.consumer.events.REBALANCING, () => + this._status$.next(KafkaStatus.REBALANCING), + ); + this.consumer.on(this.consumer.events.STOP, () => + this._status$.next(KafkaStatus.STOPPED), + ); + this.consumer.on(this.consumer.events.CRASH, () => + this._status$.next(KafkaStatus.CRASHED), + ); + } + + protected registerProducerEventListeners() { + this.producer.on(this.producer.events.CONNECT, () => + this._status$.next(KafkaStatus.CONNECTED), + ); + this.producer.on(this.producer.events.DISCONNECT, () => + this._status$.next(KafkaStatus.DISCONNECTED), + ); + } + public createClient(): T { return new kafkaPackage.Kafka( Object.assign( @@ -204,6 +228,22 @@ export class ServerKafka extends Server implements CustomTransportStrategy { this.send(replayStream$, publish); } + public unwrap(): T { + if (!this.client) { + throw new Error( + 'Not initialized. Please call the "listen"/"startAllMicroservices" method before accessing the server.', + ); + } + return this.client as T; + } + + public on< + EventKey extends string | number | symbol = string | number | symbol, + EventCallback = any, + >(event: EventKey, callback: EventCallback) { + throw new Error('Method is not supported for Kafka server'); + } + private combineStreamsAndThrowIfRetriable( response$: Observable, replayStream$: ReplaySubject, diff --git a/packages/microservices/server/server-mqtt.ts b/packages/microservices/server/server-mqtt.ts index a7818a37f28..36bf74b4f6d 100644 --- a/packages/microservices/server/server-mqtt.ts +++ b/packages/microservices/server/server-mqtt.ts @@ -1,8 +1,5 @@ import { isUndefined } from '@nestjs/common/utils/shared.utils'; import { - CONNECT_EVENT, - ERROR_EVENT, - MESSAGE_EVENT, MQTT_DEFAULT_URL, MQTT_SEPARATOR, MQTT_WILDCARD_ALL, @@ -11,9 +8,8 @@ import { } from '../constants'; import { MqttContext } from '../ctx-host/mqtt.context'; import { Transport } from '../enums'; -import { MqttClient } from '../external/mqtt-client.interface'; +import { MqttEvents, MqttEventsMap, MqttStatus } from '../events/mqtt.events'; import { - CustomTransportStrategy, IncomingRequest, MessageHandler, PacketId, @@ -26,15 +22,24 @@ import { Server } from './server'; let mqttPackage: any = {}; +// To enable type safety for MQTT. This cant be uncommented by default +// because it would require the user to install the mqtt package even if they dont use MQTT +// Otherwise, TypeScript would fail to compile the code. +// +// type MqttClient = import('mqtt').MqttClient; +type MqttClient = any; + /** * @publicApi */ -export class ServerMqtt extends Server implements CustomTransportStrategy { +export class ServerMqtt extends Server { public readonly transportId = Transport.MQTT; - + protected readonly url: string; protected mqttClient: MqttClient; - - private readonly url: string; + protected pendingEventListeners: Array<{ + event: keyof MqttEvents; + callback: MqttEvents[keyof MqttEvents]; + }> = []; constructor(private readonly options: MqttOptions['options']) { super(); @@ -62,14 +67,23 @@ export class ServerMqtt extends Server implements CustomTransportStrategy { public start( callback: (err?: unknown, ...optionalParams: unknown[]) => void, ) { - this.handleError(this.mqttClient); + this.registerErrorListener(this.mqttClient); + this.registerReconnectListener(this.mqttClient); + this.registerDisconnectListener(this.mqttClient); + this.registerCloseListener(this.mqttClient); + this.registerConnectListener(this.mqttClient); + + this.pendingEventListeners.forEach(({ event, callback }) => + this.mqttClient.on(event, callback), + ); + this.pendingEventListeners = []; this.bindEvents(this.mqttClient); - this.mqttClient.on(CONNECT_EVENT, () => callback()); + this.mqttClient.on(MqttEventsMap.CONNECT, () => callback()); } public bindEvents(mqttClient: MqttClient) { - mqttClient.on(MESSAGE_EVENT, this.getMessageHandler(mqttClient).bind(this)); + mqttClient.on('message', this.getMessageHandler(mqttClient).bind(this)); const registeredPatterns = [...this.messageHandlers.keys()]; registeredPatterns.forEach(pattern => { @@ -83,6 +97,7 @@ export class ServerMqtt extends Server implements CustomTransportStrategy { public close() { this.mqttClient && this.mqttClient.end(); + this.pendingEventListeners = []; } public createMqttClient(): MqttClient { @@ -216,8 +231,54 @@ export class ServerMqtt extends Server implements CustomTransportStrategy { return `${pattern}/reply`; } - public handleError(stream: any) { - stream.on(ERROR_EVENT, (err: any) => this.logger.error(err)); + public registerErrorListener(client: MqttClient) { + client.on(MqttEventsMap.ERROR, (err: unknown) => this.logger.error(err)); + } + + public registerReconnectListener(client: MqttClient) { + client.on(MqttEventsMap.RECONNECT, () => { + this._status$.next(MqttStatus.RECONNECTING); + + this.logger.log('MQTT connection lost. Trying to reconnect...'); + }); + } + + public registerDisconnectListener(client: MqttClient) { + client.on(MqttEventsMap.DISCONNECT, () => { + this._status$.next(MqttStatus.DISCONNECTED); + }); + } + + public registerCloseListener(client: MqttClient) { + client.on(MqttEventsMap.CLOSE, () => { + this._status$.next(MqttStatus.CLOSED); + }); + } + + public registerConnectListener(client: MqttClient) { + client.on(MqttEventsMap.CONNECT, () => { + this._status$.next(MqttStatus.CONNECTED); + }); + } + + public unwrap(): T { + if (!this.mqttClient) { + throw new Error( + 'Not initialized. Please call the "listen"/"startAllMicroservices" method before accessing the server.', + ); + } + return this.mqttClient as T; + } + + public on< + EventKey extends keyof MqttEvents = keyof MqttEvents, + EventCallback extends MqttEvents[EventKey] = MqttEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.mqttClient) { + this.mqttClient.on(event, callback as any); + } else { + this.pendingEventListeners.push({ event, callback }); + } } protected initializeSerializer(options: MqttOptions['options']) { diff --git a/packages/microservices/server/server-nats.ts b/packages/microservices/server/server-nats.ts index e9c376b1292..241931afe83 100644 --- a/packages/microservices/server/server-nats.ts +++ b/packages/microservices/server/server-nats.ts @@ -1,10 +1,10 @@ -import { isUndefined, isObject } from '@nestjs/common/utils/shared.utils'; +import { isObject, isUndefined } from '@nestjs/common/utils/shared.utils'; +import { EventEmitter } from 'events'; import { NATS_DEFAULT_URL, NO_MESSAGE_HANDLER } from '../constants'; import { NatsContext } from '../ctx-host/nats.context'; import { NatsRequestJSONDeserializer } from '../deserializers/nats-request-json.deserializer'; import { Transport } from '../enums'; -import { Client, NatsMsg } from '../external/nats-client.interface'; -import { CustomTransportStrategy } from '../interfaces'; +import { NatsEvents, NatsEventsMap, NatsStatus } from '../events/nats.events'; import { NatsOptions } from '../interfaces/microservice-configuration.interface'; import { IncomingRequest } from '../interfaces/packet.interface'; import { NatsRecord } from '../record-builders'; @@ -13,13 +13,26 @@ import { Server } from './server'; let natsPackage = {} as any; +// To enable type safety for Nats. This cant be uncommented by default +// because it would require the user to install the nats package even if they dont use Nats +// Otherwise, TypeScript would fail to compile the code. +// +// type Client = import('nats').NatsConnection; +// type NatsMsg = import('nats').Msg; + +type Client = any; +type NatsMsg = any; + /** * @publicApi */ -export class ServerNats extends Server implements CustomTransportStrategy { +export class ServerNats extends Server { public readonly transportId = Transport.NATS; private natsClient: Client; + protected statusEventEmitter = new EventEmitter<{ + [key in keyof NatsEvents]: Parameters; + }>(); constructor(private readonly options: NatsOptions['options']) { super(); @@ -37,6 +50,8 @@ export class ServerNats extends Server implements CustomTransportStrategy { ) { try { this.natsClient = await this.createNatsClient(); + + this._status$.next(NatsStatus.CONNECTED); this.handleStatusUpdates(this.natsClient); this.start(callback); } catch (err) { @@ -65,6 +80,8 @@ export class ServerNats extends Server implements CustomTransportStrategy { public async close() { await this.natsClient?.close(); + this.statusEventEmitter.removeAllListeners(); + this.natsClient = null; } @@ -142,10 +159,21 @@ export class ServerNats extends Server implements CustomTransportStrategy { switch (status.type) { case 'error': + this.logger.error( + `NatsError: type: "${status.type}", data: "${data}".`, + ); + break; + case 'disconnect': this.logger.error( `NatsError: type: "${status.type}", data: "${data}".`, ); + + this._status$.next(NatsStatus.DISCONNECTED); + this.statusEventEmitter.emit( + NatsEventsMap.DISCONNECT, + status.data as string, + ); break; case 'pingTimer': @@ -156,6 +184,29 @@ export class ServerNats extends Server implements CustomTransportStrategy { } break; + case 'reconnecting': + this._status$.next(NatsStatus.RECONNECTING); + break; + + case 'reconnect': + this.logger.log( + `NatsStatus: type: "${status.type}", data: "${data}".`, + ); + + this._status$.next(NatsStatus.CONNECTED); + this.statusEventEmitter.emit( + NatsEventsMap.RECONNECT, + status.data as string, + ); + break; + + case 'update': + this.logger.log( + `NatsStatus: type: "${status.type}", data: "${data}".`, + ); + this.statusEventEmitter.emit(NatsEventsMap.UPDATE, status.data); + break; + default: this.logger.log( `NatsStatus: type: "${status.type}", data: "${data}".`, @@ -165,6 +216,22 @@ export class ServerNats extends Server implements CustomTransportStrategy { } } + public unwrap(): T { + if (!this.natsClient) { + throw new Error( + 'Not initialized. Please call the "listen"/"startAllMicroservices" method before accessing the server.', + ); + } + return this.natsClient as T; + } + + public on< + EventKey extends keyof NatsEvents = keyof NatsEvents, + EventCallback extends NatsEvents[EventKey] = NatsEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + this.statusEventEmitter.on(event, callback as any); + } + protected initializeSerializer(options: NatsOptions['options']) { this.serializer = options?.serializer ?? new NatsRecordSerializer(); } diff --git a/packages/microservices/server/server-redis.ts b/packages/microservices/server/server-redis.ts index cc6f53d69af..a9ed45c753b 100644 --- a/packages/microservices/server/server-redis.ts +++ b/packages/microservices/server/server-redis.ts @@ -1,7 +1,5 @@ import { isUndefined } from '@nestjs/common/utils/shared.utils'; import { - ERROR_EVENT, - MESSAGE_EVENT, NO_MESSAGE_HANDLER, REDIS_DEFAULT_HOST, REDIS_DEFAULT_PORT, @@ -9,12 +7,18 @@ import { import { RedisContext } from '../ctx-host'; import { Transport } from '../enums'; import { - CustomTransportStrategy, - IncomingRequest, - RedisOptions, -} from '../interfaces'; + RedisEvents, + RedisEventsMap, + RedisStatus, +} from '../events/redis.events'; +import { IncomingRequest, RedisOptions } from '../interfaces'; import { Server } from './server'; +// To enable type safety for Redis. This cant be uncommented by default +// because it would require the user to install the ioredis package even if they dont use Redis +// Otherwise, TypeScript would fail to compile the code. +// +// type Redis = import('ioredis').Redis; type Redis = any; let redisPackage = {} as any; @@ -22,14 +26,19 @@ let redisPackage = {} as any; /** * @publicApi */ -export class ServerRedis extends Server implements CustomTransportStrategy { +export class ServerRedis extends Server { public readonly transportId = Transport.REDIS; - private subClient: Redis; - private pubClient: Redis; - private isExplicitlyTerminated = false; + protected subClient: Redis; + protected pubClient: Redis; + protected isManuallyClosed = false; + protected wasInitialConnectionSuccessful = false; + protected pendingEventListeners: Array<{ + event: keyof RedisEvents; + callback: RedisEvents[keyof RedisEvents]; + }> = []; - constructor(private readonly options: RedisOptions['options']) { + constructor(protected readonly options: RedisOptions['options']) { super(); redisPackage = this.loadPackage('ioredis', ServerRedis.name, () => @@ -47,8 +56,17 @@ export class ServerRedis extends Server implements CustomTransportStrategy { this.subClient = this.createRedisClient(); this.pubClient = this.createRedisClient(); - this.handleError(this.pubClient); - this.handleError(this.subClient); + [this.subClient, this.pubClient].forEach((client, index) => { + const type = index === 0 ? 'pub' : 'sub'; + this.registerErrorListener(client); + this.registerReconnectListener(client); + this.registerReadyListener(client); + this.registerEndListener(client); + this.pendingEventListeners.forEach(({ event, callback }) => + client.on(event, (...args: [any]) => callback(type, ...args)), + ); + }); + this.pendingEventListeners = []; this.start(callback); } catch (err) { @@ -67,7 +85,7 @@ export class ServerRedis extends Server implements CustomTransportStrategy { public bindEvents(subClient: Redis, pubClient: Redis) { subClient.on( - this.options?.wildcards ? 'pmessage' : MESSAGE_EVENT, + this.options?.wildcards ? 'pmessage' : 'message', this.getMessageHandler(pubClient).bind(this), ); const subscribePatterns = [...this.messageHandlers.keys()]; @@ -87,9 +105,10 @@ export class ServerRedis extends Server implements CustomTransportStrategy { } public close() { - this.isExplicitlyTerminated = true; + this.isManuallyClosed = true; this.pubClient && this.pubClient.quit(); this.subClient && this.subClient.quit(); + this.pendingEventListeners = []; } public createRedisClient(): Redis { @@ -172,8 +191,52 @@ export class ServerRedis extends Server implements CustomTransportStrategy { return `${pattern}.reply`; } - public handleError(stream: any) { - stream.on(ERROR_EVENT, (err: any) => this.logger.error(err)); + public registerErrorListener(client: any) { + client.on(RedisEventsMap.ERROR, (err: any) => this.logger.error(err)); + } + + public registerReconnectListener(client: { + on: (event: string, fn: () => void) => void; + }) { + client.on(RedisEventsMap.RECONNECTING, () => { + if (this.isManuallyClosed) { + return; + } + this._status$.next(RedisStatus.RECONNECTING); + + if (this.wasInitialConnectionSuccessful) { + this.logger.log('Reconnecting to Redis...'); + } + }); + } + + public registerReadyListener(client: { + on: (event: string, fn: () => void) => void; + }) { + client.on(RedisEventsMap.READY, () => { + this._status$.next(RedisStatus.CONNECTED); + + this.logger.log('Connected to Redis. Subscribing to channels...'); + + if (!this.wasInitialConnectionSuccessful) { + this.wasInitialConnectionSuccessful = true; + } + }); + } + + public registerEndListener(client: { + on: (event: string, fn: () => void) => void; + }) { + client.on('end', () => { + if (this.isManuallyClosed) { + return; + } + this._status$.next(RedisStatus.DISCONNECTED); + + this.logger.error( + 'Disconnected from Redis. No further reconnection attempts will be made.', + ); + }); } public getClientOptions(): Partial { @@ -186,16 +249,40 @@ export class ServerRedis extends Server implements CustomTransportStrategy { } public createRetryStrategy(times: number): undefined | number | void { - if (this.isExplicitlyTerminated) { + if (this.isManuallyClosed) { return undefined; } - if ( - !this.getOptionsProp(this.options, 'retryAttempts') || - times > this.getOptionsProp(this.options, 'retryAttempts') - ) { + if (!this.getOptionsProp(this.options, 'retryAttempts')) { + this.logger.error( + 'Redis connection closed and retry attempts not specified', + ); + return; + } + if (times > this.getOptionsProp(this.options, 'retryAttempts')) { this.logger.error(`Retry time exhausted`); return; } - return this.getOptionsProp(this.options, 'retryDelay') || 0; + return this.getOptionsProp(this.options, 'retryDelay') ?? 5000; + } + + public unwrap(): T { + if (!this.pubClient || !this.subClient) { + throw new Error( + 'Not initialized. Please call the "listen"/"startAllMicroservices" method before accessing the server.', + ); + } + return [this.pubClient, this.subClient] as T; + } + + public on< + EventKey extends keyof RedisEvents = keyof RedisEvents, + EventCallback extends RedisEvents[EventKey] = RedisEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.subClient && this.pubClient) { + this.subClient.on(event, (...args: [any]) => callback('sub', ...args)); + this.pubClient.on(event, (...args: [any]) => callback('pub', ...args)); + } else { + this.pendingEventListeners.push({ event, callback }); + } } } diff --git a/packages/microservices/server/server-rmq.ts b/packages/microservices/server/server-rmq.ts index c0ea0bd8ff4..418c06e297c 100644 --- a/packages/microservices/server/server-rmq.ts +++ b/packages/microservices/server/server-rmq.ts @@ -5,10 +5,7 @@ import { } from '@nestjs/common/utils/shared.utils'; import { CONNECTION_FAILED_MESSAGE, - CONNECT_EVENT, - CONNECT_FAILED_EVENT, DISCONNECTED_RMQ_MESSAGE, - DISCONNECT_EVENT, NO_MESSAGE_HANDLER, RQM_DEFAULT_IS_GLOBAL_PREFETCH_COUNT, RQM_DEFAULT_NOACK, @@ -22,8 +19,9 @@ import { } from '../constants'; import { RmqContext } from '../ctx-host'; import { Transport } from '../enums'; +import { RmqEvents, RmqEventsMap, RmqStatus } from '../events/rmq.events'; import { RmqUrl } from '../external/rmq-url.interface'; -import { CustomTransportStrategy, RmqOptions } from '../interfaces'; +import { RmqOptions } from '../interfaces'; import { IncomingRequest, OutgoingResponse, @@ -32,45 +30,50 @@ import { import { RmqRecordSerializer } from '../serializers/rmq-record.serializer'; import { Server } from './server'; -let rmqPackage: any = {}; +// To enable type safety for RMQ. This cant be uncommented by default +// because it would require the user to install the amqplib package even if they dont use RabbitMQ +// Otherwise, TypeScript would fail to compile the code. +// +type AmqpConnectionManager = + import('amqp-connection-manager').AmqpConnectionManager; +type ChannelWrapper = import('amqp-connection-manager').ChannelWrapper; +type Message = import('amqplib').Message; + +// type AmqpConnectionManager = any; +// type ChannelWrapper = any; +// type Message = any; + +let rmqPackage = {} as any; // as typeof import('amqp-connection-manager'); const INFINITE_CONNECTION_ATTEMPTS = -1; /** * @publicApi */ -export class ServerRMQ extends Server implements CustomTransportStrategy { +export class ServerRMQ extends Server { public readonly transportId = Transport.RMQ; - protected server: any = null; - protected channel: any = null; + protected server: AmqpConnectionManager = null; + protected channel: ChannelWrapper = null; protected connectionAttempts = 0; protected readonly urls: string[] | RmqUrl[]; protected readonly queue: string; - protected readonly prefetchCount: number; protected readonly noAck: boolean; protected readonly queueOptions: any; - protected readonly isGlobalPrefetchCount: boolean; - protected readonly noAssert: boolean; + protected pendingEventListeners: Array<{ + event: keyof RmqEvents; + callback: RmqEvents[keyof RmqEvents]; + }> = []; + constructor(protected readonly options: RmqOptions['options']) { super(); this.urls = this.getOptionsProp(this.options, 'urls') || [RQM_DEFAULT_URL]; this.queue = this.getOptionsProp(this.options, 'queue') || RQM_DEFAULT_QUEUE; - this.prefetchCount = - this.getOptionsProp(this.options, 'prefetchCount') || - RQM_DEFAULT_PREFETCH_COUNT; this.noAck = this.getOptionsProp(this.options, 'noAck', RQM_DEFAULT_NOACK); - this.isGlobalPrefetchCount = - this.getOptionsProp(this.options, 'isGlobalPrefetchCount') || - RQM_DEFAULT_IS_GLOBAL_PREFETCH_COUNT; this.queueOptions = this.getOptionsProp(this.options, 'queueOptions') || RQM_DEFAULT_QUEUE_OPTIONS; - this.noAssert = - this.getOptionsProp(this.options, 'noAssert') ?? - this.queueOptions.noAssert ?? - RQM_DEFAULT_NO_ASSERT; this.loadPackage('amqplib', ServerRMQ.name, () => require('amqplib')); rmqPackage = this.loadPackage( @@ -96,16 +99,18 @@ export class ServerRMQ extends Server implements CustomTransportStrategy { public close(): void { this.channel && this.channel.close(); this.server && this.server.close(); + this.pendingEventListeners = []; } public async start( callback?: (err?: unknown, ...optionalParams: unknown[]) => void, ) { this.server = this.createClient(); - this.server.on(CONNECT_EVENT, () => { + this.server.once(RmqEventsMap.CONNECT, () => { if (this.channel) { return; } + this._status$.next(RmqStatus.CONNECTED); this.channel = this.server.createChannel({ json: false, setup: (channel: any) => this.setupChannel(channel, callback), @@ -117,11 +122,18 @@ export class ServerRMQ extends Server implements CustomTransportStrategy { 'maxConnectionAttempts', INFINITE_CONNECTION_ATTEMPTS, ); - this.server.on(DISCONNECT_EVENT, (err: any) => { - this.logger.error(DISCONNECTED_RMQ_MESSAGE); - this.logger.error(err); - }); - this.server.on(CONNECT_FAILED_EVENT, (error: Record) => { + + this.registerConnectListener(); + this.registerDisconnectListener(); + this.pendingEventListeners.forEach(({ event, callback }) => + this.server.on(event, callback), + ); + this.pendingEventListeners = []; + + const connectFailedEvent = 'connectFailed'; + this.server.once(connectFailedEvent, (error: Record) => { + this._status$.next(RmqStatus.DISCONNECTED); + this.logger.error(CONNECTION_FAILED_MESSAGE); if (error?.err) { this.logger.error(error.err); @@ -149,11 +161,41 @@ export class ServerRMQ extends Server implements CustomTransportStrategy { }); } + private registerConnectListener() { + this.server.on(RmqEventsMap.CONNECT, (err: any) => { + this._status$.next(RmqStatus.CONNECTED); + }); + } + + private registerDisconnectListener() { + this.server.on(RmqEventsMap.DISCONNECT, (err: any) => { + this._status$.next(RmqStatus.DISCONNECTED); + this.logger.error(DISCONNECTED_RMQ_MESSAGE); + this.logger.error(err); + }); + } + public async setupChannel(channel: any, callback: Function) { - if (!this.noAssert) { + const noAssert = + this.getOptionsProp(this.options, 'noAssert') ?? + this.queueOptions.noAssert ?? + RQM_DEFAULT_NO_ASSERT; + + if (!noAssert) { await channel.assertQueue(this.queue, this.queueOptions); } - await channel.prefetch(this.prefetchCount, this.isGlobalPrefetchCount); + + const isGlobalPrefetchCount = this.getOptionsProp( + this.options, + 'isGlobalPrefetchCount', + RQM_DEFAULT_IS_GLOBAL_PREFETCH_COUNT, + ); + const prefetchCount = this.getOptionsProp( + this.options, + 'prefetchCount', + RQM_DEFAULT_PREFETCH_COUNT, + ); + await channel.prefetch(prefetchCount, isGlobalPrefetchCount); channel.consume( this.queue, (msg: Record) => this.handleMessage(msg, channel), @@ -192,7 +234,7 @@ export class ServerRMQ extends Server implements CustomTransportStrategy { if (!handler) { if (!this.noAck) { this.logger.warn(RQM_NO_MESSAGE_HANDLER`${pattern}`); - this.channel.nack(rmqContext.getMessage(), false, false); + this.channel.nack(rmqContext.getMessage() as Message, false, false); } const status = 'error'; const noHandlerPacket = { @@ -223,7 +265,7 @@ export class ServerRMQ extends Server implements CustomTransportStrategy { ): Promise { const handler = this.getHandlerByPattern(pattern); if (!handler && !this.noAck) { - this.channel.nack(context.getMessage(), false, false); + this.channel.nack(context.getMessage() as Message, false, false); return this.logger.warn(RQM_NO_EVENT_HANDLER`${pattern}`); } return super.handleEvent(pattern, packet, context); @@ -244,6 +286,26 @@ export class ServerRMQ extends Server implements CustomTransportStrategy { this.channel.sendToQueue(replyTo, buffer, { correlationId, ...options }); } + public unwrap(): T { + if (!this.server) { + throw new Error( + 'Not initialized. Please call the "listen"/"startAllMicroservices" method before accessing the server.', + ); + } + return this.server as T; + } + + public on< + EventKey extends keyof RmqEvents = keyof RmqEvents, + EventCallback extends RmqEvents[EventKey] = RmqEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.server) { + this.server.addListener(event, callback); + } else { + this.pendingEventListeners.push({ event, callback }); + } + } + protected initializeSerializer(options: RmqOptions['options']) { this.serializer = options?.serializer ?? new RmqRecordSerializer(); } diff --git a/packages/microservices/server/server-tcp.ts b/packages/microservices/server/server-tcp.ts index b76a21c11f5..56c9c3b64f0 100644 --- a/packages/microservices/server/server-tcp.ts +++ b/packages/microservices/server/server-tcp.ts @@ -2,22 +2,19 @@ import { Type } from '@nestjs/common'; import { isString, isUndefined } from '@nestjs/common/utils/shared.utils'; import * as net from 'net'; import { Server as NetSocket, Socket } from 'net'; +import { createServer as tlsCreateServer, TlsOptions } from 'tls'; import { - CLOSE_EVENT, EADDRINUSE, ECONNREFUSED, - ERROR_EVENT, - MESSAGE_EVENT, NO_MESSAGE_HANDLER, TCP_DEFAULT_HOST, TCP_DEFAULT_PORT, } from '../constants'; import { TcpContext } from '../ctx-host/tcp.context'; import { Transport } from '../enums'; +import { TcpEvents, TcpEventsMap, TcpStatus } from '../events/tcp.events'; import { JsonSocket, TcpSocket } from '../helpers'; -import { createServer as tlsCreateServer } from 'tls'; import { - CustomTransportStrategy, IncomingRequest, PacketId, ReadPacket, @@ -29,17 +26,20 @@ import { Server } from './server'; /** * @publicApi */ -export class ServerTCP extends Server implements CustomTransportStrategy { +export class ServerTCP extends Server { public readonly transportId = Transport.TCP; protected server: NetSocket; - - private readonly port: number; - private readonly host: string; - private readonly socketClass: Type; - private isExplicitlyTerminated = false; - private retryAttemptsCount = 0; - private tlsOptions?; + protected readonly port: number; + protected readonly host: string; + protected readonly socketClass: Type; + protected isManuallyTerminated = false; + protected retryAttemptsCount = 0; + protected tlsOptions?: TlsOptions; + protected pendingEventListeners: Array<{ + event: keyof TcpEvents; + callback: TcpEvents[keyof TcpEvents]; + }> = []; constructor(private readonly options: TcpOptions['options']) { super(); @@ -57,8 +57,10 @@ export class ServerTCP extends Server implements CustomTransportStrategy { public listen( callback: (err?: unknown, ...optionalParams: unknown[]) => void, ) { - this.server.once(ERROR_EVENT, (err: Record) => { + this.server.once(TcpEventsMap.ERROR, (err: Record) => { if (err?.code === EADDRINUSE || err?.code === ECONNREFUSED) { + this._status$.next(TcpStatus.DISCONNECTED); + return callback(err); } }); @@ -66,17 +68,18 @@ export class ServerTCP extends Server implements CustomTransportStrategy { } public close() { - this.isExplicitlyTerminated = true; + this.isManuallyTerminated = true; this.server.close(); + this.pendingEventListeners = []; } public bindHandler(socket: Socket) { const readSocket = this.getSocketInstance(socket); - readSocket.on(MESSAGE_EVENT, async (msg: ReadPacket & PacketId) => + readSocket.on('message', async (msg: ReadPacket & PacketId) => this.handleMessage(readSocket, msg), ); - readSocket.on(ERROR_EVENT, this.handleError.bind(this)); + readSocket.on(TcpEventsMap.ERROR, this.handleError.bind(this)); } public async handleMessage(socket: TcpSocket, rawMessage: unknown) { @@ -89,6 +92,7 @@ export class ServerTCP extends Server implements CustomTransportStrategy { if (isUndefined((packet as IncomingRequest).id)) { return this.handleEvent(pattern, packet, tcpContext); } + const handler = this.getHandlerByPattern(pattern); if (!handler) { const status = 'error'; @@ -115,7 +119,7 @@ export class ServerTCP extends Server implements CustomTransportStrategy { public handleClose(): undefined | number | NodeJS.Timer { if ( - this.isExplicitlyTerminated || + this.isManuallyTerminated || !this.getOptionsProp(this.options, 'retryAttempts') || this.retryAttemptsCount >= this.getOptionsProp(this.options, 'retryAttempts') @@ -129,7 +133,27 @@ export class ServerTCP extends Server implements CustomTransportStrategy { ); } - private init() { + public unwrap(): T { + if (!this.server) { + throw new Error( + 'Not initialized. Please call the "listen"/"startAllMicroservices" method before accessing the server.', + ); + } + return this.server as T; + } + + public on< + EventKey extends keyof TcpEvents = keyof TcpEvents, + EventCallback extends TcpEvents[EventKey] = TcpEvents[EventKey], + >(event: EventKey, callback: EventCallback) { + if (this.server) { + this.server.on(event, callback as any); + } else { + this.pendingEventListeners.push({ event, callback }); + } + } + + protected init() { if (this.tlsOptions) { // TLS enabled, use tls server this.server = tlsCreateServer( @@ -140,11 +164,39 @@ export class ServerTCP extends Server implements CustomTransportStrategy { // TLS disabled, use net server this.server = net.createServer(this.bindHandler.bind(this)); } - this.server.on(ERROR_EVENT, this.handleError.bind(this)); - this.server.on(CLOSE_EVENT, this.handleClose.bind(this)); + this.registerListeningListener(this.server); + this.registerErrorListener(this.server); + this.registerCloseListener(this.server); + + this.pendingEventListeners.forEach(({ event, callback }) => + this.server.on(event, callback), + ); + this.pendingEventListeners = []; + } + + protected registerListeningListener(socket: net.Server) { + socket.on(TcpEventsMap.LISTENING, () => { + this._status$.next(TcpStatus.CONNECTED); + }); + } + + protected registerErrorListener(socket: net.Server) { + socket.on(TcpEventsMap.ERROR, err => { + if ('code' in err && err.code === ECONNREFUSED) { + this._status$.next(TcpStatus.DISCONNECTED); + } + this.handleError(err as any); + }); + } + + protected registerCloseListener(socket: net.Server) { + socket.on(TcpEventsMap.CLOSE, () => { + this._status$.next(TcpStatus.DISCONNECTED); + this.handleClose(); + }); } - private getSocketInstance(socket: Socket): TcpSocket { + protected getSocketInstance(socket: Socket): TcpSocket { return new this.socketClass(socket); } } diff --git a/packages/microservices/server/server.ts b/packages/microservices/server/server.ts index 076900054f3..4e86e1ee51d 100644 --- a/packages/microservices/server/server.ts +++ b/packages/microservices/server/server.ts @@ -8,13 +8,20 @@ import { Observable, ObservedValueOf, of, + ReplaySubject, Subject, Subscription, } from 'rxjs'; -import { catchError, finalize, mergeMap } from 'rxjs/operators'; +import { + catchError, + distinctUntilChanged, + finalize, + mergeMap, +} from 'rxjs/operators'; import { NO_EVENT_HANDLER } from '../constants'; import { BaseRpcContext } from '../ctx-host/base-rpc.context'; import { IncomingRequestDeserializer } from '../deserializers/incoming-request.deserializer'; +import { Transport } from '../enums'; import { ClientOptions, KafkaOptions, @@ -37,11 +44,52 @@ import { transformPatternToRoute } from '../utils'; /** * @publicApi */ -export abstract class Server { +export abstract class Server< + EventsMap extends Record = Record, + Status extends string = string, +> { + /** + * Unique transport identifier. + */ + readonly transportId?: Transport | symbol; + protected readonly messageHandlers = new Map(); protected readonly logger: LoggerService = new Logger(Server.name); protected serializer: ConsumerSerializer; protected deserializer: ConsumerDeserializer; + protected _status$ = new ReplaySubject(1); + + /** + * Returns an observable that emits status changes. + */ + public get status(): Observable { + return this._status$.asObservable().pipe(distinctUntilChanged()); + } + + /** + * Registers an event listener for the given event. + * @param event Event name + * @param callback Callback to be executed when the event is emitted + */ + public abstract on< + EventKey extends keyof EventsMap = keyof EventsMap, + EventCallback extends EventsMap[EventKey] = EventsMap[EventKey], + >(event: EventKey, callback: EventCallback): any; + /** + * Returns an instance of the underlying server/broker instance, + * or a group of servers if there are more than one. + */ + public abstract unwrap(): T; + + /** + * Method called when server is being initialized. + * @param callback Function to be called upon initialization + */ + public abstract listen(callback: (...optionalParams: unknown[]) => any): any; + /** + * Method called when server is being terminated. + */ + public abstract close(): any; public addHandler( pattern: any, diff --git a/packages/microservices/test/client/client-kafka.spec.ts b/packages/microservices/test/client/client-kafka.spec.ts index c6c030ee616..0d404817b8f 100644 --- a/packages/microservices/test/client/client-kafka.spec.ts +++ b/packages/microservices/test/client/client-kafka.spec.ts @@ -176,6 +176,21 @@ describe('ClientKafka', () => { run, events: { GROUP_JOIN: 'consumer.group_join', + HEARTBEAT: 'consumer.heartbeat', + COMMIT_OFFSETS: 'consumer.commit_offsets', + FETCH_START: 'consumer.fetch_start', + FETCH: 'consumer.fetch', + START_BATCH_PROCESS: 'consumer.start_batch_process', + END_BATCH_PROCESS: 'consumer.end_batch_process', + CONNECT: 'consumer.connect', + DISCONNECT: 'consumer.disconnect', + STOP: 'consumer.stop', + CRASH: 'consumer.crash', + REBALANCING: 'consumer.rebalancing', + RECEIVED_UNSUBSCRIBED_TOPICS: 'consumer.received_unsubscribed_topics', + REQUEST: 'consumer.network.request', + REQUEST_TIMEOUT: 'consumer.network.request_timeout', + REQUEST_QUEUE_SIZE: 'consumer.network.request_queue_size', }, on, }; @@ -184,6 +199,14 @@ describe('ClientKafka', () => { return { connect, send, + events: { + CONNECT: 'producer.connect', + DISCONNECT: 'producer.disconnect', + REQUEST: 'producer.network.request', + REQUEST_TIMEOUT: 'producer.network.request_timeout', + REQUEST_QUEUE_SIZE: 'producer.network.request_queue_size', + }, + on, }; }); kafkaClient = { @@ -250,16 +273,16 @@ describe('ClientKafka', () => { const consumer = { disconnect: sinon.stub().resolves() }; const producer = { disconnect: sinon.stub().resolves() }; beforeEach(() => { - (client as any).consumer = consumer; - (client as any).producer = producer; + (client as any)._consumer = consumer; + (client as any)._producer = producer; }); it('should close server', async () => { await client.close(); expect(consumer.disconnect.calledOnce).to.be.true; expect(producer.disconnect.calledOnce).to.be.true; - expect((client as any).consumer).to.be.null; - expect((client as any).producer).to.be.null; + expect((client as any)._consumer).to.be.null; + expect((client as any)._producer).to.be.null; expect((client as any).client).to.be.null; }); }); @@ -267,7 +290,6 @@ describe('ClientKafka', () => { describe('connect', () => { let consumerAssignmentsStub: sinon.SinonStub; let bindTopicsStub: sinon.SinonStub; - // let handleErrorsSpy: sinon.SinonSpy; describe('consumer and producer', () => { beforeEach(() => { @@ -285,14 +307,10 @@ describe('ClientKafka', () => { expect(createClientStub.calledOnce).to.be.true; expect(producerStub.calledOnce).to.be.true; - expect(consumerStub.calledOnce).to.be.true; - - expect(on.calledOnce).to.be.true; + expect(on.called).to.be.true; expect(client['consumerAssignments']).to.be.empty; - expect(connect.calledTwice).to.be.true; - expect(bindTopicsStub.calledOnce).to.be.true; expect(connection).to.deep.equal(producerStub()); }); @@ -428,7 +446,7 @@ describe('ClientKafka', () => { describe('bindTopics', () => { it('should bind topics from response patterns', async () => { (client as any).responsePatterns = [replyTopic]; - (client as any).consumer = kafkaClient.consumer(); + (client as any)._consumer = kafkaClient.consumer(); await client.bindTopics(); @@ -443,7 +461,7 @@ describe('ClientKafka', () => { it('should bind topics from response patterns with options', async () => { (client as any).responsePatterns = [replyTopic]; - (client as any).consumer = kafkaClient.consumer(); + (client as any)._consumer = kafkaClient.consumer(); (client as any).options.subscribe = {}; (client as any).options.subscribe.fromBeginning = true; @@ -567,7 +585,7 @@ describe('ClientKafka', () => { }); it('should publish packet', async () => { - sinon.stub(client as any, 'producer').value({ + sinon.stub(client as any, '_producer').value({ send: sendSpy, }); @@ -636,13 +654,14 @@ describe('ClientKafka', () => { }); describe('publish', () => { + const waitForNextTick = async () => + await new Promise(resolve => process.nextTick(resolve)); const readPacket = { pattern: topic, data: messageValue, }; let assignPacketIdStub: sinon.SinonStub; - let normalizePatternSpy: sinon.SinonSpy; let getResponsePatternNameSpy: sinon.SinonSpy; let getReplyTopicPartitionSpy: sinon.SinonSpy; @@ -650,7 +669,6 @@ describe('ClientKafka', () => { let sendSpy: sinon.SinonSpy; beforeEach(() => { - // spy normalizePatternSpy = sinon.spy(client as any, 'normalizePattern'); getResponsePatternNameSpy = sinon.spy( client as any, @@ -663,7 +681,6 @@ describe('ClientKafka', () => { routingMapSetSpy = sinon.spy((client as any).routingMap, 'set'); sendSpy = sinon.spy(() => Promise.resolve()); - // stub assignPacketIdStub = sinon .stub(client as any, 'assignPacketId') .callsFake(packet => @@ -672,45 +689,61 @@ describe('ClientKafka', () => { }), ); - sinon.stub(client as any, 'producer').value({ + sinon.stub(client as any, '_producer').value({ send: sendSpy, }); - // set client['consumerAssignments'] = { [replyTopic]: parseFloat(replyPartition), }; }); it('should assign a packet id', async () => { - await client['publish'](readPacket, callback); + client['publish'](readPacket, callback); + + await waitForNextTick(); + expect(assignPacketIdStub.calledWith(readPacket)).to.be.true; }); it('should normalize the pattern', async () => { - await client['publish'](readPacket, callback); + client['publish'](readPacket, callback); + + await waitForNextTick(); + expect(normalizePatternSpy.calledWith(topic)).to.be.true; }); it('should get the reply pattern', async () => { - await client['publish'](readPacket, callback); + client['publish'](readPacket, callback); + + await waitForNextTick(); + expect(getResponsePatternNameSpy.calledWith(topic)).to.be.true; }); it('should get the reply partition', async () => { - await client['publish'](readPacket, callback); + client['publish'](readPacket, callback); + + await waitForNextTick(); + expect(getReplyTopicPartitionSpy.calledWith(replyTopic)).to.be.true; }); it('should add the callback to the routing map', async () => { - await client['publish'](readPacket, callback); + client['publish'](readPacket, callback); + + await waitForNextTick(); + expect(routingMapSetSpy.calledOnce).to.be.true; expect(routingMapSetSpy.args[0][0]).to.eq(correlationId); expect(routingMapSetSpy.args[0][1]).to.eq(callback); }); it('should send the message with headers', async () => { - await client['publish'](readPacket, callback); + client['publish'](readPacket, callback); + + await waitForNextTick(); expect(sendSpy.calledOnce).to.be.true; expect(sendSpy.args[0][0].topic).to.eq(topic); @@ -731,6 +764,9 @@ describe('ClientKafka', () => { it('should remove callback from routing map when unsubscribe', async () => { client['publish'](readPacket, callback)(); + + await waitForNextTick(); + expect(client['routingMap'].has(correlationId)).to.be.false; expect(client['routingMap'].size).to.eq(0); }); @@ -744,7 +780,7 @@ describe('ClientKafka', () => { throw new Error(); }); - clientProducerStub = sinon.stub(client as any, 'producer').value({ + clientProducerStub = sinon.stub(client as any, '_producer').value({ send: sendStub, }); }); diff --git a/packages/microservices/test/client/client-mqtt.spec.ts b/packages/microservices/test/client/client-mqtt.spec.ts index cab94c152a3..fa484d210be 100644 --- a/packages/microservices/test/client/client-mqtt.spec.ts +++ b/packages/microservices/test/client/client-mqtt.spec.ts @@ -2,7 +2,7 @@ import { expect } from 'chai'; import { EMPTY } from 'rxjs'; import * as sinon from 'sinon'; import { ClientMqtt } from '../../client/client-mqtt'; -import { ERROR_EVENT } from '../../constants'; +import { MqttEventsMap } from '../../events/mqtt.events'; import { ReadPacket } from '../../interfaces'; import { MqttRecord } from '../../record-builders'; @@ -245,7 +245,7 @@ describe('ClientMqtt', () => { }); describe('connect', () => { let createClientStub: sinon.SinonStub; - let handleErrorsSpy: sinon.SinonSpy; + let registerErrorListenerSpy: sinon.SinonSpy; let connect$Stub: sinon.SinonStub; let mergeCloseEvent: sinon.SinonStub; @@ -255,9 +255,10 @@ describe('ClientMqtt', () => { ({ addListener: () => ({}), removeListener: () => ({}), + on: () => ({}), }) as any, ); - handleErrorsSpy = sinon.spy(client, 'handleError'); + registerErrorListenerSpy = sinon.spy(client, 'registerErrorListener'); connect$Stub = sinon.stub(client, 'connect$' as any).callsFake(() => ({ subscribe: ({ complete }) => complete(), pipe() { @@ -270,7 +271,7 @@ describe('ClientMqtt', () => { }); afterEach(() => { createClientStub.restore(); - handleErrorsSpy.restore(); + registerErrorListenerSpy.restore(); connect$Stub.restore(); mergeCloseEvent.restore(); }); @@ -279,8 +280,8 @@ describe('ClientMqtt', () => { client['mqttClient'] = null; await client.connect(); }); - it('should call "handleError" once', async () => { - expect(handleErrorsSpy.called).to.be.true; + it('should call "registerErrorListener" once', async () => { + expect(registerErrorListenerSpy.called).to.be.true; }); it('should call "createClient" once', async () => { expect(createClientStub.called).to.be.true; @@ -296,8 +297,8 @@ describe('ClientMqtt', () => { it('should not call "createClient"', () => { expect(createClientStub.called).to.be.false; }); - it('should not call "handleError"', () => { - expect(handleErrorsSpy.called).to.be.false; + it('should not call "registerErrorListener"', () => { + expect(registerErrorListenerSpy.called).to.be.false; }); it('should not call "connect$"', () => { expect(connect$Stub.called).to.be.false; @@ -316,14 +317,54 @@ describe('ClientMqtt', () => { }); }); }); - describe('handleError', () => { + describe('registerErrorListener', () => { it('should bind error event handler', () => { const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); const emitter = { - addListener: callback, + on: callback, }; - client.handleError(emitter as any); - expect(callback.getCall(0).args[0]).to.be.eql(ERROR_EVENT); + client.registerErrorListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(MqttEventsMap.ERROR); + }); + }); + describe('registerConnectListener', () => { + it('should bind connect event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerConnectListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(MqttEventsMap.CONNECT); + }); + }); + describe('registerDisconnectListener', () => { + it('should bind disconnect event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerDisconnectListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(MqttEventsMap.DISCONNECT); + }); + }); + describe('registerOfflineListener', () => { + it('should bind offline event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerOfflineListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(MqttEventsMap.OFFLINE); + }); + }); + describe('registerCloseListener', () => { + it('should bind close event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerCloseListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(MqttEventsMap.CLOSE); }); }); describe('dispatchEvent', () => { diff --git a/packages/microservices/test/client/client-nats.spec.ts b/packages/microservices/test/client/client-nats.spec.ts index d9f93bc2170..cfb701abf13 100644 --- a/packages/microservices/test/client/client-nats.spec.ts +++ b/packages/microservices/test/client/client-nats.spec.ts @@ -241,7 +241,7 @@ describe('ClientNats', () => { beforeEach(async () => { createClientSpy = sinon .stub(client, 'createClient') - .callsFake(() => ({}) as any); + .callsFake(() => Promise.resolve({})); handleStatusUpdatesSpy = sinon.spy(client, 'handleStatusUpdates'); await client.connect(); @@ -253,7 +253,7 @@ describe('ClientNats', () => { describe('when is not connected', () => { beforeEach(async () => { client['natsClient'] = null; - client['clientConnectionPromise'] = null; + client['connectionPromise'] = null; await client.connect(); }); it('should call "handleStatusUpdatesSpy" once', async () => { diff --git a/packages/microservices/test/client/client-proxy.spec.ts b/packages/microservices/test/client/client-proxy.spec.ts index ea1a912dc10..e0afccdb036 100644 --- a/packages/microservices/test/client/client-proxy.spec.ts +++ b/packages/microservices/test/client/client-proxy.spec.ts @@ -8,9 +8,15 @@ class TestClientProxy extends ClientProxy { protected async dispatchEvent( packet: ReadPacket, ): Promise {} + public async connect() { return Promise.resolve(); } + + public unwrap(): T { + throw new Error('Method not implemented.'); + } + public publish(pattern, callback): any {} public async close() {} } diff --git a/packages/microservices/test/client/client-redis.spec.ts b/packages/microservices/test/client/client-redis.spec.ts index 6cae9da8565..eaadb6cac08 100644 --- a/packages/microservices/test/client/client-redis.spec.ts +++ b/packages/microservices/test/client/client-redis.spec.ts @@ -1,7 +1,7 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { ClientRedis } from '../../client/client-redis'; -import { ERROR_EVENT } from '../../constants'; +import { RedisEventsMap } from '../../events/redis.events'; describe('ClientRedis', () => { const test = 'test'; @@ -28,8 +28,8 @@ describe('ClientRedis', () => { removeListenerSpy: sinon.SinonSpy, unsubscribeSpy: sinon.SinonSpy, connectSpy: sinon.SinonSpy, - sub, - pub; + sub: any, + pub: any; beforeEach(() => { subscribeSpy = sinon.spy((name, fn) => fn()); @@ -43,7 +43,6 @@ describe('ClientRedis', () => { on: (type, handler) => (type === 'subscribe' ? handler() : onSpy()), removeListener: removeListenerSpy, unsubscribe: unsubscribeSpy, - addListener: () => ({}), }; pub = { publish: publishSpy }; (client as any).subClient = sub; @@ -98,7 +97,7 @@ describe('ClientRedis', () => { getReplyPatternStub = sinon .stub(client, 'getReplyPattern') .callsFake(() => channel); - subscription = await client['publish'](msg, callback); + subscription = client['publish'](msg, callback); subscription(channel, JSON.stringify({ isDisposed: true, id })); }); afterEach(() => { @@ -181,23 +180,26 @@ describe('ClientRedis', () => { }); }); describe('close', () => { + const untypedClient = client as any; + let pubClose: sinon.SinonSpy; let subClose: sinon.SinonSpy; - let pub, sub; + let pub: any, sub: any; + beforeEach(() => { pubClose = sinon.spy(); subClose = sinon.spy(); pub = { quit: pubClose }; sub = { quit: subClose }; - (client as any).pubClient = pub; - (client as any).subClient = sub; + untypedClient.pubClient = pub; + untypedClient.subClient = sub; }); it('should close "pub" when it is not null', () => { client.close(); expect(pubClose.called).to.be.true; }); it('should not close "pub" when it is null', () => { - (client as any).pubClient = null; + untypedClient.pubClient = null; client.close(); expect(pubClose.called).to.be.false; }); @@ -206,47 +208,80 @@ describe('ClientRedis', () => { expect(subClose.called).to.be.true; }); it('should not close "sub" when it is null', () => { - (client as any).subClient = null; + untypedClient.subClient = null; client.close(); expect(subClose.called).to.be.false; }); }); describe('connect', () => { let createClientSpy: sinon.SinonSpy; - let handleErrorsSpy: sinon.SinonSpy; + let registerErrorListenerSpy: sinon.SinonSpy; beforeEach(() => { createClientSpy = sinon.stub(client, 'createClient').callsFake( () => ({ + on: () => null, addListener: () => null, removeListener: () => null, }) as any, ); - handleErrorsSpy = sinon.spy(client, 'handleError'); + registerErrorListenerSpy = sinon.spy(client, 'registerErrorListener'); client.connect(); client['pubClient'] = null; }); afterEach(() => { createClientSpy.restore(); - handleErrorsSpy.restore(); + registerErrorListenerSpy.restore(); }); it('should call "createClient" twice', () => { expect(createClientSpy.calledTwice).to.be.true; }); - it('should call "handleError" twice', () => { - expect(handleErrorsSpy.calledTwice).to.be.true; + it('should call "registerErrorListener" twice', () => { + expect(registerErrorListenerSpy.calledTwice).to.be.true; }); }); - describe('handleError', () => { + describe('registerErrorListener', () => { it('should bind error event handler', () => { const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); const emitter = { addListener: callback, }; - client.handleError(emitter as any); - expect(callback.getCall(0).args[0]).to.be.eql(ERROR_EVENT); + client.registerErrorListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(RedisEventsMap.ERROR); + }); + }); + describe('registerEndListener', () => { + it('should bind end event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerEndListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(RedisEventsMap.END); + }); + }); + describe('registerReadyListener', () => { + it('should bind ready event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerReadyListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(RedisEventsMap.READY); + }); + }); + describe('registerReconnectListener', () => { + it('should bind reconnect event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerReconnectListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql( + RedisEventsMap.RECONNECTING, + ); }); }); describe('getClientOptions', () => { @@ -262,14 +297,14 @@ describe('ClientRedis', () => { describe('createRetryStrategy', () => { describe('when is terminated', () => { it('should return undefined', () => { - (client as any).isExplicitlyTerminated = true; + (client as any).isManuallyClosed = true; const result = client.createRetryStrategy(0); expect(result).to.be.undefined; }); }); describe('when "retryAttempts" does not exist', () => { it('should return undefined', () => { - (client as any).isExplicitlyTerminated = false; + (client as any).isManuallyClosed = false; (client as any).options.options = {}; (client as any).options.options.retryAttempts = undefined; const result = client.createRetryStrategy(1); @@ -278,7 +313,7 @@ describe('ClientRedis', () => { }); describe('when "attempts" count is max', () => { it('should return undefined', () => { - (client as any).isExplicitlyTerminated = false; + (client as any).isManuallyClosed = false; (client as any).options.options = {}; (client as any).options.options.retryAttempts = 3; const result = client.createRetryStrategy(4); @@ -288,7 +323,7 @@ describe('ClientRedis', () => { describe('otherwise', () => { it('should return delay (ms)', () => { (client as any).options = {}; - (client as any).isExplicitlyTerminated = false; + (client as any).isManuallyClosed = false; (client as any).options.retryAttempts = 3; (client as any).options.retryDelay = 3; const result = client.createRetryStrategy(2); diff --git a/packages/microservices/test/client/client-rmq.spec.ts b/packages/microservices/test/client/client-rmq.spec.ts index b9083e1fa4c..3a3a29458ec 100644 --- a/packages/microservices/test/client/client-rmq.spec.ts +++ b/packages/microservices/test/client/client-rmq.spec.ts @@ -11,22 +11,9 @@ describe('ClientRMQ', function () { let client: ClientRMQ; - describe('constructor', () => { - it(`should fallback to queueOptions.noAssert when 'noAssert' is undefined`, () => { - const queueOptions = { - noAssert: true, - }; - const instance = new ClientRMQ({ - queueOptions, - }); - - expect(instance).property('noAssert').to.eq(queueOptions.noAssert); - }); - }); - describe('connect', () => { let createClientStub: sinon.SinonStub; - let handleErrorsSpy: sinon.SinonSpy; + let registerErrorListenerSpy: sinon.SinonSpy; let connect$Stub: sinon.SinonStub; beforeEach(async () => { @@ -35,7 +22,7 @@ describe('ClientRMQ', function () { addListener: () => ({}), removeListener: () => ({}), })); - handleErrorsSpy = sinon.spy(client, 'handleError'); + registerErrorListenerSpy = sinon.spy(client, 'registerErrorListener'); connect$Stub = sinon.stub(client, 'connect$' as any).callsFake(() => ({ subscribe: resolve => resolve(), toPromise() { @@ -56,8 +43,8 @@ describe('ClientRMQ', function () { await client.connect(); } catch {} }); - it('should call "handleError" once', async () => { - expect(handleErrorsSpy.called).to.be.true; + it('should call "registerErrorListener" once', async () => { + expect(registerErrorListenerSpy.called).to.be.true; }); it('should call "createClient" once', async () => { expect(createClientStub.called).to.be.true; @@ -74,8 +61,8 @@ describe('ClientRMQ', function () { it('should not call "createClient"', () => { expect(createClientStub.called).to.be.false; }); - it('should not call "handleError"', () => { - expect(handleErrorsSpy.called).to.be.false; + it('should not call "registerErrorListener"', () => { + expect(registerErrorListenerSpy.called).to.be.false; }); it('should not call "connect$"', () => { expect(connect$Stub.called).to.be.false; diff --git a/packages/microservices/test/client/client-tcp.spec.ts b/packages/microservices/test/client/client-tcp.spec.ts index b1b7941bb45..7a944970010 100644 --- a/packages/microservices/test/client/client-tcp.spec.ts +++ b/packages/microservices/test/client/client-tcp.spec.ts @@ -3,15 +3,18 @@ import { Socket as NetSocket } from 'net'; import * as sinon from 'sinon'; import { TLSSocket } from 'tls'; import { ClientTCP } from '../../client/client-tcp'; -import { ERROR_EVENT } from '../../constants'; +import { TcpEventsMap } from '../../events/tcp.events'; describe('ClientTCP', () => { let client: ClientTCP; - let socket; + let untypedClient: any; + let socket: any; let createSocketStub: sinon.SinonStub; beforeEach(() => { client = new ClientTCP({}); + untypedClient = client as any; + const onFakeCallback = (event, callback) => event !== 'error' && event !== 'close' && callback({}); @@ -63,7 +66,7 @@ describe('ClientTCP', () => { }); }); describe('handleResponse', () => { - let callback; + let callback: sinon.SinonSpy; const id = '1'; describe('when disposed', () => { @@ -106,14 +109,20 @@ describe('ClientTCP', () => { }); }); describe('connect', () => { - let bindEventsSpy: sinon.SinonSpy; + let registerConnectListenerSpy: sinon.SinonSpy; + let registerErrorListenerSpy: sinon.SinonSpy; + let registerCloseListenerSpy: sinon.SinonSpy; let connect$Stub: sinon.SinonStub; beforeEach(async () => { - bindEventsSpy = sinon.spy(client, 'bindEvents'); + registerConnectListenerSpy = sinon.spy(client, 'registerConnectListener'); + registerErrorListenerSpy = sinon.spy(client, 'registerErrorListener'); + registerCloseListenerSpy = sinon.spy(client, 'registerCloseListener'); }); afterEach(() => { - bindEventsSpy.restore(); + registerConnectListenerSpy.restore(); + registerErrorListenerSpy.restore(); + registerCloseListenerSpy.restore; }); describe('when is not connected', () => { beforeEach(async () => { @@ -130,8 +139,14 @@ describe('ClientTCP', () => { afterEach(() => { connect$Stub.restore(); }); - it('should call "bindEvents" once', async () => { - expect(bindEventsSpy.called).to.be.true; + it('should call "registerConnectListener" once', async () => { + expect(registerConnectListenerSpy.called).to.be.true; + }); + it('should call "registerErrorListener" once', async () => { + expect(registerErrorListenerSpy.called).to.be.true; + }); + it('should call "registerCloseListener" once', async () => { + expect(registerCloseListenerSpy.called).to.be.true; }); it('should call "createSocket" once', async () => { expect(createSocketStub.called).to.be.true; @@ -151,34 +166,31 @@ describe('ClientTCP', () => { expect(createSocketStub.called).to.be.false; }); it('should not call "bindEvents"', () => { - expect(bindEventsSpy.called).to.be.false; + expect(registerConnectListenerSpy.called).to.be.false; }); }); }); describe('close', () => { - let routingMap; - let callback; + let routingMap: Map; + let callback: sinon.SinonSpy; beforeEach(() => { routingMap = new Map(); callback = sinon.spy(); routingMap.set('some id', callback); - (client as any).socket = socket; - (client as any).isConnected = true; - (client as any).routingMap = routingMap; + + untypedClient.socket = socket; + untypedClient.routingMap = routingMap; client.close(); }); it('should end() socket', () => { expect(socket.end.called).to.be.true; }); - it('should set "isConnected" to false', () => { - expect((client as any).isConnected).to.be.false; - }); it('should set "socket" to null', () => { - expect((client as any).socket).to.be.null; + expect(untypedClient.socket).to.be.null; }); it('should clear out the routing map', () => { - expect((client as any).routingMap.size).to.be.eq(0); + expect(untypedClient.routingMap.size).to.be.eq(0); }); it('should call callbacks', () => { expect( @@ -188,14 +200,34 @@ describe('ClientTCP', () => { ).to.be.true; }); }); - describe('bindEvents', () => { + describe('registerErrorListener', () => { it('should bind error event handler', () => { const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); const emitter = { on: callback, }; - client.bindEvents(emitter as any); - expect(callback.getCall(0).args[0]).to.be.eql(ERROR_EVENT); + client.registerErrorListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(TcpEventsMap.ERROR); + }); + }); + describe('registerCloseListener', () => { + it('should bind close event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerCloseListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(TcpEventsMap.CLOSE); + }); + }); + describe('registerConnectListener', () => { + it('should bind connect event handler', () => { + const callback = sinon.stub().callsFake((_, fn) => fn({ code: 'test' })); + const emitter = { + on: callback, + }; + client.registerConnectListener(emitter as any); + expect(callback.getCall(0).args[0]).to.be.eql(TcpEventsMap.CONNECT); }); }); describe('dispatchEvent', () => { @@ -207,7 +239,7 @@ describe('ClientTCP', () => { internalSocket = { sendMessage: sendMessageStub, }; - (client as any).socket = internalSocket; + untypedClient.socket = internalSocket; }); it('should publish packet', async () => { diff --git a/packages/microservices/test/json-socket/connection.spec.ts b/packages/microservices/test/json-socket/connection.spec.ts index df95cbbcc2b..8fb18f2e707 100644 --- a/packages/microservices/test/json-socket/connection.spec.ts +++ b/packages/microservices/test/json-socket/connection.spec.ts @@ -1,11 +1,13 @@ import { expect } from 'chai'; import { AddressInfo, createServer, Socket } from 'net'; -import { CONNECT_EVENT, MESSAGE_EVENT } from '../../constants'; +import { TcpEventsMap } from '../../events/tcp.events'; import { JsonSocket } from '../../helpers/json-socket'; import { longPayload } from './data/long-payload-with-special-chars'; import * as helpers from './helpers'; import { ip } from './helpers'; +const MESSAGE_EVENT = 'message'; + describe('JsonSocket connection', () => { it('should connect, send and receive message', done => { helpers.createServerAndClient( @@ -179,7 +181,7 @@ describe('JsonSocket connection', () => { expect(serverSocket['isClosed']).to.equal(true); expect(clientSocket['isClosed']).to.equal(true); - clientSocket.on(CONNECT_EVENT, () => { + clientSocket.on(TcpEventsMap.CONNECT, () => { setTimeout(() => { expect(clientSocket['isClosed']).to.equal(false); diff --git a/packages/microservices/test/json-socket/helpers.ts b/packages/microservices/test/json-socket/helpers.ts index acaf339d8c9..1e11859108a 100644 --- a/packages/microservices/test/json-socket/helpers.ts +++ b/packages/microservices/test/json-socket/helpers.ts @@ -4,7 +4,7 @@ import { Server, Socket, } from 'net'; -import { ERROR_EVENT } from '../../constants'; +import { TcpEventsMap } from '../../events/tcp.events'; import { JsonSocket } from '../../helpers/json-socket'; export const ip = '127.0.0.1'; @@ -17,7 +17,7 @@ export function createServer(callback: (err?: any, server?: Server) => void) { callback(null, server); }); - server.on(ERROR_EVENT, (err: any) => { + server.on(TcpEventsMap.ERROR, (err: any) => { callback(err); }); } @@ -40,7 +40,7 @@ export function createClient( clientSocket.connect(port, ip); - clientSocket.on(ERROR_EVENT, (err: any) => { + clientSocket.on(TcpEventsMap.ERROR, (err: any) => { callback(err); }); diff --git a/packages/microservices/test/json-socket/listener-chaining.spec.ts b/packages/microservices/test/json-socket/listener-chaining.spec.ts index fe07945478b..c1d41efa48b 100644 --- a/packages/microservices/test/json-socket/listener-chaining.spec.ts +++ b/packages/microservices/test/json-socket/listener-chaining.spec.ts @@ -1,7 +1,9 @@ -import { CONNECT_EVENT, MESSAGE_EVENT } from '../../constants'; +import { expect } from 'chai'; +import { TcpEventsMap } from '../../events/tcp.events'; import { JsonSocket } from '../../helpers/json-socket'; import * as helpers from './helpers'; -import { expect } from 'chai'; + +const MESSAGE_EVENT = 'message'; describe('JsonSocket chaining', () => { it('should return the instance when subscribing to event', done => { @@ -13,7 +15,7 @@ describe('JsonSocket chaining', () => { expect(clientSocket.on(MESSAGE_EVENT, () => {})).to.be.instanceof( JsonSocket, ); - expect(clientSocket.on(CONNECT_EVENT, () => {})).to.deep.equal( + expect(clientSocket.on(TcpEventsMap.CONNECT, () => {})).to.deep.equal( clientSocket, ); expect( diff --git a/packages/microservices/test/json-socket/message-parsing.spec.ts b/packages/microservices/test/json-socket/message-parsing.spec.ts index 9092bf8d2fa..6b08e03c62b 100644 --- a/packages/microservices/test/json-socket/message-parsing.spec.ts +++ b/packages/microservices/test/json-socket/message-parsing.spec.ts @@ -1,9 +1,11 @@ import { expect } from 'chai'; import { Socket } from 'net'; import * as sinon from 'sinon'; -import { ERROR_EVENT, MESSAGE_EVENT } from '../../constants'; +import { TcpEventsMap } from '../../events/tcp.events'; import { JsonSocket } from '../../helpers/json-socket'; +const MESSAGE_EVENT = 'message'; + describe('JsonSocket message parsing', () => { const socket = new JsonSocket(new Socket()); let messages: string[] = []; @@ -122,7 +124,7 @@ describe('JsonSocket message parsing', () => { expect(socket['buffer']).to.deep.equal(''); }); - it(`should emit ${ERROR_EVENT} event on socket`, () => { + it(`should emit ${TcpEventsMap.ERROR} event on socket`, () => { const socketEmitSpy: sinon.SinonSpy = sinon.spy( socket['socket'], 'emit', @@ -131,12 +133,13 @@ describe('JsonSocket message parsing', () => { socket['onData'](packet); try { - expect(socketEmitSpy.calledOnceWithExactly(ERROR_EVENT, errorMsg)).to - .be.true; + expect( + socketEmitSpy.calledOnceWithExactly(TcpEventsMap.ERROR, errorMsg), + ).to.be.true; } catch (err) { expect( socketEmitSpy.calledOnceWithExactly( - ERROR_EVENT, + TcpEventsMap.ERROR, errorMsgNodeBelowV20, ), ).to.be.true; @@ -169,7 +172,7 @@ describe('JsonSocket message parsing', () => { expect(socket['buffer']).to.deep.equal(''); }); - it(`should emit ${ERROR_EVENT} event on socket`, () => { + it(`should emit ${TcpEventsMap.ERROR} event on socket`, () => { const socketEmitSpy: sinon.SinonSpy = sinon.spy( socket['socket'], 'emit', @@ -177,8 +180,9 @@ describe('JsonSocket message parsing', () => { socket['onData'](packet); - expect(socketEmitSpy.calledOnceWithExactly(ERROR_EVENT, errorMsg)).to.be - .true; + expect( + socketEmitSpy.calledOnceWithExactly(TcpEventsMap.ERROR, errorMsg), + ).to.be.true; socketEmitSpy.restore(); }); diff --git a/packages/microservices/test/server/server-grpc.spec.ts b/packages/microservices/test/server/server-grpc.spec.ts index af683a23787..40c0299c06e 100644 --- a/packages/microservices/test/server/server-grpc.spec.ts +++ b/packages/microservices/test/server/server-grpc.spec.ts @@ -3,13 +3,14 @@ import { expect } from 'chai'; import { join } from 'path'; import { ReplaySubject, Subject, throwError } from 'rxjs'; import * as sinon from 'sinon'; -import { CANCEL_EVENT } from '../../constants'; import { InvalidGrpcPackageException } from '../../errors/invalid-grpc-package.exception'; import { InvalidProtoDefinitionException } from '../../errors/invalid-proto-definition.exception'; import * as grpcHelpers from '../../helpers/grpc-helpers'; import { GrpcMethodStreamingType } from '../../index'; import { ServerGrpc } from '../../server'; +const CANCELLED_EVENT = 'cancelled'; + class NoopLogger extends Logger { log(message: any, context?: string): void {} error(message: any, trace?: string, context?: string): void {} @@ -620,7 +621,7 @@ describe('ServerGrpc', () => { const fn = server.createRequestStreamMethod(handler, false); const call = { on: (event, callback) => { - if (event !== CANCEL_EVENT) { + if (event !== CANCELLED_EVENT) { callback(); } }, @@ -641,7 +642,7 @@ describe('ServerGrpc', () => { const fn = server.createRequestStreamMethod(handler, false); const call = { on: (event, callback) => { - if (event !== CANCEL_EVENT) { + if (event !== CANCELLED_EVENT) { callback(); } }, @@ -677,7 +678,7 @@ describe('ServerGrpc', () => { }; const cancel = () => { - emitter.dispatchEvent(new Event(CANCEL_EVENT)); + emitter.dispatchEvent(new Event(CANCELLED_EVENT)); }; const call = { @@ -732,7 +733,7 @@ describe('ServerGrpc', () => { const fn = server.createRequestStreamMethod(handler, true); const call = { on: (event, callback) => { - if (event !== CANCEL_EVENT) { + if (event !== CANCELLED_EVENT) { callback(); } }, diff --git a/packages/microservices/test/server/server-kafka.spec.ts b/packages/microservices/test/server/server-kafka.spec.ts index 5255b43107c..0112c292511 100644 --- a/packages/microservices/test/server/server-kafka.spec.ts +++ b/packages/microservices/test/server/server-kafka.spec.ts @@ -90,9 +90,11 @@ describe('ServerKafka', () => { let subscribe: sinon.SinonSpy; let run: sinon.SinonSpy; let send: sinon.SinonSpy; + let on: sinon.SinonSpy; let consumerStub: sinon.SinonStub; let producerStub: sinon.SinonStub; - let client; + let client: any; + let untypedServer: any; beforeEach(() => { server = new ServerKafka({}); @@ -101,18 +103,46 @@ describe('ServerKafka', () => { subscribe = sinon.spy(); run = sinon.spy(); send = sinon.spy(); + on = sinon.spy(); consumerStub = sinon.stub(server as any, 'consumer').callsFake(() => { return { connect, subscribe, run, + on, + events: { + GROUP_JOIN: 'consumer.group_join', + HEARTBEAT: 'consumer.heartbeat', + COMMIT_OFFSETS: 'consumer.commit_offsets', + FETCH_START: 'consumer.fetch_start', + FETCH: 'consumer.fetch', + START_BATCH_PROCESS: 'consumer.start_batch_process', + END_BATCH_PROCESS: 'consumer.end_batch_process', + CONNECT: 'consumer.connect', + DISCONNECT: 'consumer.disconnect', + STOP: 'consumer.stop', + CRASH: 'consumer.crash', + REBALANCING: 'consumer.rebalancing', + RECEIVED_UNSUBSCRIBED_TOPICS: 'consumer.received_unsubscribed_topics', + REQUEST: 'consumer.network.request', + REQUEST_TIMEOUT: 'consumer.network.request_timeout', + REQUEST_QUEUE_SIZE: 'consumer.network.request_queue_size', + }, }; }); producerStub = sinon.stub(server as any, 'producer').callsFake(() => { return { connect, send, + on, + events: { + CONNECT: 'producer.connect', + DISCONNECT: 'producer.disconnect', + REQUEST: 'producer.network.request', + REQUEST_TIMEOUT: 'producer.network.request_timeout', + REQUEST_QUEUE_SIZE: 'producer.network.request_queue_size', + }, }; }); client = { @@ -120,6 +150,8 @@ describe('ServerKafka', () => { producer: producerStub, }; sinon.stub(server, 'createClient').callsFake(() => client); + + untypedServer = server as any; }); describe('listen', () => { @@ -127,7 +159,8 @@ describe('ServerKafka', () => { bindEventsStub = sinon .stub(server, 'bindEvents') .callsFake(() => ({}) as any); - await server.listen(callback); + + await server.listen(err => console.log(err)); expect(bindEventsStub.called).to.be.true; }); it('should call callback', async () => { @@ -152,40 +185,40 @@ describe('ServerKafka', () => { const consumer = { disconnect: sinon.spy() }; const producer = { disconnect: sinon.spy() }; beforeEach(() => { - (server as any).consumer = consumer; - (server as any).producer = producer; + untypedServer.consumer = consumer; + untypedServer.producer = producer; }); it('should close server', async () => { await server.close(); expect(consumer.disconnect.calledOnce).to.be.true; expect(producer.disconnect.calledOnce).to.be.true; - expect((server as any).consumer).to.be.null; - expect((server as any).producer).to.be.null; - expect((server as any).client).to.be.null; + expect(untypedServer.consumer).to.be.null; + expect(untypedServer.producer).to.be.null; + expect(untypedServer.client).to.be.null; }); }); describe('bindEvents', () => { it('should not call subscribe nor run on consumer when there are no messageHandlers', async () => { - (server as any).logger = new NoopLogger(); + untypedServer.logger = new NoopLogger(); await server.listen(callback); - await server.bindEvents((server as any).consumer); + await server.bindEvents(untypedServer.consumer); expect(subscribe.called).to.be.false; expect(run.called).to.be.true; expect(connect.called).to.be.true; }); it('should call subscribe and run on consumer when there are messageHandlers', async () => { - (server as any).logger = new NoopLogger(); + untypedServer.logger = new NoopLogger(); await server.listen(callback); const pattern = 'test'; const handler = sinon.spy(); - (server as any).messageHandlers = objectToMap({ + untypedServer.messageHandlers = objectToMap({ [pattern]: handler, }); - await server.bindEvents((server as any).consumer); + await server.bindEvents(untypedServer.consumer); expect(subscribe.called).to.be.true; expect( @@ -198,18 +231,18 @@ describe('ServerKafka', () => { expect(connect.called).to.be.true; }); it('should call subscribe with options and run on consumer when there are messageHandlers', async () => { - (server as any).logger = new NoopLogger(); - (server as any).options.subscribe = {}; - (server as any).options.subscribe.fromBeginning = true; + untypedServer.logger = new NoopLogger(); + untypedServer.options.subscribe = {}; + untypedServer.options.subscribe.fromBeginning = true; await server.listen(callback); const pattern = 'test'; const handler = sinon.spy(); - (server as any).messageHandlers = objectToMap({ + untypedServer.messageHandlers = objectToMap({ [pattern]: handler, }); - await server.bindEvents((server as any).consumer); + await server.bindEvents(untypedServer.consumer); expect(subscribe.called).to.be.true; expect( @@ -337,7 +370,7 @@ describe('ServerKafka', () => { it('should call "handleEvent" if correlation identifier and reply topic are present but the handler is of type eventHandler', async () => { const handler = sinon.spy(); (handler as any).isEventHandler = true; - (server as any).messageHandlers = objectToMap({ + untypedServer.messageHandlers = objectToMap({ [topic]: handler, }); const handleEventSpy = sinon.spy(server, 'handleEvent'); @@ -348,7 +381,7 @@ describe('ServerKafka', () => { it('should NOT call "handleEvent" if correlation identifier and reply topic are present but the handler is not of type eventHandler', async () => { const handler = sinon.spy(); (handler as any).isEventHandler = false; - (server as any).messageHandlers = objectToMap({ + untypedServer.messageHandlers = objectToMap({ [topic]: handler, }); const handleEventSpy = sinon.spy(server, 'handleEvent'); @@ -368,7 +401,7 @@ describe('ServerKafka', () => { it(`should call handler with expected arguments`, async () => { const handler = sinon.spy(); - (server as any).messageHandlers = objectToMap({ + untypedServer.messageHandlers = objectToMap({ [topic]: handler, }); diff --git a/packages/microservices/test/server/server-mqtt.spec.ts b/packages/microservices/test/server/server-mqtt.spec.ts index 8e63a016257..30893e8f452 100644 --- a/packages/microservices/test/server/server-mqtt.spec.ts +++ b/packages/microservices/test/server/server-mqtt.spec.ts @@ -30,13 +30,25 @@ describe('ServerMqtt', () => { server.listen(callbackSpy); expect(onSpy.getCall(0).args[0]).to.be.equal('error'); }); - it('should bind "message" event to handler', () => { + it('should bind "reconnect" event to handler', () => { + server.listen(callbackSpy); + expect(onSpy.getCall(1).args[0]).to.be.equal('reconnect'); + }); + it('should bind "disconnect" event to handler', () => { + server.listen(callbackSpy); + expect(onSpy.getCall(2).args[0]).to.be.equal('disconnect'); + }); + it('should bind "close" event to handler', () => { server.listen(callbackSpy); - expect(onSpy.getCall(1).args[0]).to.be.equal('message'); + expect(onSpy.getCall(3).args[0]).to.be.equal('close'); }); it('should bind "connect" event to handler', () => { server.listen(callbackSpy); - expect(onSpy.getCall(2).args[0]).to.be.equal('connect'); + expect(onSpy.getCall(4).args[0]).to.be.equal('connect'); + }); + it('should bind "message" event to handler', () => { + server.listen(callbackSpy); + expect(onSpy.getCall(5).args[0]).to.be.equal('message'); }); describe('when "start" throws an exception', () => { it('should call callback with a thrown error as an argument', () => { diff --git a/packages/microservices/test/server/server-redis.spec.ts b/packages/microservices/test/server/server-redis.spec.ts index dc2373c9193..3f5cde0480d 100644 --- a/packages/microservices/test/server/server-redis.spec.ts +++ b/packages/microservices/test/server/server-redis.spec.ts @@ -233,7 +233,7 @@ describe('ServerRedis', () => { describe('createRetryStrategy', () => { describe('when is terminated', () => { it('should return undefined', () => { - (server as any).isExplicitlyTerminated = true; + (server as any).isManuallyClosed = true; const result = server.createRetryStrategy(0); expect(result).to.be.undefined; }); @@ -257,7 +257,7 @@ describe('ServerRedis', () => { describe('otherwise', () => { it('should return delay (ms)', () => { (server as any).options = {}; - (server as any).isExplicitlyTerminated = false; + (server as any).isManuallyClosed = false; (server as any).options.retryAttempts = 3; (server as any).options.retryDelay = 3; const result = server.createRetryStrategy(2); diff --git a/packages/microservices/test/server/server-rmq.spec.ts b/packages/microservices/test/server/server-rmq.spec.ts index 365acd7a686..32f9c01d08f 100644 --- a/packages/microservices/test/server/server-rmq.spec.ts +++ b/packages/microservices/test/server/server-rmq.spec.ts @@ -1,9 +1,8 @@ import { assert, expect } from 'chai'; import * as sinon from 'sinon'; import { NO_MESSAGE_HANDLER } from '../../constants'; -import { BaseRpcContext } from '../../ctx-host/base-rpc.context'; -import { ServerRMQ } from '../../server/server-rmq'; import { RmqContext } from '../../ctx-host'; +import { ServerRMQ } from '../../server/server-rmq'; describe('ServerRMQ', () => { let server: ServerRMQ; @@ -15,19 +14,6 @@ describe('ServerRMQ', () => { server = new ServerRMQ({}); }); - describe('constructor', () => { - it(`should fallback to queueOptions.noAssert when 'noAssert' is undefined`, () => { - const queueOptions = { - noAssert: true, - }; - const instance = new ServerRMQ({ - queueOptions, - }); - - expect(instance).property('noAssert').to.eq(queueOptions.noAssert); - }); - }); - describe('listen', () => { let createClient: sinon.SinonStub; let onStub: sinon.SinonStub; @@ -47,6 +33,7 @@ describe('ServerRMQ', () => { client = { on: onStub, + once: onStub, createChannel: createChannelStub, }; createClient = sinon.stub(server, 'createClient').callsFake(() => client); @@ -59,17 +46,17 @@ describe('ServerRMQ', () => { server.listen(callbackSpy); expect(createClient.called).to.be.true; }); - it('should bind "connect" event to handler', () => { - server.listen(callbackSpy); + it('should bind "connect" event to handler', async () => { + await server.listen(callbackSpy); expect(onStub.getCall(0).args[0]).to.be.equal('connect'); }); - it('should bind "disconnect" event to handler', () => { - server.listen(callbackSpy); - expect(onStub.getCall(1).args[0]).to.be.equal('disconnect'); + it('should bind "disconnected" event to handler', async () => { + await server.listen(callbackSpy); + expect(onStub.getCall(2).args[0]).to.be.equal('disconnect'); }); - it('should bind "connectFailed" event to handler', () => { - server.listen(callbackSpy); - expect(onStub.getCall(2).args[0]).to.be.equal('connectFailed'); + it('should bind "connectFailed" event to handler', async () => { + await server.listen(callbackSpy); + expect(onStub.getCall(3).args[0]).to.be.equal('connectFailed'); }); describe('when "start" throws an exception', () => { it('should call callback with a thrown error as an argument', () => { @@ -201,8 +188,10 @@ describe('ServerRMQ', () => { beforeEach(() => { (server as any)['queue'] = queue; (server as any)['queueOptions'] = queueOptions; - (server as any)['isGlobalPrefetchCount'] = isGlobalPrefetchCount; - (server as any)['prefetchCount'] = prefetchCount; + (server as any)['options'] = { + isGlobalPrefetchCount, + prefetchCount, + }; channel = { assertQueue: sinon.spy(() => ({})), @@ -217,7 +206,10 @@ describe('ServerRMQ', () => { expect(channel.assertQueue.calledWith(queue, queueOptions)).to.be.true; }); it('should not call "assertQueue" when noAssert is true', async () => { - server['noAssert' as any] = true; + server['options' as any] = { + ...(server as any)['options'], + noAssert: true, + }; await server.setupChannel(channel, () => null); expect(channel.assertQueue.called).not.to.be.true; diff --git a/packages/microservices/test/server/server.spec.ts b/packages/microservices/test/server/server.spec.ts index ae62ca1083a..60dcecba213 100644 --- a/packages/microservices/test/server/server.spec.ts +++ b/packages/microservices/test/server/server.spec.ts @@ -1,9 +1,16 @@ import { expect } from 'chai'; -import { lastValueFrom, Observable, of, throwError as _throw } from 'rxjs'; +import { throwError as _throw, lastValueFrom, Observable, of } from 'rxjs'; import * as sinon from 'sinon'; import { Server } from '../../server/server'; class TestServer extends Server { + public on< + EventKey extends string = string, + EventCallback extends Function = Function, + >(event: EventKey, callback: EventCallback) {} + public unwrap(): T { + return null; + } public listen(callback: () => void) {} public close() {} } From f983f0366562f5bf311beeeecb4ae3a723e68c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Fri, 15 Nov 2024 10:19:26 +0100 Subject: [PATCH 02/10] fix: update broken import paths --- .../deserializers/nats-request-json.deserializer.ts | 2 +- .../deserializers/nats-response-json.deserializer.ts | 2 +- packages/microservices/serializers/nats-record.serializer.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/microservices/deserializers/nats-request-json.deserializer.ts b/packages/microservices/deserializers/nats-request-json.deserializer.ts index 68763e17f60..e0311041ace 100644 --- a/packages/microservices/deserializers/nats-request-json.deserializer.ts +++ b/packages/microservices/deserializers/nats-request-json.deserializer.ts @@ -1,5 +1,5 @@ import { loadPackage } from '@nestjs/common/utils/load-package.util'; -import { NatsCodec } from '../external/nats-client.interface'; +import { NatsCodec } from '../external/nats-codec.interface'; import { IncomingEvent, IncomingRequest } from '../interfaces'; import { IncomingRequestDeserializer } from './incoming-request.deserializer'; diff --git a/packages/microservices/deserializers/nats-response-json.deserializer.ts b/packages/microservices/deserializers/nats-response-json.deserializer.ts index 51a80972676..5b81f7512ee 100644 --- a/packages/microservices/deserializers/nats-response-json.deserializer.ts +++ b/packages/microservices/deserializers/nats-response-json.deserializer.ts @@ -1,5 +1,5 @@ import { loadPackage } from '@nestjs/common/utils/load-package.util'; -import { NatsCodec } from '../external/nats-client.interface'; +import { NatsCodec } from '../external/nats-codec.interface'; import { IncomingResponse } from '../interfaces'; import { IncomingResponseDeserializer } from './incoming-response.deserializer'; import { NatsRequestJSONDeserializer } from './nats-request-json.deserializer'; diff --git a/packages/microservices/serializers/nats-record.serializer.ts b/packages/microservices/serializers/nats-record.serializer.ts index 77f9a4bab6e..c3e1caf115f 100644 --- a/packages/microservices/serializers/nats-record.serializer.ts +++ b/packages/microservices/serializers/nats-record.serializer.ts @@ -1,6 +1,6 @@ import { loadPackage } from '@nestjs/common/utils/load-package.util'; import { isObject } from '@nestjs/common/utils/shared.utils'; -import { NatsCodec } from '../external/nats-client.interface'; +import { NatsCodec } from '../external/nats-codec.interface'; import { ReadPacket } from '../interfaces'; import { Serializer } from '../interfaces/serializer.interface'; import { NatsRecord, NatsRecordBuilder } from '../record-builders'; From f2b6cc0e939ac95b1df5f1d4090a68e9d36b1c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Fri, 15 Nov 2024 11:50:09 +0100 Subject: [PATCH 03/10] fix: fix minor type issues, broken imports --- integration/microservices/src/tcp-tls/app.module.ts | 8 ++++---- packages/microservices/client/client-proxy.ts | 2 +- .../microservices/interfaces/client-metadata.interface.ts | 4 ++-- packages/microservices/test/server/server-nats.spec.ts | 4 +++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/integration/microservices/src/tcp-tls/app.module.ts b/integration/microservices/src/tcp-tls/app.module.ts index 79a167886d8..b3720d2a96d 100644 --- a/integration/microservices/src/tcp-tls/app.module.ts +++ b/integration/microservices/src/tcp-tls/app.module.ts @@ -1,13 +1,13 @@ -import { Module, Injectable } from '@nestjs/common'; -import { AppController } from './app.controller'; +import { Injectable, Module } from '@nestjs/common'; import { + ClientOptions, ClientsModule, - Transport, ClientsModuleOptionsFactory, - ClientOptions, ClientTCP, RpcException, + Transport, } from '@nestjs/microservices'; +import { AppController } from './app.controller'; import * as fs from 'fs'; import * as path from 'path'; diff --git a/packages/microservices/client/client-proxy.ts b/packages/microservices/client/client-proxy.ts index b937fe98329..ed25ad4d923 100644 --- a/packages/microservices/client/client-proxy.ts +++ b/packages/microservices/client/client-proxy.ts @@ -36,7 +36,7 @@ import { transformPatternToRoute } from '../utils'; * @publicApi */ export abstract class ClientProxy< - EventsMap extends Record = Record, + EventsMap extends Record = Record, Status extends string = string, > { protected routingMap = new Map(); diff --git a/packages/microservices/interfaces/client-metadata.interface.ts b/packages/microservices/interfaces/client-metadata.interface.ts index 53bb8b556c7..26b68e36c4c 100644 --- a/packages/microservices/interfaces/client-metadata.interface.ts +++ b/packages/microservices/interfaces/client-metadata.interface.ts @@ -1,7 +1,8 @@ import { Type } from '@nestjs/common'; +import { ConnectionOptions } from 'tls'; import { ClientProxy } from '../client'; -import { TcpSocket } from '../helpers'; import { Transport } from '../enums/transport.enum'; +import { TcpSocket } from '../helpers'; import { Deserializer } from './deserializer.interface'; import { GrpcOptions, @@ -12,7 +13,6 @@ import { RmqOptions, } from './microservice-configuration.interface'; import { Serializer } from './serializer.interface'; -import { ConnectionOptions } from 'tls'; export type ClientOptions = | RedisOptions diff --git a/packages/microservices/test/server/server-nats.spec.ts b/packages/microservices/test/server/server-nats.spec.ts index 1b98399038d..05911102ad6 100644 --- a/packages/microservices/test/server/server-nats.spec.ts +++ b/packages/microservices/test/server/server-nats.spec.ts @@ -4,9 +4,11 @@ import * as sinon from 'sinon'; import { NO_MESSAGE_HANDLER } from '../../constants'; import { NatsContext } from '../../ctx-host'; import { BaseRpcContext } from '../../ctx-host/base-rpc.context'; -import { NatsMsg } from '../../external/nats-client.interface'; import { ServerNats } from '../../server/server-nats'; +// type NatsMsg = import('nats').Msg; +type NatsMsg = any; + describe('ServerNats', () => { let server: ServerNats; From 66c3ab23eec9d8be9f387aa0a95eba0722c7c6c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Fri, 15 Nov 2024 12:48:22 +0100 Subject: [PATCH 04/10] sample: update grpc client options type --- sample/04-grpc/src/grpc-client.options.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sample/04-grpc/src/grpc-client.options.ts b/sample/04-grpc/src/grpc-client.options.ts index 4ade19b1918..abdae9a88bd 100644 --- a/sample/04-grpc/src/grpc-client.options.ts +++ b/sample/04-grpc/src/grpc-client.options.ts @@ -1,8 +1,8 @@ import { ReflectionService } from '@grpc/reflection'; -import { ClientOptions, Transport } from '@nestjs/microservices'; +import { GrpcOptions, Transport } from '@nestjs/microservices'; import { join } from 'path'; -export const grpcClientOptions: ClientOptions = { +export const grpcClientOptions: GrpcOptions = { transport: Transport.GRPC, options: { package: 'hero', // ['hero', 'hero2'] From 259b5dd8286628f625567567333078bd363127cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Fri, 15 Nov 2024 13:27:04 +0100 Subject: [PATCH 05/10] fix: remove duplicated client redis message handler --- packages/microservices/client/client-redis.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/microservices/client/client-redis.ts b/packages/microservices/client/client-redis.ts index 00542d05120..00d5596db10 100644 --- a/packages/microservices/client/client-redis.ts +++ b/packages/microservices/client/client-redis.ts @@ -85,8 +85,6 @@ export class ClientRedis extends ClientProxy { this.pubClient.connect(), ]); await this.connectionPromise; - - this.subClient.on('message', this.createResponseCallback()); return this.connectionPromise; } From bc31b5cff5910d591cae34a9d5d045101fef78ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Mon, 18 Nov 2024 13:14:59 +0100 Subject: [PATCH 06/10] test: update tcp config opts type --- integration/microservices/e2e/sum-rpc-async.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/integration/microservices/e2e/sum-rpc-async.spec.ts b/integration/microservices/e2e/sum-rpc-async.spec.ts index 45ed1e5ccdb..f6ead5b2485 100644 --- a/integration/microservices/e2e/sum-rpc-async.spec.ts +++ b/integration/microservices/e2e/sum-rpc-async.spec.ts @@ -4,6 +4,7 @@ import { Injectable, Module, } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; import { AsyncOptions, ClientTCP, @@ -11,11 +12,10 @@ import { MessagePattern, MicroserviceOptions, Payload, - TcpClientOptions, + TcpOptions, Transport, } from '@nestjs/microservices'; import { expect } from 'chai'; -import { NestFactory } from '@nestjs/core'; let port: number; @@ -25,7 +25,7 @@ do { @Injectable() class RpcOptionsProvider { - getOptions(): TcpClientOptions { + getOptions(): TcpOptions { return { transport: Transport.TCP, options: { From 4f55f88bff52774f66266182977afc335b051927 Mon Sep 17 00:00:00 2001 From: Kamil Mysliwiec Date: Mon, 18 Nov 2024 15:26:07 +0100 Subject: [PATCH 07/10] Update packages/microservices/server/server-nats.ts Co-authored-by: Rick Dutour Geerling --- packages/microservices/server/server-nats.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/microservices/server/server-nats.ts b/packages/microservices/server/server-nats.ts index dafd441fb69..1ff94597633 100644 --- a/packages/microservices/server/server-nats.ts +++ b/packages/microservices/server/server-nats.ts @@ -23,11 +23,11 @@ let natsPackage = {} as any; // // type Client = import('nats').NatsConnection; // type NatsMsg = import('nats').Msg; -type Subscription = import('nats').Subscription; +// type Subscription = import('nats').Subscription; type Client = any; type NatsMsg = any; -// type Subscription = any; +type Subscription = any; /** * @publicApi From 129fe79f6bc9008661397db73467234bceb056e1 Mon Sep 17 00:00:00 2001 From: Kamil Mysliwiec Date: Mon, 18 Nov 2024 15:26:15 +0100 Subject: [PATCH 08/10] Update packages/microservices/server/server-rmq.ts Co-authored-by: Rick Dutour Geerling --- packages/microservices/server/server-rmq.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/microservices/server/server-rmq.ts b/packages/microservices/server/server-rmq.ts index eb9b9566bd3..5b9e28d79bf 100644 --- a/packages/microservices/server/server-rmq.ts +++ b/packages/microservices/server/server-rmq.ts @@ -34,14 +34,14 @@ import { Server } from './server'; // because it would require the user to install the amqplib package even if they dont use RabbitMQ // Otherwise, TypeScript would fail to compile the code. // -type AmqpConnectionManager = - import('amqp-connection-manager').AmqpConnectionManager; -type ChannelWrapper = import('amqp-connection-manager').ChannelWrapper; -type Message = import('amqplib').Message; - -// type AmqpConnectionManager = any; -// type ChannelWrapper = any; -// type Message = any; +// type AmqpConnectionManager = +// import('amqp-connection-manager').AmqpConnectionManager; +// type ChannelWrapper = import('amqp-connection-manager').ChannelWrapper; +// type Message = import('amqplib').Message; + +type AmqpConnectionManager = any; +type ChannelWrapper = any; +type Message = any; let rmqPackage = {} as any; // as typeof import('amqp-connection-manager'); From 935650927464b46a2166d4b5e39693ccfd8daedb Mon Sep 17 00:00:00 2001 From: Kamil Mysliwiec Date: Wed, 20 Nov 2024 10:22:02 +0100 Subject: [PATCH 09/10] Update packages/microservices/server/server-nats.ts Co-authored-by: Rick Dutour Geerling --- packages/microservices/server/server-nats.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/microservices/server/server-nats.ts b/packages/microservices/server/server-nats.ts index 1ff94597633..b2772e1762d 100644 --- a/packages/microservices/server/server-nats.ts +++ b/packages/microservices/server/server-nats.ts @@ -32,7 +32,7 @@ type Subscription = any; /** * @publicApi */ -export class ServerNats extends Server { +export class ServerNats extends Server { public readonly transportId = Transport.NATS; private natsClient: Client; From c66d1fb5f7ed842e609a4b0a9aba3da62625ddd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20My=C5=9Bliwiec?= Date: Wed, 20 Nov 2024 10:41:37 +0100 Subject: [PATCH 10/10] fix: add type constraints and assertions --- packages/microservices/server/server-nats.ts | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/microservices/server/server-nats.ts b/packages/microservices/server/server-nats.ts index b2772e1762d..f3212fcb446 100644 --- a/packages/microservices/server/server-nats.ts +++ b/packages/microservices/server/server-nats.ts @@ -32,7 +32,10 @@ type Subscription = any; /** * @publicApi */ -export class ServerNats extends Server { +export class ServerNats< + E extends NatsEvents = NatsEvents, + S extends NatsStatus = NatsStatus, +> extends Server { public readonly transportId = Transport.NATS; private natsClient: Client; @@ -58,7 +61,7 @@ export class ServerNats extends Server { try { this.natsClient = await this.createNatsClient(); - this._status$.next(NatsStatus.CONNECTED); + this._status$.next(NatsStatus.CONNECTED as S); this.handleStatusUpdates(this.natsClient); this.start(callback); } catch (err) { @@ -199,7 +202,7 @@ export class ServerNats extends Server { `NatsError: type: "${status.type}", data: "${data}".`, ); - this._status$.next(NatsStatus.DISCONNECTED); + this._status$.next(NatsStatus.DISCONNECTED as S); this.statusEventEmitter.emit( NatsEventsMap.DISCONNECT, status.data as string, @@ -215,7 +218,7 @@ export class ServerNats extends Server { break; case 'reconnecting': - this._status$.next(NatsStatus.RECONNECTING); + this._status$.next(NatsStatus.RECONNECTING as S); break; case 'reconnect': @@ -223,7 +226,7 @@ export class ServerNats extends Server { `NatsStatus: type: "${status.type}", data: "${data}".`, ); - this._status$.next(NatsStatus.CONNECTED); + this._status$.next(NatsStatus.CONNECTED as S); this.statusEventEmitter.emit( NatsEventsMap.RECONNECT, status.data as string, @@ -256,8 +259,8 @@ export class ServerNats extends Server { } public on< - EventKey extends keyof NatsEvents = keyof NatsEvents, - EventCallback extends NatsEvents[EventKey] = NatsEvents[EventKey], + EventKey extends keyof E = keyof E, + EventCallback extends E[EventKey] = E[EventKey], >(event: EventKey, callback: EventCallback) { this.statusEventEmitter.on(event, callback as any); }