@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
text/typescript
/**
* @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.
*/
('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;
}
}