UNPKG

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