@wolfcoded/nestjs-bufconnect
Version:
NestJs BufConnect is a custom transport strategy for NestJs microservices that integrates with the Buf's gRPC implementation.
642 lines (629 loc) • 24.1 kB
JavaScript
;
var microservices = require('@nestjs/microservices');
var rxjs = require('rxjs');
var shared_utils = require('@nestjs/common/utils/shared.utils');
var http = require('node:http');
var https = require('node:https');
var http2 = require('node:http2');
var connectNode = require('@connectrpc/connect-node');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var http__namespace = /*#__PURE__*/_interopNamespaceDefault(http);
var https__namespace = /*#__PURE__*/_interopNamespaceDefault(https);
var http2__namespace = /*#__PURE__*/_interopNamespaceDefault(http2);
/**
* A constant string used as a key for storing and retrieving method decorator metadata.
* This key is used with `Reflect` to associate metadata with gRPC methods.
*/
const METHOD_DECORATOR_KEY = Symbol('METHOD_DECORATOR_KEY');
/**
* A constant string used as a key for storing and retrieving stream method decorator metadata.
* This key is used with `Reflect` to associate metadata with gRPC stream methods.
*/
const STREAM_METHOD_DECORATOR_KEY = Symbol('STREAM_METHOD_DECORATOR_KEY');
/**
* A constant symbol used as a key for storing and retrieving the Buf transport metadata.
* This key is used with `Reflect` to associate metadata with the Buf transport.
*/
const BUF_TRANSPORT = Symbol('BUF_TRANSPORT');
/**
* Enum representing the different method types for gRPC streaming.
*/
exports.MethodType = void 0;
(function (MethodType) {
MethodType["NO_STREAMING"] = "no_stream";
MethodType["RX_STREAMING"] = "rx_stream";
})(exports.MethodType || (exports.MethodType = {}));
/**
* Enum representing the supported server protocols.
*/
exports.ServerProtocol = void 0;
(function (ServerProtocol) {
ServerProtocol["HTTP"] = "http";
ServerProtocol["HTTPS"] = "https";
ServerProtocol["HTTP2"] = "http2";
ServerProtocol["HTTP2_INSECURE"] = "http2_insecure";
})(exports.ServerProtocol || (exports.ServerProtocol = {}));
/**
* Provides a storage mechanism for custom metadata associated with ServiceType instances from the '@bufbuild/protobuf' package.
*
* This is a singleton class, so use `CustomMetadataStore.getInstance()` to get the instance.
*
* Example usage:
*
* ```ts
* const customMetadataStore = CustomMetadataStore.getInstance();
*
* customMetadataStore.set('myKey', myServiceType);
* const myServiceTypeFromStore = customMetadataStore.get('myKey');
* ```
*/
class CustomMetadataStore {
static instance;
customMetadata = new Map();
/**
* Private constructor to enforce the singleton pattern.
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
constructor() { }
/**
* getInstance returns the singleton instance of the CustomMetadataStore,
* creating it if it does not already exist.
* @returns {CustomMetadataStore} The singleton instance of CustomMetadataStore.
*/
static getInstance() {
if (!CustomMetadataStore.instance) {
CustomMetadataStore.instance = new CustomMetadataStore();
}
return CustomMetadataStore.instance;
}
/**
* set stores a ServiceType instance with the associated key in the store.
* @param {string} key - The key to associate with the ServiceType instance.
* @param {ServiceType} value - The ServiceType instance to store.
*/
set(key, value) {
this.customMetadata.set(key, value);
}
/**
* get retrieves the ServiceType instance associated with the given key,
* returning undefined if the key is not found in the store.
* @param {string} key - The key associated with the desired ServiceType instance.
* @returns {ServiceType | undefined} The ServiceType instance associated with the key,
* or undefined if not found.
*/
get(key) {
return this.customMetadata.get(key) ?? undefined;
}
}
/**
* Checks if the given input is an AsyncGenerator.
*
* @param input - The object to check.
* @returns True if the input is an AsyncGenerator, false otherwise.
*/
function isAsyncGenerator(input) {
return (typeof input === 'object' && input !== null && Symbol.asyncIterator in input);
}
/**
* Converts an Observable to an AsyncGenerator.
*
* @param observable - The Observable to be converted.
* @returns An AsyncGenerator that yields values from the provided Observable.
*/
async function* observableToAsyncGenerator(observable) {
const queue = [];
let didComplete = false;
let error = null;
const subscriber = observable.subscribe({
next: (value) => {
queue.push(value);
},
error: (innerError) => {
error = innerError;
didComplete = true;
},
complete: () => {
didComplete = true;
},
});
try {
while (!didComplete || queue.length > 0) {
if (queue.length > 0) {
const item = queue.shift();
if (item !== undefined) {
yield item;
}
}
else {
// eslint-disable-next-line no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
if (error) {
throw new Error(String(error));
}
}
finally {
subscriber.unsubscribe();
}
}
/**
* Checks if the given object is an instance of Observable.
*
* @param object - The object to check.
* @returns True if the object is an instance of Observable, false otherwise.
*/
const isObservable = (object) => object instanceof rxjs.Observable;
/**
* Checks if the given object has a 'subscribe' function.
*
* @param object - The object to check.
* @returns True if the object has a 'subscribe' function, false otherwise.
*/
const hasSubscribe = (object) => typeof object === 'object' &&
object !== null &&
typeof object.subscribe === 'function';
/**
* Checks if the given object has a 'toPromise' function.
*
* @param object - The object to check.
* @returns True if the object has a 'toPromise' function, false otherwise.
*/
const hasToPromise = (object) => typeof object === 'object' &&
object !== null &&
typeof object.toPromise ===
'function';
/**
* Transforms a given ResultOrDeferred into an Observable.
*
* @param resultOrDeferred - The ResultOrDeferred to be transformed.
* @returns An Observable instance of the result or deferred.
*/
const transformToObservable = (resultOrDeferred) => {
if (isObservable(resultOrDeferred)) {
return resultOrDeferred;
}
if (hasSubscribe(resultOrDeferred)) {
return new rxjs.Observable(() => resultOrDeferred.subscribe());
}
if (hasToPromise(resultOrDeferred)) {
return new rxjs.Observable((subscriber) => {
resultOrDeferred
.toPromise()
.then((response) => {
subscriber.next(response);
subscriber.complete();
})
.catch((error) => subscriber.error(error));
});
}
return new rxjs.Observable((subscriber) => {
subscriber.next(resultOrDeferred);
subscriber.complete();
});
};
/**
* Converts an Observable or AsyncGenerator to an AsyncGenerator.
*
* @param input - The Observable or AsyncGenerator to be converted.
* @returns An AsyncGenerator that yields values from the provided input.
* @throws An Error if the input is neither an Observable nor an AsyncGenerator.
*/
async function* toAsyncGenerator(input) {
if (isObservable(input)) {
yield* observableToAsyncGenerator(input);
}
else if (isAsyncGenerator(input)) {
yield* input;
}
else {
throw new Error('Unsupported input type. Expected an Observable or an AsyncGenerator.');
}
}
/**
* Adds services to the given ConnectRouter using the provided serviceHandlersMap and customMetadataStore.
*
* @param router - The ConnectRouter to which services will be added.
* @param serviceHandlersMap - An object containing service implementations for each service name.
* @param customMetadataStore - A store containing metadata for the services.
*/
const addServicesToRouter = (router, serviceHandlersMap, customMetadataStore) => {
Object.keys(serviceHandlersMap).forEach((serviceName) => {
const service = customMetadataStore.get(serviceName);
if (service) {
router.service(service, serviceHandlersMap[serviceName]);
}
});
};
/**
* Creates a map of service handlers using the provided handlers and customMetadataStore.
* The map is keyed by service names with values being partial implementations of the ServiceType.
*
* @param handlers - A map of message handlers, keyed by JSON string patterns.
* @param customMetadataStore - A store containing metadata for the services.
* @returns A map of service handlers keyed by service names.
*/
const createServiceHandlersMap = (handlers, customMetadataStore) => {
const serviceHandlersMap = {};
handlers.forEach((handlerMetadata, pattern) => {
const parsedPattern = JSON.parse(pattern);
if (handlerMetadata) {
const service = customMetadataStore.get(parsedPattern.service);
const methodProto = service?.methods[parsedPattern.rpc];
if (service && methodProto) {
if (!serviceHandlersMap[parsedPattern.service]) {
serviceHandlersMap[parsedPattern.service] = {};
}
switch (parsedPattern.streaming) {
case exports.MethodType.NO_STREAMING: {
serviceHandlersMap[parsedPattern.service][parsedPattern.rpc] =
async (request, context) => {
const result = handlerMetadata(request, context);
const resultOrDeferred = await result;
return rxjs.lastValueFrom(transformToObservable(resultOrDeferred));
};
break;
}
case exports.MethodType.RX_STREAMING: {
serviceHandlersMap[parsedPattern.service][parsedPattern.rpc] =
async function* rxStreamingHandler(request, context) {
const result = handlerMetadata(request, context);
const streamOrValue = await result;
yield* toAsyncGenerator(streamOrValue);
};
break;
}
default: {
throw new Error('Invalid streaming type');
}
}
}
}
});
return serviceHandlersMap;
};
/**
* Creates metadata for a gRPC method within a BufService.
*
* @param target - The object containing the method implementation.
* @param key - The method name, as a string or symbol.
* @param service - The name of the service, or undefined if it should be inferred from the target's constructor.
* @param method - The name of the method, or undefined if it should be inferred from the key.
* @param streaming - The streaming type of the method, defaulting to MethodType.NO_STREAMING.
* @returns An object containing the metadata for the gRPC method.
*/
const createBufConnectMethodMetadata = (target, key, service, method, streaming = exports.MethodType.NO_STREAMING) => {
const capitalizeFirstLetter = (input) => input.charAt(0).toUpperCase() + input.slice(1);
if (!service) {
const { name } = target.constructor;
return {
service: name,
rpc: capitalizeFirstLetter(key),
streaming,
};
}
if (service && !method) {
return { service, rpc: capitalizeFirstLetter(key), streaming };
}
return { service, rpc: method, streaming };
};
function isFunctionPropertyDescriptor(descriptor) {
return descriptor !== undefined && typeof descriptor.value === 'function';
}
/**
* Decorator for defining a gRPC service and its methods. It uses the metadata from
* `BufMethod` and `BufStreamMethod` to initialize the service and its methods.
*
* @param serviceName - A `ServiceType` object that defines the gRPC service.
* @returns A class decorator that can be applied to a class implementing the gRPC service.
*/
const BufService = (serviceName) => (target) => {
const processMethodKey = (methodImpl) => {
const functionName = methodImpl.key;
const { methodType } = methodImpl;
const descriptor = Object.getOwnPropertyDescriptor(target.prototype, functionName);
if (isFunctionPropertyDescriptor(descriptor)) {
const metadata = createBufConnectMethodMetadata(descriptor.value, functionName, serviceName.typeName, functionName, methodType);
const customMetadataStore = CustomMetadataStore.getInstance();
customMetadataStore.set(serviceName.typeName, serviceName);
microservices.MessagePattern(metadata, BUF_TRANSPORT)(target.prototype, functionName, descriptor);
}
};
const unaryMethodKeys = Reflect.getMetadata(METHOD_DECORATOR_KEY, target) || [];
const streamMethodKeys = Reflect.getMetadata(STREAM_METHOD_DECORATOR_KEY, target) || [];
unaryMethodKeys.forEach((methodImpl) => processMethodKey(methodImpl));
streamMethodKeys.forEach((methodImpl) => processMethodKey(methodImpl));
};
/**
* Decorator for a unary gRPC method within a `BufService`. It stores the method's metadata,
* which is later used by `BufService` to initialize the method.
*
* @returns A method decorator that can be applied to a method implementing a unary gRPC method.
*/
const BufMethod = () => (target, key) => {
const metadata = {
key: key.toString(),
methodType: exports.MethodType.NO_STREAMING,
};
const existingMethods = Reflect.getMetadata(METHOD_DECORATOR_KEY, target.constructor) ||
new Set();
if (!existingMethods.has(metadata)) {
existingMethods.add(metadata);
Reflect.defineMetadata(METHOD_DECORATOR_KEY, existingMethods, target.constructor);
}
};
/**
* Decorator for a streaming gRPC method within a `BufService`. It stores the method's metadata,
* which is later used by `BufService` to initialize the method.
*
* @returns A method decorator that can be applied to a method implementing a streaming gRPC method.
*/
const BufStreamMethod = () => (target, key) => {
const metadata = {
key: key.toString(),
methodType: exports.MethodType.RX_STREAMING,
};
const existingMethods = Reflect.getMetadata(STREAM_METHOD_DECORATOR_KEY, target.constructor) ||
new Set();
if (!existingMethods.has(metadata)) {
existingMethods.add(metadata);
Reflect.defineMetadata(STREAM_METHOD_DECORATOR_KEY, existingMethods, target.constructor);
}
};
/**
* The HTTPServer class is responsible for creating and managing the server instance based on the given options.
* It supports HTTP, HTTPS, and HTTP2 (secure and insecure) protocols.
*/
class HTTPServer {
options;
router;
serverPrivate = null;
set server(value) {
this.serverPrivate = value;
}
get server() {
return this.serverPrivate;
}
/**
* Constructor for the HTTPServer class.
* @param options - Server configuration options.
* @param router - A function that takes a ConnectRouter and configures it with the server's message handlers.
*/
constructor(options, router) {
this.options = options;
this.router = router;
}
/**
* Starts the server and listens for incoming requests.
* @returns A promise that resolves when the server starts listening.
*/
async listen() {
return new Promise((resolve, reject) => {
this.startServer(resolve, reject);
});
}
/**
* Creates an HTTP server.
* @returns An instance of an HTTP server.
*/
createHttpServer() {
const { serverOptions = {}, connectOptions = {} } = this
.options;
return http__namespace.createServer(serverOptions, connectNode.connectNodeAdapter({
...connectOptions,
routes: this.router,
}));
}
/**
* Creates an HTTPS server.
* @returns An instance of an HTTPS server.
*/
createHttpsServer() {
const { serverOptions = {}, connectOptions = {} } = this
.options;
return https__namespace.createServer(serverOptions, connectNode.connectNodeAdapter({ ...connectOptions, routes: this.router }));
}
/**
* Creates an HTTP2 server with secure connection.
* @returns An instance of an HTTP2 server with secure connection.
*/
createHttp2Server() {
const { serverOptions = {}, connectOptions = {} } = this
.options;
return http2__namespace.createSecureServer(serverOptions, connectNode.connectNodeAdapter({ ...connectOptions, routes: this.router }));
}
/**
* Creates an HTTP2 server with secure connection.
* @returns An instance of an HTTP2 server with secure connection.
*/
createHttp2InsecureServer() {
const { serverOptions = {}, connectOptions = {} } = this
.options;
return http2__namespace.createServer(serverOptions, connectNode.connectNodeAdapter({ ...connectOptions, routes: this.router }));
}
/**
* Starts the server based on the provided protocol in the options.
* @param resolve - A function that resolves the promise when the server starts successfully.
* @param reject - A function that rejects the promise with an error when the server fails to start.
*/
startServer(resolve, reject) {
try {
switch (this.options.protocol) {
case exports.ServerProtocol.HTTP: {
this.server = this.createHttpServer();
break;
}
case exports.ServerProtocol.HTTPS: {
this.server = this.createHttpsServer();
break;
}
case exports.ServerProtocol.HTTP2: {
this.server = this.createHttp2Server();
break;
}
case exports.ServerProtocol.HTTP2_INSECURE: {
this.server = this.createHttp2InsecureServer();
break;
}
default: {
// eslint-disable-next-line no-throw-literal
throw new Error('Invalid protocol option');
}
}
this.server.listen(this.options.port, () => {
if (this.options.callback)
this.options.callback();
resolve();
});
}
catch (error) {
if (error instanceof Error) {
reject(error);
}
else {
reject(new Error('Unknown error occurred'));
}
}
}
/**
* Stops the server and releases all resources.
* @param callback - An optional callback function to be executed when the server stops.
* @returns A promise that resolves when the server stops.
*/
close(callback) {
return new Promise((resolve, reject) => {
if (this.server === null) {
reject(new Error('Server is not running'));
}
else {
this.server.close(() => {
this.server = null;
if (callback)
callback();
resolve();
});
}
});
}
}
/**
* A custom transport strategy for NestJS microservices that integrates with the '@connectrpc/connect-es' package.
*
* @remarks
* This class extends the `Server` class provided by NestJS and implements the `CustomTransportStrategy` interface.
*
* @example
* import { NestFactory } from '@nestjs/core';
* import { MicroserviceOptions, Transport } from '@nestjs/microservices';
* import { ServerBufConnect } from '@wolfcoded/nestjs-bufconnect';
* import { AppModule } from './app/app.module';
*
* async function bootstrap() {
* const serverOptions: HttpOptions = {
* protocol: ServerProtocol.HTTP,
* port: 3000,
* }
*
* const app = await NestFactory.createMicroservice<MicroserviceOptions>(
* AppModule, {
* strategy: new ServerBufConnect(serverOptions),
* }
* );
*
* await app.listen();
* }
*
* bootstrap();
*/
class ServerBufConnect extends microservices.Server {
CustomMetadataStore = null;
server = null;
Options;
/**
* Constructor for ServerBufConnect.
* @param options - The options for configuring the server.
*/
constructor(options) {
super();
this.CustomMetadataStore = CustomMetadataStore.getInstance();
this.Options = options;
}
/**
* Starts the HTTP server, listening on the specified port.
* @param callback - An optional callback to be executed when the server starts listening.
* @returns {Promise<void>} A promise that resolves when the server starts listening.
*/
async listen(callback) {
try {
const router = this.buildRouter();
this.server = new HTTPServer(this.Options, router);
await this.server.listen();
}
catch (error) {
callback(error);
}
}
/**
* Stops the HTTP server.
* @returns {Promise<void>} A promise that resolves when the server stops.
*/
async close() {
await this.server?.close();
}
/**
* Adds a message handler for the given pattern.
* @param pattern - The pattern associated with the message handler.
* @param callback - The message handler function.
* @param isEventHandler - Optional flag to mark the message handler as an event handler. Defaults to false.
*/
addHandler(pattern, callback, isEventHandler = false) {
const route = shared_utils.isString(pattern) ? pattern : JSON.stringify(pattern);
if (isEventHandler) {
const modifiedCallback = callback;
modifiedCallback.isEventHandler = true;
this.messageHandlers.set(route, modifiedCallback);
}
this.messageHandlers.set(route, callback);
}
/**
* Builds the ConnectRouter with the server's message handlers.
* @returns A function that takes a ConnectRouter and configures it with the server's message handlers.
* @private
*/
buildRouter() {
return (router) => {
if (this.CustomMetadataStore) {
const serviceHandlersMap = createServiceHandlersMap(this.getHandlers(), this.CustomMetadataStore);
addServicesToRouter(router, serviceHandlersMap, this.CustomMetadataStore);
}
};
}
}
exports.BufMethod = BufMethod;
exports.BufService = BufService;
exports.BufStreamMethod = BufStreamMethod;
exports.CustomMetadataStore = CustomMetadataStore;
exports.HTTPServer = HTTPServer;
exports.ServerBufConnect = ServerBufConnect;
exports.hasSubscribe = hasSubscribe;
exports.hasToPromise = hasToPromise;
exports.isAsyncGenerator = isAsyncGenerator;
exports.isObservable = isObservable;
exports.observableToAsyncGenerator = observableToAsyncGenerator;
exports.toAsyncGenerator = toAsyncGenerator;
exports.transformToObservable = transformToObservable;