feat(microservices): add status, unwrap, on, and other features

This commit is contained in:
Kamil Myśliwiec
2024-11-15 10:03:05 +01:00
parent bc4667c15a
commit 3dfc7fc68e
62 changed files with 2114 additions and 852 deletions

View File

@@ -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<any>;
/**
* 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<any>[]): 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<void>}
*/
close(): Promise<void>;
/**
* Returns an observable that emits status changes.
*
* @returns {Observable<string>}
*/
status: Observable<string>;
/**
* 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<string, Function> = Record<string, Function>,
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>(): T;
}

View File

@@ -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<never, never>
implements ClientGrpc
{
protected readonly logger = new Logger(ClientProxy.name);
protected readonly clients = new Map<string, any>();
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<EventKey extends never = never, EventCallback = any>(
event: EventKey,
callback: EventCallback,
) {
throw new Error('Method is not supported in gRPC mode.');
}
public unwrap<T>(): T {
throw new Error('Method is not supported in gRPC mode.');
}
}

View File

@@ -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<never, KafkaStatus>
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<void> | 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<void> {
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<Producer> {
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<void> {
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<void> {
if (this._consumer) {
return this._consumer.commitOffsets(topicPartitions);
} else {
throw new Error('No consumer initialized');
}
}
public unwrap<T>(): 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<any> {
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<void> {
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');
}
}

View File

@@ -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<MqttEvents, MqttStatus> {
protected readonly logger = new Logger(ClientProxy.name);
protected readonly subscriptionsCount = new Map<string, number>();
protected readonly url: string;
protected mqttClient: MqttClient;
protected connection: Promise<any>;
protected connectionPromise: Promise<any>;
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<any> {
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<T = any>(
instance: MqttClient,
source$: Observable<T>,
): Observable<T> {
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>(): 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());

View File

@@ -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<NatsEvents, NatsStatus> {
protected readonly logger = new Logger(ClientNats.name);
protected natsClient: Client;
protected clientConnectionPromise: Promise<Client>;
protected connectionPromise: Promise<Client>;
protected statusEventEmitter = new EventEmitter<{
[key in keyof NatsEvents]: Parameters<NatsEvents[key]>;
}>();
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<any> {
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<Client> {
const options: any = this.options || ({} as NatsOptions);
const options = this.options || ({} as NatsOptions);
return natsPackage.connect({
servers: NATS_DEFAULT_URL,
...options,
@@ -61,12 +83,46 @@ export class ClientNats extends ClientProxy {
switch (status.type) {
case 'error':
case 'disconnect':
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':
if (this.options.debug) {
this.logger.debug(
@@ -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>(): 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,

View File

@@ -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;
}
}

View File

@@ -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<any>;
public abstract close(): any;
/**
* @publicApi
*/
export abstract class ClientProxy<
EventsMap extends Record<string, Function> = Record<string, Function>,
Status extends string = string,
> {
protected routingMap = new Map<string, Function>();
protected serializer: ProducerSerializer;
protected deserializer: ProducerDeserializer;
protected _status$ = new ReplaySubject<Status>(1);
/**
* Returns an observable that emits status changes.
*/
public get status(): Observable<Status> {
return this._status$.asObservable().pipe(distinctUntilChanged());
}
/**
* Establishes the connection to the underlying server/broker.
*/
public abstract connect(): Promise<any>;
/**
* 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>(): 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<TResult = any, TInput = any>(
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<TResult = any, TInput = any>(
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<any> {
const error$ = fromEvent(instance, errorEvent).pipe(
map((err: any) => {

View File

@@ -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<RedisEvents, RedisStatus> {
protected readonly logger = new Logger(ClientProxy.name);
protected readonly subscriptionsCount = new Map<string, number>();
protected pubClient: Redis;
protected subClient: Redis;
protected connection: Promise<any>;
protected isExplicitlyTerminated = false;
protected connectionPromise: Promise<any>;
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<any> {
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<RedisOptions['options']> {
@@ -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>(): 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(): (

View File

@@ -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<RmqEvents, RmqStatus> {
protected readonly logger = new Logger(ClientProxy.name);
protected connection$: ReplaySubject<any>;
protected connection: Promise<any>;
protected connectionPromise: Promise<void>;
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<string, any>;
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<any> {
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<void> {
@@ -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<T = any>(
@@ -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>(): 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<void>;
public async handleMessage(
packet: unknown,
options: Record<string, unknown>,
callback: (packet: WritePacket) => any,
);
): Promise<void>;
public async handleMessage(
packet: unknown,
options: Record<string, unknown> | ((packet: WritePacket) => any),
callback?: (packet: WritePacket) => any,
) {
): Promise<void> {
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),
},

View File

@@ -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<any>;
private readonly logger = new Logger(ClientTCP.name);
private readonly port: number;
private readonly host: string;
private readonly socketClass: Type<TcpSocket>;
private isConnected = false;
private socket: TcpSocket;
public tlsOptions?: ConnectionOptions;
export class ClientTCP extends ClientProxy<TcpEvents, TcpStatus> {
protected readonly logger = new Logger(ClientTCP.name);
protected readonly port: number;
protected readonly host: string;
protected readonly socketClass: Type<TcpSocket>;
protected readonly tlsOptions?: ConnectionOptions;
protected socket: TcpSocket;
protected connectionPromise: Promise<any>;
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<any> {
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<void> {
@@ -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>(): 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,

View File

@@ -1,2 +0,0 @@
export const GRPC_CANCELLED = 'Cancelled';
export const RABBITMQ_REPLY_QUEUE = 'amq.rabbitmq.reply-to';

View File

@@ -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...';

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -0,0 +1,7 @@
export const enum KafkaStatus {
DISCONNECTED = 'disconnected',
CONNECTED = 'connected',
CRASHED = 'crashed',
STOPPED = 'stopped',
REBALANCING = 'rebalancing',
}

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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 <message> to <topic>
*
* @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 <topic>
*
* @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;
}

View File

@@ -1,93 +0,0 @@
/**
* @see https://github.com/nats-io/nats.js
*
* @publicApi
*/
export interface NatsCodec<T> {
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<T> {
queue?: string;
max?: number;
timeout?: number;
callback?: (err: object | null, msg: T) => void;
}
declare type SubscriptionOptions = SubOpts<NatsMsg>;
export interface NatsMsg {
subject: string;
sid: number;
reply?: string;
data: Uint8Array;
headers?: any;
respond(data?: Uint8Array, opts?: PublishOptions): boolean;
}
interface Sub<T> extends AsyncIterable<T> {
unsubscribe(max?: number): void;
drain(): Promise<void>;
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<NatsMsg>;
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<string, any>;
closed(): Promise<void | Error>;
close(): Promise<void>;
publish(subject: string, data?: Uint8Array, options?: PublishOptions): void;
subscribe(subject: string, opts?: SubscriptionOptions): Subscription;
request(
subject: string,
data?: Uint8Array,
opts?: RequestOptions,
): Promise<NatsMsg>;
flush(): Promise<void>;
drain(): Promise<void>;
isClosed(): boolean;
isDraining(): boolean;
getServer(): string;
status(): AsyncIterable<Status>;
stats(): Record<string, any>;
jetstreamManager(opts?: Record<string, any>): Promise<any>;
jetstream(opts?: Record<string, any>): any;
}

View File

@@ -0,0 +1,9 @@
/**
* @see https://github.com/nats-io/nats.js
*
* @publicApi
*/
export interface NatsCodec<T> {
encode(d: T): Uint8Array;
decode(a: Uint8Array): T;
}

View File

@@ -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<string, unknown>;
@@ -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);
}
}

View File

@@ -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';

View File

@@ -2,6 +2,16 @@
* @publicApi
*/
export interface ClientGrpc {
/**
* Returns an instance of the given gRPC service.
* @param name Service name
* @returns gRPC service
*/
getService<T extends {}>(name: string): T;
/**
* Returns an instance of the given gRPC client.
* @param name Service name
* @returns gRPC client
*/
getClientByServiceName<T = any>(name: string): T;
}

View File

@@ -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<ClientProxy<never, KafkaStatus>, '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<void>;
}

View File

@@ -1,3 +0,0 @@
export interface Closeable {
close(): void;
}

View File

@@ -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;
}

View File

@@ -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';

View File

@@ -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<TcpSocket>;
};

View File

@@ -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<Controller | Injectable>,
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,
);
});
}

View File

@@ -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<string | symbol | Function, InstanceWrapper<Controller>>,
server: Server & CustomTransportStrategy,
serverInstance: Server,
moduleName: string,
) {
controllers.forEach(wrapper =>
this.listenersController.registerPatternHandlers(
wrapper,
server,
serverInstance,
moduleName,
),
);

View File

@@ -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;

View File

@@ -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<NestMicroserviceOptions>
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<any>[]): 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<any> {
this.assertNotInPreviewMode('listen');
!this.isInitialized && (await this.registerModules());
return new Promise<any>((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<void>}
*/
public async close(): Promise<any> {
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>(): 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<any> {
@@ -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());
}

View File

@@ -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<string, string | string[]>;

View File

@@ -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']);
}
}
}

View File

@@ -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<TRequest = any, TMetadata = any> {
request: TRequest;
@@ -45,11 +52,16 @@ interface GrpcCall<TRequest = any, TMetadata = any> {
/**
* @publicApi
*/
export class ServerGrpc extends Server implements CustomTransportStrategy {
export class ServerGrpc extends Server<never, never> {
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>(): 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<any> {
public async createClient() {
const channelOptions: ChannelOptions =
this.options && this.options.channelOptions
? this.options.channelOptions

View File

@@ -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<never, KafkaStatus> {
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 = any>(): 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>(): 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<any>,
replayStream$: ReplaySubject<unknown>,

View File

@@ -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<MqttEvents, MqttStatus> {
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>(): 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']) {

View File

@@ -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<NatsEvents, NatsStatus> {
public readonly transportId = Transport.NATS;
private natsClient: Client;
protected statusEventEmitter = new EventEmitter<{
[key in keyof NatsEvents]: Parameters<NatsEvents[key]>;
}>();
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>(): 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();
}

View File

@@ -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<RedisEvents, RedisStatus> {
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<RedisOptions['options']> {
@@ -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>(): 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 });
}
}
}

View File

@@ -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<RmqEvents, RmqStatus> {
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<string, unknown>) => {
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<string, unknown>) => {
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<string, any>) => 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<any> {
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>(): 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();
}

View File

@@ -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<TcpEvents, TcpStatus> {
public readonly transportId = Transport.TCP;
protected server: NetSocket;
private readonly port: number;
private readonly host: string;
private readonly socketClass: Type<TcpSocket>;
private isExplicitlyTerminated = false;
private retryAttemptsCount = 0;
private tlsOptions?;
protected readonly port: number;
protected readonly host: string;
protected readonly socketClass: Type<TcpSocket>;
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<string, unknown>) => {
this.server.once(TcpEventsMap.ERROR, (err: Record<string, unknown>) => {
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>(): 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 = [];
}
private getSocketInstance(socket: Socket): TcpSocket {
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();
});
}
protected getSocketInstance(socket: Socket): TcpSocket {
return new this.socketClass(socket);
}
}

View File

@@ -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<string, Function> = Record<string, Function>,
Status extends string = string,
> {
/**
* Unique transport identifier.
*/
readonly transportId?: Transport | symbol;
protected readonly messageHandlers = new Map<string, MessageHandler>();
protected readonly logger: LoggerService = new Logger(Server.name);
protected serializer: ConsumerSerializer;
protected deserializer: ConsumerDeserializer;
protected _status$ = new ReplaySubject<Status>(1);
/**
* Returns an observable that emits status changes.
*/
public get status(): Observable<Status> {
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>(): 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,

View File

@@ -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,
});
});

View File

@@ -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', () => {

View File

@@ -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 () => {

View File

@@ -8,9 +8,15 @@ class TestClientProxy extends ClientProxy {
protected async dispatchEvent<T = any>(
packet: ReadPacket<any>,
): Promise<any> {}
public async connect() {
return Promise.resolve();
}
public unwrap<T>(): T {
throw new Error('Method not implemented.');
}
public publish(pattern, callback): any {}
public async close() {}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<string, Function>;
let callback: sinon.SinonSpy;
beforeEach(() => {
routingMap = new Map<string, Function>();
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 () => {

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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(

View File

@@ -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<any, any> = 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<any, any> = 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();
});

View File

@@ -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();
}
},

View File

@@ -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,
});

View File

@@ -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('message');
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(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', () => {

View File

@@ -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);

View File

@@ -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;

View File

@@ -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>(): T {
return null;
}
public listen(callback: () => void) {}
public close() {}
}