@noxfly/noxus
Version:
Simulate lightweight HTTP-like requests between renderer and main process in Electron applications with MessagePort, with structured and modular design.
339 lines (279 loc) • 14.4 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 { getRouteMetadata } from 'src/decorators/method.decorator';
import { getMiddlewaresForController, getMiddlewaresForControllerAction, IMiddleware, NextFunction } from 'src/decorators/middleware.decorator';
import { MethodNotAllowedException, NotFoundException, ResponseException, UnauthorizedException } from 'src/exceptions';
import { IResponse, Request } from 'src/request';
import { Logger } from 'src/utils/logger';
import { RadixTree } from 'src/utils/radix-tree';
import { Type } from 'src/utils/types';
/**
* 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>[] = [];
/**
* 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;
}
/**
* 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> {
Logger.comment(`> ${request.method} /${request.path}`);
const t0 = performance.now();
const response: IResponse = {
requestId: request.id,
status: 200,
body: null,
};
try {
const routeDef = 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) {
response.status = 500;
response.error = error.message || 'Internal Server Error';
response.stack = error.stack || 'No stack trace available';
}
else {
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
Logger.error(message);
if(response.error !== undefined) {
Logger.error(response.error);
if(response.stack !== undefined) {
Logger.errorStack(response.stack);
}
}
return response;
}
}
/**
* 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.
*/
private findRoute(request: Request): IRouteDefinition {
const matchedRoutes = this.routes.search(request.path);
if(matchedRoutes?.node === undefined || matchedRoutes.node.children.length === 0) {
throw new NotFoundException(`No route matches ${request.method} ${request.path}`);
}
const routeDef = matchedRoutes.node.findExactChild(request.method);
if(routeDef?.value === undefined) {
throw new MethodNotAllowedException(`Method Not Allowed for ${request.method} ${request.path}`);
}
return routeDef.value;
}
/**
* 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;
}
}