UNPKG

@noxfly/noxus

Version:

Simulate lightweight HTTP-like requests between renderer and main process in Electron applications with MessagePort, with structured and modular design.

595 lines (492 loc) 24 kB
/** * @copyright 2025 NoxFly * @license MIT * @author NoxFly */ import 'reflect-metadata'; import { getControllerMetadata } from 'src/decorators/controller.decorator'; import { getGuardForController, getGuardForControllerAction, IGuard } from 'src/decorators/guards.decorator'; import { Injectable } from 'src/decorators/injectable.decorator'; import { AtomicHttpMethod, getRouteMetadata } from 'src/decorators/method.decorator'; import { getMiddlewaresForController, getMiddlewaresForControllerAction, IMiddleware, NextFunction } from 'src/decorators/middleware.decorator'; import { BadRequestException, MethodNotAllowedException, NotFoundException, ResponseException, UnauthorizedException } from 'src/exceptions'; import { IBatchRequestItem, IBatchRequestPayload, IBatchResponsePayload, IResponse, Request } from 'src/request'; import { InjectorExplorer } from 'src/DI/injector-explorer'; import { Logger } from 'src/utils/logger'; import { RadixTree } from 'src/utils/radix-tree'; import { Type } from 'src/utils/types'; /** * A lazy route entry maps a path prefix to a dynamic import function. * The module is loaded on the first request matching the prefix. */ export interface ILazyRoute { /** Path prefix (e.g. "auth", "printing"). Matched against the first segment(s) of the request path. */ path: string; /** Dynamic import function returning the module file. */ loadModule: () => Promise<unknown>; } interface LazyRouteEntry { loadModule: () => Promise<unknown>; loading: Promise<void> | null; loaded: boolean; } const ATOMIC_HTTP_METHODS: ReadonlySet<AtomicHttpMethod> = new Set<AtomicHttpMethod>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']); function isAtomicHttpMethod(method: unknown): method is AtomicHttpMethod { return typeof method === 'string' && ATOMIC_HTTP_METHODS.has(method as AtomicHttpMethod); } /** * IRouteDefinition interface defines the structure of a route in the application. * It includes the HTTP method, path, controller class, handler method name, * guards, and middlewares associated with the route. */ export interface IRouteDefinition { method: string; path: string; controller: Type<any>; handler: string; guards: Type<IGuard>[]; middlewares: Type<IMiddleware>[]; } /** * This type defines a function that represents an action in a controller. * It takes a Request and an IResponse as parameters and returns a value or a Promise. */ export type ControllerAction = (request: Request, response: IResponse) => any; /** * Router class is responsible for managing the application's routing. * It registers controllers, handles requests, and manages middlewares and guards. */ @Injectable('singleton') export class Router { private readonly routes = new RadixTree<IRouteDefinition>(); private readonly rootMiddlewares: Type<IMiddleware>[] = []; private readonly lazyRoutes = new Map<string, LazyRouteEntry>(); /** * Registers a controller class with the router. * This method extracts the route metadata from the controller class and registers it in the routing tree. * It also handles the guards and middlewares associated with the controller. * @param controllerClass - The controller class to register. */ public registerController(controllerClass: Type<unknown>): Router { const controllerMeta = getControllerMetadata(controllerClass); const controllerGuards = getGuardForController(controllerClass.name); const controllerMiddlewares = getMiddlewaresForController(controllerClass.name); if(!controllerMeta) throw new Error(`Missing @Controller decorator on ${controllerClass.name}`); const routeMetadata = getRouteMetadata(controllerClass); for(const def of routeMetadata) { const fullPath = `${controllerMeta.path}/${def.path}`.replace(/\/+/g, '/'); const routeGuards = getGuardForControllerAction(controllerClass.name, def.handler); const routeMiddlewares = getMiddlewaresForControllerAction(controllerClass.name, def.handler); const guards = new Set([...controllerGuards, ...routeGuards]); const middlewares = new Set([...controllerMiddlewares, ...routeMiddlewares]); const routeDef: IRouteDefinition = { method: def.method, path: fullPath, controller: controllerClass, handler: def.handler, guards: [...guards], middlewares: [...middlewares], }; this.routes.insert(fullPath + '/' + def.method, routeDef); const hasActionGuards = routeDef.guards.length > 0; const actionGuardsInfo = hasActionGuards ? '<' + routeDef.guards.map(g => g.name).join('|') + '>' : ''; Logger.log(`Mapped {${routeDef.method} /${fullPath}}${actionGuardsInfo} route`); } const hasCtrlGuards = controllerMeta.guards.length > 0; const controllerGuardsInfo = hasCtrlGuards ? '<' + controllerMeta.guards.map(g => g.name).join('|') + '>' : ''; Logger.log(`Mapped ${controllerClass.name}${controllerGuardsInfo} controller's routes`); return this; } /** * Registers a lazy route. The module behind this route prefix will only * be imported (and its controllers/services registered in DI) the first * time a request targets this prefix. * * @param pathPrefix - Route prefix (e.g. "auth"). Matched against the first segment of the request path. * @param loadModule - A function that returns a dynamic import promise. */ public registerLazyRoute(pathPrefix: string, loadModule: () => Promise<unknown>): Router { const normalized = pathPrefix.replace(/^\/+|\/+$/g, ''); this.lazyRoutes.set(normalized, { loadModule, loading: null, loaded: false }); Logger.log(`Registered lazy route prefix {${normalized}}`); return this; } /** * Defines a middleware for the root of the application. * This method allows you to register a middleware that will be applied to all requests * to the application, regardless of the controller or action. * @param middleware - The middleware class to register. */ public defineRootMiddleware(middleware: Type<IMiddleware>): Router { this.rootMiddlewares.push(middleware); return this; } /** * Shuts down the message channel for a specific sender ID. * This method closes the IPC channel for the specified sender ID and * removes it from the messagePorts map. * @param channelSenderId - The ID of the sender channel to shut down. */ public async handle(request: Request): Promise<IResponse> { if(request.method === 'BATCH') { return this.handleBatch(request); } return this.handleAtomic(request); } private async handleAtomic(request: Request): Promise<IResponse> { Logger.comment(`> ${request.method} /${request.path}`); const t0 = performance.now(); const response: IResponse = { requestId: request.id, status: 200, body: null, }; let isCritical: boolean = false; try { const routeDef = await this.findRoute(request); await this.resolveController(request, response, routeDef); if(response.status > 400) { throw new ResponseException(response.status, response.error); } } catch(error: unknown) { response.body = undefined; if(error instanceof ResponseException) { response.status = error.status; response.error = error.message; response.stack = error.stack; } else if(error instanceof Error) { isCritical = true; response.status = 500; response.error = error.message || 'Internal Server Error'; response.stack = error.stack || 'No stack trace available'; } else { isCritical = true; response.status = 500; response.error = 'Unknown error occurred'; response.stack = 'No stack trace available'; } } finally { const t1 = performance.now(); const message = `< ${response.status} ${request.method} /${request.path} ${Logger.colors.yellow}${Math.round(t1 - t0)}ms${Logger.colors.initial}`; if(response.status < 400) { Logger.log(message); } else if(response.status < 500) { Logger.warn(message); } else { if(isCritical) { Logger.critical(message); } else { Logger.error(message); } } if(response.error !== undefined) { if(isCritical) { Logger.critical(response.error); } else { Logger.error(response.error); } if(response.stack !== undefined) { Logger.errorStack(response.stack); } } return response; } } private async handleBatch(request: Request): Promise<IResponse> { Logger.comment(`> ${request.method} /${request.path}`); const t0 = performance.now(); const response: IResponse<IBatchResponsePayload> = { requestId: request.id, status: 200, body: { responses: [] }, }; let isCritical: boolean = false; try { const payload = this.normalizeBatchPayload(request.body); const batchPromises = payload.requests.map((item, index) => { const subRequestId = item.requestId ?? `${request.id}:${index}`; const atomicRequest = new Request(request.event, request.senderId, subRequestId, item.method, item.path, item.body); return this.handleAtomic(atomicRequest); }); response.body!.responses = await Promise.all(batchPromises); } catch(error: unknown) { response.body = undefined; if(error instanceof ResponseException) { response.status = error.status; response.error = error.message; response.stack = error.stack; } else if(error instanceof Error) { isCritical = true; response.status = 500; response.error = error.message || 'Internal Server Error'; response.stack = error.stack || 'No stack trace available'; } else { isCritical = true; response.status = 500; response.error = 'Unknown error occurred'; response.stack = 'No stack trace available'; } } finally { const t1 = performance.now(); const message = `< ${response.status} ${request.method} /${request.path} ${Logger.colors.yellow}${Math.round(t1 - t0)}ms${Logger.colors.initial}`; if(response.status < 400) { Logger.log(message); } else if(response.status < 500) { Logger.warn(message); } else { if(isCritical) { Logger.critical(message); } else { Logger.error(message); } } if(response.error !== undefined) { if(isCritical) { Logger.critical(response.error); } else { Logger.error(response.error); } if(response.stack !== undefined) { Logger.errorStack(response.stack); } } return response; } } private normalizeBatchPayload(body: unknown): IBatchRequestPayload { if(body === null || typeof body !== 'object') { throw new BadRequestException('Batch payload must be an object containing a requests array.'); } const possiblePayload = body as Partial<IBatchRequestPayload>; const { requests } = possiblePayload; if(!Array.isArray(requests)) { throw new BadRequestException('Batch payload must define a requests array.'); } const normalizedRequests = requests.map((entry, index) => this.normalizeBatchItem(entry, index)); return { requests: normalizedRequests }; } private normalizeBatchItem(entry: unknown, index: number): IBatchRequestItem { if(entry === null || typeof entry !== 'object') { throw new BadRequestException(`Batch request at index ${index} must be an object.`); } const { requestId, path, method, body } = entry as Partial<IBatchRequestItem> & { method?: unknown }; if(requestId !== undefined && typeof requestId !== 'string') { throw new BadRequestException(`Batch request at index ${index} has an invalid requestId.`); } if(typeof path !== 'string' || path.length === 0) { throw new BadRequestException(`Batch request at index ${index} must define a non-empty path.`); } if(typeof method !== 'string') { throw new BadRequestException(`Batch request at index ${index} must define an HTTP method.`); } const normalizedMethod = method.toUpperCase(); if(!isAtomicHttpMethod(normalizedMethod)) { throw new BadRequestException(`Batch request at index ${index} uses the unsupported method ${method}.`); } return { requestId, path, method: normalizedMethod as AtomicHttpMethod, body, }; } /** * Finds the route definition for a given request. * This method searches the routing tree for a matching route based on the request's path and method. * If no matching route is found, it throws a NotFoundException. * @param request - The Request object containing the method and path to search for. * @returns The IRouteDefinition for the matched route. */ /** * Attempts to find a route definition for the given request. * Returns undefined instead of throwing when the route is not found, * so the caller can try lazy-loading first. */ private tryFindRoute(request: Request): IRouteDefinition | undefined { const matchedRoutes = this.routes.search(request.path); if(matchedRoutes?.node === undefined || matchedRoutes.node.children.length === 0) { return undefined; } const routeDef = matchedRoutes.node.findExactChild(request.method); return routeDef?.value; } /** * Finds the route definition for a given request. * If no eagerly-registered route matches, attempts to load a lazy module * whose prefix matches the request path, then retries. */ private async findRoute(request: Request): Promise<IRouteDefinition> { // Fast path: route already registered const direct = this.tryFindRoute(request); if(direct) return direct; // Try lazy route loading await this.tryLoadLazyRoute(request.path); // Retry after lazy load const afterLazy = this.tryFindRoute(request); if(afterLazy) return afterLazy; throw new NotFoundException(`No route matches ${request.method} ${request.path}`); } /** * Given a request path, checks whether a lazy route prefix matches * and triggers the dynamic import if it hasn't been loaded yet. */ private async tryLoadLazyRoute(requestPath: string): Promise<void> { const firstSegment = requestPath.replace(/^\/+/, '').split('/')[0] ?? ''; // Check exact first segment, then try multi-segment prefixes for(const [prefix, entry] of this.lazyRoutes) { if(entry.loaded) continue; const normalizedPath = requestPath.replace(/^\/+/, ''); if(normalizedPath === prefix || normalizedPath.startsWith(prefix + '/') || firstSegment === prefix) { if(!entry.loading) { entry.loading = this.loadLazyModule(prefix, entry); } await entry.loading; return; } } } /** * Dynamically imports a lazy module and registers its decorated classes * (controllers, services) in the DI container using the two-phase strategy. */ private async loadLazyModule(prefix: string, entry: LazyRouteEntry): Promise<void> { const t0 = performance.now(); InjectorExplorer.beginAccumulate(); await entry.loadModule(); InjectorExplorer.flushAccumulated(); entry.loaded = true; const t1 = performance.now(); Logger.info(`Lazy-loaded module for prefix {${prefix}} in ${Math.round(t1 - t0)}ms`); } /** * Resolves the controller for a given route definition. * This method creates an instance of the controller class and prepares the request parameters. * It also runs the request pipeline, which includes executing middlewares and guards. * @param request - The Request object containing the request data. * @param response - The IResponse object to populate with the response data. * @param routeDef - The IRouteDefinition for the matched route. * @return A Promise that resolves when the controller action has been executed. * @throws UnauthorizedException if the request is not authorized by the guards. */ private async resolveController(request: Request, response: IResponse, routeDef: IRouteDefinition): Promise<void> { const controllerInstance = request.context.resolve(routeDef.controller); Object.assign(request.params, this.extractParams(request.path, routeDef.path)); await this.runRequestPipeline(request, response, routeDef, controllerInstance); } /** * Runs the request pipeline for a given request. * This method executes the middlewares and guards associated with the route, * and finally calls the controller action. * @param request - The Request object containing the request data. * @param response - The IResponse object to populate with the response data. * @param routeDef - The IRouteDefinition for the matched route. * @param controllerInstance - The instance of the controller class. * @return A Promise that resolves when the request pipeline has been executed. * @throws ResponseException if the response status is not successful. */ private async runRequestPipeline(request: Request, response: IResponse, routeDef: IRouteDefinition, controllerInstance: any): Promise<void> { const middlewares = [...new Set([...this.rootMiddlewares, ...routeDef.middlewares])]; const middlewareMaxIndex = middlewares.length - 1; const guardsMaxIndex = middlewareMaxIndex + routeDef.guards.length; let index = -1; const dispatch = async (i: number): Promise<void> => { if(i <= index) throw new Error("next() called multiple times"); index = i; // middlewares if(i <= middlewareMaxIndex) { const nextFn = dispatch.bind(null, i + 1); await this.runMiddleware(request, response, nextFn, middlewares[i]!); if(response.status >= 400) { throw new ResponseException(response.status, response.error); } return; } // guards if(i <= guardsMaxIndex) { const guardIndex = i - middlewares.length; const guardType = routeDef.guards[guardIndex]!; await this.runGuard(request, guardType); await dispatch(i + 1); return; } // endpoint action const action = controllerInstance[routeDef.handler] as ControllerAction; response.body = await action.call(controllerInstance, request, response); // avoid parsing error on the renderer if the action just does treatment without returning anything if(response.body === undefined) { response.body = {}; } }; await dispatch(0); } /** * Runs a middleware function in the request pipeline. * This method creates an instance of the middleware and invokes its `invoke` method, * passing the request, response, and next function. * @param request - The Request object containing the request data. * @param response - The IResponse object to populate with the response data. * @param next - The NextFunction to call to continue the middleware chain. * @param middlewareType - The type of the middleware to run. * @return A Promise that resolves when the middleware has been executed. */ private async runMiddleware(request: Request, response: IResponse, next: NextFunction, middlewareType: Type<IMiddleware>): Promise<void> { const middleware = request.context.resolve(middlewareType); await middleware.invoke(request, response, next); } /** * Runs a guard to check if the request is authorized. * This method creates an instance of the guard and calls its `canActivate` method. * If the guard returns false, it throws an UnauthorizedException. * @param request - The Request object containing the request data. * @param guardType - The type of the guard to run. * @return A Promise that resolves if the guard allows the request, or throws an UnauthorizedException if not. * @throws UnauthorizedException if the guard denies access to the request. */ private async runGuard(request: Request, guardType: Type<IGuard>): Promise<void> { const guard = request.context.resolve(guardType); const allowed = await guard.canActivate(request); if(!allowed) throw new UnauthorizedException(`Unauthorized for ${request.method} ${request.path}`); } /** * Extracts parameters from the actual request path based on the template path. * This method splits the actual path and the template path into segments, * then maps the segments to parameters based on the template. * @param actual - The actual request path. * @param template - The template path to extract parameters from. * @returns An object containing the extracted parameters. */ private extractParams(actual: string, template: string): Record<string, string> { const aParts = actual.split('/'); const tParts = template.split('/'); const params: Record<string, string> = {}; tParts.forEach((part, i) => { if(part.startsWith(':')) { params[part.slice(1)] = aParts[i] ?? ''; } }); return params; } }