5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.env
|
||||||
|
coverage/
|
||||||
|
logs/
|
||||||
|
.locale/
|
||||||
|
cache/
|
13
.vscode/settings.json
vendored
Normal file
13
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"editor.tabSize": 4,
|
||||||
|
"editor.insertSpaces": true,
|
||||||
|
"deno.enable": true,
|
||||||
|
"deno.testing.args": [
|
||||||
|
"--allow-all",
|
||||||
|
"--unstable-kv"
|
||||||
|
],
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.defaultFormatter": "denoland.vscode-deno",
|
||||||
|
"editor.detectIndentation": false,
|
||||||
|
"editor.indentSize": "tabSize",
|
||||||
|
}
|
28
deno.jsonc
Normal file
28
deno.jsonc
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
// "start": "deno run --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose",
|
||||||
|
// "watch": "deno run --watch --allow-net --allow-env --unstable-kv --allow-read --allow-write --env-file src/main.ts -- --verbose",
|
||||||
|
"test": "deno test --allow-net --allow-env --unstable-kv --allow-read --allow-write --coverage **/__tests__/*.test.ts"
|
||||||
|
},
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"fmt": {
|
||||||
|
"useTabs": false,
|
||||||
|
"lineWidth": 80,
|
||||||
|
"indentWidth": 4,
|
||||||
|
"semiColons": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"include": [
|
||||||
|
"src/",
|
||||||
|
"main.ts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"importMap": "./import_map.json"
|
||||||
|
}
|
121
src/HttpKernel.ts
Normal file
121
src/HttpKernel.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import {
|
||||||
|
IContext,
|
||||||
|
IHandler,
|
||||||
|
IHttpKernel,
|
||||||
|
IInternalRoute,
|
||||||
|
IMiddleware,
|
||||||
|
IRouteBuilder,
|
||||||
|
IRouteBuilderFactory,
|
||||||
|
IRouteDefinition,
|
||||||
|
ResponseDecorator,
|
||||||
|
} from './Interfaces/mod.ts';
|
||||||
|
import { RouteBuilder } from './RouteBuilder.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The central HTTP kernel responsible for managing route definitions,
|
||||||
|
* executing middleware chains, and dispatching HTTP requests to their handlers.
|
||||||
|
*
|
||||||
|
* This class supports a fluent API for route registration and allows the injection
|
||||||
|
* of custom response decorators and route builder factories for maximum flexibility and testability.
|
||||||
|
*/
|
||||||
|
export class HttpKernel implements IHttpKernel {
|
||||||
|
/**
|
||||||
|
* The list of internally registered routes, each with method, matcher, middleware, and handler.
|
||||||
|
*/
|
||||||
|
private routes: IInternalRoute[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new instance of the `HttpKernel`.
|
||||||
|
*
|
||||||
|
* @param decorateResponse - An optional response decorator function that is applied to all responses
|
||||||
|
* after the middleware/handler pipeline. Defaults to identity (no modification).
|
||||||
|
* @param routeBuilderFactory - Optional factory for creating route builders. Defaults to using `RouteBuilder`.
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private readonly decorateResponse: ResponseDecorator = (res) => res,
|
||||||
|
private readonly routeBuilderFactory: IRouteBuilderFactory =
|
||||||
|
RouteBuilder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
public route(definition: IRouteDefinition): IRouteBuilder {
|
||||||
|
return new this.routeBuilderFactory(
|
||||||
|
this.registerRoute.bind(this),
|
||||||
|
definition,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @inheritdoc
|
||||||
|
*/
|
||||||
|
public handle = async (request: Request): Promise<Response> => {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const method = request.method.toUpperCase();
|
||||||
|
|
||||||
|
for (const route of this.routes) {
|
||||||
|
if (route.method !== method) continue;
|
||||||
|
const match = route.matcher(url, request);
|
||||||
|
if (match) {
|
||||||
|
const ctx: IContext = {
|
||||||
|
req: request,
|
||||||
|
params: match.params,
|
||||||
|
state: {},
|
||||||
|
};
|
||||||
|
return await this.executePipeline(
|
||||||
|
ctx,
|
||||||
|
route.middlewares,
|
||||||
|
route.handler,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('Not Found', { status: 404 });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registers a finalized route by pushing it into the internal route list.
|
||||||
|
*
|
||||||
|
* This method is typically called by the route builder after `.handle()` is invoked.
|
||||||
|
*
|
||||||
|
* @param route - The fully constructed route including matcher, middlewares, and handler.
|
||||||
|
*/
|
||||||
|
private registerRoute(route: IInternalRoute): void {
|
||||||
|
this.routes.push(route);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes the middleware pipeline and final handler for a given request context.
|
||||||
|
*
|
||||||
|
* This function recursively invokes middleware in the order they were registered,
|
||||||
|
* ending with the route's final handler. If a middleware returns a response directly
|
||||||
|
* without calling `next()`, the pipeline is short-circuited.
|
||||||
|
*
|
||||||
|
* The final response is passed through the `decorateResponse` function before being returned.
|
||||||
|
*
|
||||||
|
* @param ctx - The request context containing the request, parameters, and shared state.
|
||||||
|
* @param middleware - The ordered list of middleware to apply before the handler.
|
||||||
|
* @param handler - The final request handler to invoke at the end of the pipeline.
|
||||||
|
* @returns The final HTTP response after middleware and decoration.
|
||||||
|
*/
|
||||||
|
private async executePipeline(
|
||||||
|
ctx: IContext,
|
||||||
|
middleware: IMiddleware[],
|
||||||
|
handler: IHandler,
|
||||||
|
): Promise<Response> {
|
||||||
|
let i = -1;
|
||||||
|
const dispatch = async (index: number): Promise<Response> => {
|
||||||
|
if (index <= i) throw new Error('next() called multiple times');
|
||||||
|
i = index;
|
||||||
|
const fn: IMiddleware | IHandler = index < middleware.length
|
||||||
|
? middleware[index]
|
||||||
|
: handler;
|
||||||
|
if (!fn) return new Response('Internal error', { status: 500 });
|
||||||
|
return index < middleware.length
|
||||||
|
? await fn(ctx, () => dispatch(index + 1))
|
||||||
|
: await (fn as IHandler)(ctx);
|
||||||
|
};
|
||||||
|
return this.decorateResponse(await dispatch(0));
|
||||||
|
}
|
||||||
|
}
|
31
src/Interfaces/IContext.ts
Normal file
31
src/Interfaces/IContext.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Represents the per-request context passed through the middleware pipeline and to the final handler.
|
||||||
|
*
|
||||||
|
* This context object encapsulates the original HTTP request,
|
||||||
|
* the path parameters extracted from the matched route,
|
||||||
|
* and a mutable state object for sharing information across middlewares and handlers.
|
||||||
|
*/
|
||||||
|
export interface IContext {
|
||||||
|
/**
|
||||||
|
* The original HTTP request object as received by Deno.
|
||||||
|
* Contains all standard fields like headers, method, body, etc.
|
||||||
|
*/
|
||||||
|
req: Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route parameters parsed from the URL path, based on route definitions
|
||||||
|
* that include dynamic segments (e.g., `/users/:id` → `{ id: "123" }`).
|
||||||
|
*
|
||||||
|
* These parameters are considered read-only and are set by the router.
|
||||||
|
*/
|
||||||
|
params: Record<string, string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A shared, mutable object used to pass arbitrary data between middlewares and handlers.
|
||||||
|
*
|
||||||
|
* Use this field to attach validated user info, auth state, logging context, etc.
|
||||||
|
*
|
||||||
|
* Each key should be well-named to avoid collisions across layers.
|
||||||
|
*/
|
||||||
|
state: Record<string, unknown>;
|
||||||
|
}
|
16
src/Interfaces/IHandler.ts
Normal file
16
src/Interfaces/IHandler.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { IContext } from './IContext.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a final request handler responsible for generating a response.
|
||||||
|
*
|
||||||
|
* The handler is the last step in the middleware pipeline and must return
|
||||||
|
* a valid HTTP `Response`. It has access to all data injected into the
|
||||||
|
* request context, including path parameters and any state added by middleware.
|
||||||
|
*/
|
||||||
|
export interface IHandler {
|
||||||
|
/**
|
||||||
|
* @param ctx - The complete request context, including parameters and middleware state.
|
||||||
|
* @returns A promise resolving to an HTTP `Response`.
|
||||||
|
*/
|
||||||
|
(ctx: IContext): Promise<Response>;
|
||||||
|
}
|
33
src/Interfaces/IHttpKernel.ts
Normal file
33
src/Interfaces/IHttpKernel.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { IRouteBuilder } from './IRouteBuilder.ts';
|
||||||
|
import { IRouteDefinition } from './IRouteDefinition.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines the core interface for the HTTP kernel, responsible for route registration,
|
||||||
|
* middleware orchestration, and request dispatching.
|
||||||
|
*/
|
||||||
|
export interface IHttpKernel {
|
||||||
|
/**
|
||||||
|
* Registers a new route with a static path pattern or a dynamic matcher.
|
||||||
|
*
|
||||||
|
* This method accepts both conventional route definitions (with path templates)
|
||||||
|
* and advanced matcher-based routes for flexible URL structures.
|
||||||
|
*
|
||||||
|
* Returns a route builder that allows chaining middleware and assigning a handler.
|
||||||
|
*
|
||||||
|
* @param definition - A static or dynamic route definition, including the HTTP method
|
||||||
|
* and either a path pattern or custom matcher function.
|
||||||
|
* @returns A builder interface to attach middleware and define the handler.
|
||||||
|
*/
|
||||||
|
route(definition: IRouteDefinition): IRouteBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles an incoming HTTP request by matching it against registered routes,
|
||||||
|
* executing any associated middleware in order, and invoking the final route handler.
|
||||||
|
*
|
||||||
|
* This method serves as the main entry point to integrate with `Deno.serve`.
|
||||||
|
*
|
||||||
|
* @param request - The incoming HTTP request object.
|
||||||
|
* @returns A promise resolving to the final HTTP response.
|
||||||
|
*/
|
||||||
|
handle(request: Request): Promise<Response>;
|
||||||
|
}
|
42
src/Interfaces/IInternalRoute.ts
Normal file
42
src/Interfaces/IInternalRoute.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { IHandler } from './IHandler.ts';
|
||||||
|
import { IMiddleware } from './IMiddleware.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an internally registered route within the HttpKernel.
|
||||||
|
*
|
||||||
|
* Contains all data required to match an incoming request and dispatch it
|
||||||
|
* through the associated middleware chain and final handler.
|
||||||
|
*/
|
||||||
|
export interface IInternalRoute {
|
||||||
|
/**
|
||||||
|
* The HTTP method (e.g. 'GET', 'POST') that this route responds to.
|
||||||
|
* The method should always be in uppercase.
|
||||||
|
*/
|
||||||
|
method: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A matcher function used to determine whether this route matches a given request.
|
||||||
|
*
|
||||||
|
* If the matcher returns `null`, the route does not apply to the request.
|
||||||
|
* If it returns a params object, the route is considered matched and the extracted
|
||||||
|
* parameters are passed into the request context.
|
||||||
|
*
|
||||||
|
* @param url - The parsed URL object from the incoming request.
|
||||||
|
* @param req - The original Request object.
|
||||||
|
* @returns An object with extracted path parameters, or `null` if not matched.
|
||||||
|
*/
|
||||||
|
matcher: (
|
||||||
|
url: URL,
|
||||||
|
req: Request,
|
||||||
|
) => null | { params: Record<string, string> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ordered list of middleware functions to be executed before the handler.
|
||||||
|
*/
|
||||||
|
middlewares: IMiddleware[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The final handler that generates the HTTP response after all middleware has run.
|
||||||
|
*/
|
||||||
|
handler: IHandler;
|
||||||
|
}
|
20
src/Interfaces/IMiddleware.ts
Normal file
20
src/Interfaces/IMiddleware.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { IContext } from './IContext.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a middleware function in the HTTP request pipeline.
|
||||||
|
*
|
||||||
|
* Middleware can perform tasks such as logging, authentication, validation,
|
||||||
|
* or response transformation. It receives the current request context and
|
||||||
|
* a `next()` function to delegate control to the next middleware or final handler.
|
||||||
|
*
|
||||||
|
* To stop the request pipeline, a middleware can return a `Response` directly
|
||||||
|
* without calling `next()`.
|
||||||
|
*/
|
||||||
|
export interface IMiddleware {
|
||||||
|
/**
|
||||||
|
* @param ctx - The request context, containing the request, path parameters, and shared state.
|
||||||
|
* @param next - A function that continues the middleware pipeline. Returns the final `Response`.
|
||||||
|
* @returns A promise resolving to an HTTP `Response`.
|
||||||
|
*/
|
||||||
|
(ctx: IContext, next: () => Promise<Response>): Promise<Response>;
|
||||||
|
}
|
35
src/Interfaces/IRouteBuilder.ts
Normal file
35
src/Interfaces/IRouteBuilder.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { IHandler } from './IHandler.ts';
|
||||||
|
import { IInternalRoute } from './IInternalRoute.ts';
|
||||||
|
import { IMiddleware } from './IMiddleware.ts';
|
||||||
|
import { IRouteDefinition } from './IRouteDefinition.ts';
|
||||||
|
|
||||||
|
export interface IRouteBuilderFactory {
|
||||||
|
new (
|
||||||
|
registerRoute: (route: IInternalRoute) => void,
|
||||||
|
def: IRouteDefinition,
|
||||||
|
mws?: IMiddleware[],
|
||||||
|
): IRouteBuilder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a fluent API to build a single route configuration by chaining
|
||||||
|
* middleware and setting the final request handler.
|
||||||
|
*/
|
||||||
|
export interface IRouteBuilder {
|
||||||
|
/**
|
||||||
|
* Adds a middleware to the current route.
|
||||||
|
* Middleware will be executed in the order of registration.
|
||||||
|
*
|
||||||
|
* @param mw - A middleware function.
|
||||||
|
* @returns The route builder for further chaining.
|
||||||
|
*/
|
||||||
|
middleware(mw: IMiddleware): IRouteBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the final request handler for the route.
|
||||||
|
* Calling this finalizes the route and registers it in the kernel.
|
||||||
|
*
|
||||||
|
* @param handler - The function to execute when this route is matched.
|
||||||
|
*/
|
||||||
|
handle(handler: IHandler): void;
|
||||||
|
}
|
46
src/Interfaces/IRouteDefinition.ts
Normal file
46
src/Interfaces/IRouteDefinition.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { IRouteMatcher } from './IRouteMatcher.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a static route using a path pattern with optional parameters.
|
||||||
|
*
|
||||||
|
* Suitable for conventional routes like "/users/:id", which can be parsed
|
||||||
|
* into named parameters using a path-matching library.
|
||||||
|
*/
|
||||||
|
export interface IStaticRouteDefinition {
|
||||||
|
/**
|
||||||
|
* The HTTP method this route should match (e.g. "GET", "POST").
|
||||||
|
*/
|
||||||
|
method: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A static path pattern for the route, which may include named parameters
|
||||||
|
* (e.g. "/caches/:id"). Internally, this can be converted to a regex matcher.
|
||||||
|
*/
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a dynamic route using a custom matcher function instead of a static path.
|
||||||
|
*
|
||||||
|
* Useful for complex URL structures that cannot easily be expressed using a static pattern,
|
||||||
|
* such as routes with variable prefixes or conditional segment logic.
|
||||||
|
*/
|
||||||
|
export interface IDynamicRouteDefinition {
|
||||||
|
/**
|
||||||
|
* The HTTP method this route should match (e.g. "GET", "POST").
|
||||||
|
*/
|
||||||
|
method: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom matcher function that receives the parsed URL and raw request.
|
||||||
|
* If the function returns `null`, the route does not match.
|
||||||
|
* If the function returns a params object, the route is considered matched.
|
||||||
|
*/
|
||||||
|
matcher: IRouteMatcher;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route definition can either be a conventional static route with a path pattern,
|
||||||
|
* or a dynamic route with a custom matcher function for advanced matching logic.
|
||||||
|
*/
|
||||||
|
export type IRouteDefinition = IStaticRouteDefinition | IDynamicRouteDefinition;
|
34
src/Interfaces/IRouteMatcher.ts
Normal file
34
src/Interfaces/IRouteMatcher.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { IRouteDefinition } from './IRouteDefinition.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defines a route matcher function that evaluates whether a route applies to a given request.
|
||||||
|
*
|
||||||
|
* If the route matches, the matcher returns an object containing extracted route parameters.
|
||||||
|
* Otherwise, it returns `null`.
|
||||||
|
*/
|
||||||
|
export interface IRouteMatcher {
|
||||||
|
/**
|
||||||
|
* Evaluates whether the given URL and request match a defined route.
|
||||||
|
*
|
||||||
|
* @param url - The full URL of the incoming request.
|
||||||
|
* @param req - The raw Request object (may be used for context or headers).
|
||||||
|
* @returns An object containing path parameters if matched, or `null` if not matched.
|
||||||
|
*/
|
||||||
|
(url: URL, req: Request): null | { params: Record<string, string> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a factory for creating route matcher functions from route definitions.
|
||||||
|
*
|
||||||
|
* This allows the matcher logic to be injected or replaced (e.g. for testing,
|
||||||
|
* pattern libraries, or advanced routing scenarios).
|
||||||
|
*/
|
||||||
|
export interface IRouteMatcherFactory {
|
||||||
|
/**
|
||||||
|
* Creates a matcher function based on a given route definition.
|
||||||
|
*
|
||||||
|
* @param def - The route definition (static or dynamic).
|
||||||
|
* @returns A matcher function that checks if a request matches and extracts parameters.
|
||||||
|
*/
|
||||||
|
(def: IRouteDefinition): IRouteMatcher;
|
||||||
|
}
|
25
src/Interfaces/ResponseDecorator.ts
Normal file
25
src/Interfaces/ResponseDecorator.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* A function that modifies or enriches an outgoing HTTP response before it is returned to the client.
|
||||||
|
*
|
||||||
|
* This decorator can be used to inject headers (e.g., CORS, security), apply global transformations,
|
||||||
|
* or wrap responses for logging, analytics, or debugging purposes.
|
||||||
|
*
|
||||||
|
* It is called exactly once at the end of the middleware/handler pipeline,
|
||||||
|
* allowing central response customization without interfering with business logic.
|
||||||
|
*
|
||||||
|
* @param res - The original `Response` object produced by the route handler or middleware chain.
|
||||||
|
* @returns A modified or wrapped `Response` object to be sent back to the client.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const addCors: ResponseDecorator = (res) => {
|
||||||
|
* const headers = new Headers(res.headers);
|
||||||
|
* headers.set("Access-Control-Allow-Origin", "*");
|
||||||
|
* return new Response(res.body, {
|
||||||
|
* status: res.status,
|
||||||
|
* headers,
|
||||||
|
* });
|
||||||
|
* };
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export type ResponseDecorator = (res: Response) => Response;
|
13
src/Interfaces/mod.ts
Normal file
13
src/Interfaces/mod.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type { IContext } from './IContext.ts';
|
||||||
|
export type { IMiddleware } from './IMiddleware.ts';
|
||||||
|
export type { IHandler } from './IHandler.ts';
|
||||||
|
export type { IHttpKernel } from './IHttpKernel.ts';
|
||||||
|
export type { IRouteBuilder, IRouteBuilderFactory } from './IRouteBuilder.ts';
|
||||||
|
export type {
|
||||||
|
IDynamicRouteDefinition,
|
||||||
|
IRouteDefinition,
|
||||||
|
IStaticRouteDefinition,
|
||||||
|
} from './IRouteDefinition.ts';
|
||||||
|
export type { IInternalRoute } from './IInternalRoute.ts';
|
||||||
|
export type { IRouteMatcher } from './IRouteMatcher.ts';
|
||||||
|
export type { ResponseDecorator } from './ResponseDecorator.ts';
|
66
src/RouteBuilder.ts
Normal file
66
src/RouteBuilder.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { IRouteMatcherFactory } from './Interfaces/IRouteMatcher.ts';
|
||||||
|
import {
|
||||||
|
IHandler,
|
||||||
|
IInternalRoute,
|
||||||
|
IMiddleware,
|
||||||
|
IRouteBuilder,
|
||||||
|
IRouteDefinition,
|
||||||
|
} from './Interfaces/mod.ts';
|
||||||
|
import { createRouteMatcher } from './Utils.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides a fluent builder interface for defining a single route,
|
||||||
|
* including HTTP method, path or matcher, middleware chain and final handler.
|
||||||
|
*
|
||||||
|
* This builder is stateless and immutable; each chained call returns a new instance.
|
||||||
|
*/
|
||||||
|
export class RouteBuilder implements IRouteBuilder {
|
||||||
|
/**
|
||||||
|
* Constructs a new instance of the route builder.
|
||||||
|
*
|
||||||
|
* @param registerRoute - A delegate used to register the finalized route definition.
|
||||||
|
* @param def - The route definition (static path or dynamic matcher).
|
||||||
|
* @param mws - The list of middleware functions collected so far (default: empty).
|
||||||
|
*/
|
||||||
|
constructor(
|
||||||
|
private readonly registerRoute: (route: IInternalRoute) => void,
|
||||||
|
private readonly def: IRouteDefinition,
|
||||||
|
private readonly mws: IMiddleware[] = [],
|
||||||
|
private readonly matcherFactory: IRouteMatcherFactory =
|
||||||
|
createRouteMatcher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a middleware function to the current route definition.
|
||||||
|
*
|
||||||
|
* Middleware is executed in the order it is added.
|
||||||
|
* Returns a new builder instance with the additional middleware appended.
|
||||||
|
*
|
||||||
|
* @param mw - A middleware function to be executed before the handler.
|
||||||
|
* @returns A new `RouteBuilder` instance for continued chaining.
|
||||||
|
*/
|
||||||
|
middleware(mw: IMiddleware): IRouteBuilder {
|
||||||
|
return new RouteBuilder(this.registerRoute, this.def, [
|
||||||
|
...this.mws,
|
||||||
|
mw,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finalizes the route by assigning the handler and registering the route.
|
||||||
|
*
|
||||||
|
* Internally constructs a matcher function from the route definition
|
||||||
|
* and passes all route data to the registration delegate.
|
||||||
|
*
|
||||||
|
* @param handler - The final request handler for this route.
|
||||||
|
*/
|
||||||
|
handle(handler: IHandler): void {
|
||||||
|
const matcher = this.matcherFactory(this.def);
|
||||||
|
this.registerRoute({
|
||||||
|
method: this.def.method.toUpperCase(),
|
||||||
|
matcher,
|
||||||
|
middlewares: this.mws,
|
||||||
|
handler,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
61
src/Utils.ts
Normal file
61
src/Utils.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { IRouteDefinition, IRouteMatcher } from './Interfaces/mod.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a matcher function from a given route definition.
|
||||||
|
*
|
||||||
|
* This utility supports both static path-based route definitions (e.g. `/users/:id`)
|
||||||
|
* and custom matcher functions for dynamic routing scenarios.
|
||||||
|
*
|
||||||
|
* ### Static Path Example
|
||||||
|
* For a definition like:
|
||||||
|
* ```ts
|
||||||
|
* { method: "GET", path: "/users/:id" }
|
||||||
|
* ```
|
||||||
|
* the returned matcher function will:
|
||||||
|
* - match requests to `/users/123`
|
||||||
|
* - extract `{ id: "123" }` as `params`
|
||||||
|
*
|
||||||
|
* ### Dynamic Matcher Example
|
||||||
|
* If the `IRouteDefinition` includes a `matcher` function, it will be used as-is.
|
||||||
|
*
|
||||||
|
* @param def - The route definition to convert into a matcher function.
|
||||||
|
* Can be static (`path`) or dynamic (`matcher`).
|
||||||
|
*
|
||||||
|
* @returns A matcher function that receives a `URL` and `Request` and returns:
|
||||||
|
* - `{ params: Record<string, string> }` if the route matches
|
||||||
|
* - `null` if the route does not match the request
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```ts
|
||||||
|
* const matcher = createRouteMatcher({ method: "GET", path: "/repo/:owner/:name" });
|
||||||
|
* const result = matcher(new URL("http://localhost/repo/foo/bar"), req);
|
||||||
|
* // result: { params: { owner: "foo", name: "bar" } }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createRouteMatcher(
|
||||||
|
def: IRouteDefinition,
|
||||||
|
): IRouteMatcher {
|
||||||
|
if ('matcher' in def) {
|
||||||
|
return def.matcher;
|
||||||
|
} else {
|
||||||
|
const pattern = def.path;
|
||||||
|
const keys: string[] = [];
|
||||||
|
const regex = new RegExp(
|
||||||
|
'^' +
|
||||||
|
pattern.replace(/:[^\/]+/g, (m) => {
|
||||||
|
keys.push(m.substring(1));
|
||||||
|
return '([^/]+)';
|
||||||
|
}) +
|
||||||
|
'$',
|
||||||
|
);
|
||||||
|
return (url: URL) => {
|
||||||
|
const match = url.pathname.match(regex);
|
||||||
|
if (!match) return null;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
params[keys[i]] = decodeURIComponent(match[i + 1]);
|
||||||
|
}
|
||||||
|
return { params };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
161
src/__tests__/HttpKernel.test.ts
Normal file
161
src/__tests__/HttpKernel.test.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import {
|
||||||
|
assertEquals,
|
||||||
|
assertRejects,
|
||||||
|
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
|
||||||
|
import { HttpKernel } from '../HttpKernel.ts';
|
||||||
|
import { IRouteDefinition } from '../Interfaces/mod.ts';
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: matches static route and executes handler', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
|
||||||
|
const def: IRouteDefinition = { method: 'GET', path: '/hello' };
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
kernel.route(def).handle(() => {
|
||||||
|
called = true;
|
||||||
|
return Promise.resolve(new Response('OK', { status: 200 }));
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await kernel.handle(
|
||||||
|
new Request('http://localhost/hello', { method: 'GET' }),
|
||||||
|
);
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(await res.text(), 'OK');
|
||||||
|
assertEquals(called, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: supports dynamic matcher', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
const def: IRouteDefinition = {
|
||||||
|
method: 'GET',
|
||||||
|
matcher: (url) => url.pathname === '/dyn' ? { params: {} } : null,
|
||||||
|
};
|
||||||
|
|
||||||
|
kernel.route(def).handle(() =>
|
||||||
|
Promise.resolve(new Response('Dyn', { status: 200 }))
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await kernel.handle(new Request('http://localhost/dyn'));
|
||||||
|
assertEquals(res.status, 200);
|
||||||
|
assertEquals(await res.text(), 'Dyn');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: calls middleware in order and passes to handler', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
kernel.route({ method: 'GET', path: '/test' })
|
||||||
|
.middleware(async (ctx, next) => {
|
||||||
|
calls.push('mw1');
|
||||||
|
return await next();
|
||||||
|
})
|
||||||
|
.middleware(async (ctx, next) => {
|
||||||
|
calls.push('mw2');
|
||||||
|
return await next();
|
||||||
|
})
|
||||||
|
.handle(() => {
|
||||||
|
calls.push('handler');
|
||||||
|
return Promise.resolve(new Response('done'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await kernel.handle(
|
||||||
|
new Request('http://localhost/test', { method: 'GET' }),
|
||||||
|
);
|
||||||
|
assertEquals(await res.text(), 'done');
|
||||||
|
assertEquals(calls, ['mw1', 'mw2', 'handler']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
kernel.route({ method: 'GET', path: '/stop' })
|
||||||
|
.middleware(() => {
|
||||||
|
calls.push('mw1');
|
||||||
|
return Promise.resolve(new Response('blocked', { status: 403 }));
|
||||||
|
})
|
||||||
|
.middleware(() => {
|
||||||
|
calls.push('mw2');
|
||||||
|
return Promise.resolve(new Response('should-not-call'));
|
||||||
|
})
|
||||||
|
.handle(() => {
|
||||||
|
calls.push('handler');
|
||||||
|
return Promise.resolve(new Response('ok'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await kernel.handle(
|
||||||
|
new Request('http://localhost/stop', { method: 'GET' }),
|
||||||
|
);
|
||||||
|
assertEquals(res.status, 403);
|
||||||
|
assertEquals(await res.text(), 'blocked');
|
||||||
|
assertEquals(calls, ['mw1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: 404 for unmatched route', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
const res = await kernel.handle(new Request('http://localhost/nothing'));
|
||||||
|
assertEquals(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: skips route with wrong method', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
|
||||||
|
kernel.route({ method: 'POST', path: '/only-post' })
|
||||||
|
.handle(() => Promise.resolve(new Response('nope')));
|
||||||
|
|
||||||
|
const res = await kernel.handle(
|
||||||
|
new Request('http://localhost/only-post', { method: 'GET' }),
|
||||||
|
);
|
||||||
|
assertEquals(res.status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: throws on next() called twice', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
|
||||||
|
kernel.route({ method: 'GET', path: '/bad' })
|
||||||
|
.middleware(async (ctx, next) => {
|
||||||
|
await next();
|
||||||
|
await next(); // ❌
|
||||||
|
return new Response('should never reach');
|
||||||
|
})
|
||||||
|
.handle(() => Promise.resolve(new Response('OK')));
|
||||||
|
|
||||||
|
await assertRejects(
|
||||||
|
() => kernel.handle(new Request('http://localhost/bad')),
|
||||||
|
Error,
|
||||||
|
'next() called multiple times',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: handler throws → error propagates', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
|
||||||
|
kernel.route({ method: 'GET', path: '/throw' })
|
||||||
|
.handle(() => {
|
||||||
|
throw new Error('fail!');
|
||||||
|
});
|
||||||
|
|
||||||
|
await assertRejects(
|
||||||
|
() => kernel.handle(new Request('http://localhost/throw')),
|
||||||
|
Error,
|
||||||
|
'fail!',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async () => {
|
||||||
|
const kernel = new HttpKernel();
|
||||||
|
|
||||||
|
// Force-manual Registrierung mit `handler: undefined`
|
||||||
|
// Umgehen des Builders zur Simulation dieses Edge-Cases
|
||||||
|
kernel['routes'].push({
|
||||||
|
method: 'GET',
|
||||||
|
matcher: (url) => url.pathname === '/fail' ? { params: {} } : null,
|
||||||
|
middlewares: [],
|
||||||
|
// @ts-expect-error absichtlich ungültiger Handler
|
||||||
|
handler: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await kernel.handle(new Request('http://localhost/fail'));
|
||||||
|
assertEquals(res.status, 500);
|
||||||
|
assertEquals(await res.text(), 'Internal error');
|
||||||
|
});
|
111
src/__tests__/RouteBuilder.test.ts
Normal file
111
src/__tests__/RouteBuilder.test.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import {
|
||||||
|
assert,
|
||||||
|
assertEquals,
|
||||||
|
assertNotEquals,
|
||||||
|
assertThrows,
|
||||||
|
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
|
||||||
|
import {
|
||||||
|
IHandler,
|
||||||
|
IInternalRoute,
|
||||||
|
IMiddleware,
|
||||||
|
IRouteDefinition,
|
||||||
|
} from '../Interfaces/mod.ts';
|
||||||
|
import { RouteBuilder } from '../mod.ts';
|
||||||
|
|
||||||
|
// Dummy objects
|
||||||
|
const dummyHandler: IHandler = async () => new Response('ok');
|
||||||
|
const dummyMiddleware: IMiddleware = async (_, next) => await next();
|
||||||
|
const dummyDef: IRouteDefinition = { method: 'get', path: '/hello' };
|
||||||
|
const dummyMatcher = () => ({ params: {} });
|
||||||
|
|
||||||
|
Deno.test('middleware: single middleware is registered correctly', () => {
|
||||||
|
let registered: IInternalRoute | null = null as IInternalRoute | null;
|
||||||
|
|
||||||
|
const builder = new RouteBuilder((r) => registered = r, dummyDef)
|
||||||
|
.middleware(dummyMiddleware);
|
||||||
|
|
||||||
|
builder.handle(dummyHandler);
|
||||||
|
|
||||||
|
assert(registered);
|
||||||
|
assertEquals(registered?.middlewares.length, 1);
|
||||||
|
assertEquals(registered?.middlewares[0], dummyMiddleware);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('middleware: middleware is chained immutably', () => {
|
||||||
|
const builder1 = new RouteBuilder(() => {}, dummyDef);
|
||||||
|
const builder2 = builder1.middleware(dummyMiddleware);
|
||||||
|
|
||||||
|
assertNotEquals(builder1, builder2);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('middleware: preserves order of middleware', () => {
|
||||||
|
const mw1: IMiddleware = async (_, next) => await next();
|
||||||
|
const mw2: IMiddleware = async (_, next) => await next();
|
||||||
|
|
||||||
|
let result: IInternalRoute | null = null as IInternalRoute | null;
|
||||||
|
|
||||||
|
const builder = new RouteBuilder((r) => result = r, dummyDef)
|
||||||
|
.middleware(mw1)
|
||||||
|
.middleware(mw2);
|
||||||
|
|
||||||
|
builder.handle(dummyHandler);
|
||||||
|
|
||||||
|
assert(result);
|
||||||
|
assertEquals(result!.middlewares, [mw1, mw2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('handle: uppercases method', () => {
|
||||||
|
let result: IInternalRoute | null = null as IInternalRoute | null;
|
||||||
|
|
||||||
|
new RouteBuilder((r) => result = r, { method: 'post', path: '/x' })
|
||||||
|
.handle(dummyHandler);
|
||||||
|
|
||||||
|
assertEquals(result?.method, 'POST');
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('handle: works with no middleware', async () => {
|
||||||
|
let route: IInternalRoute | null = null as IInternalRoute | null;
|
||||||
|
|
||||||
|
const builder = new RouteBuilder((r) => route = r, dummyDef);
|
||||||
|
builder.handle(dummyHandler);
|
||||||
|
|
||||||
|
assert(route);
|
||||||
|
assertEquals(route?.middlewares.length, 0);
|
||||||
|
|
||||||
|
const request = new Request('http://localhost');
|
||||||
|
|
||||||
|
const res1 = await route?.handler({ req: request, params: {}, state: {} });
|
||||||
|
const res2 = await dummyHandler({ req: request, params: {}, state: {} });
|
||||||
|
|
||||||
|
assertEquals(res1?.status, res2?.status);
|
||||||
|
assertEquals(await res1?.text(), await res2?.text());
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('handle: uses custom matcher factory', () => {
|
||||||
|
let called = false;
|
||||||
|
|
||||||
|
const factory = (def: IRouteDefinition) => {
|
||||||
|
called = true;
|
||||||
|
return dummyMatcher;
|
||||||
|
};
|
||||||
|
|
||||||
|
let route: IInternalRoute | null = null as IInternalRoute | null;
|
||||||
|
|
||||||
|
new RouteBuilder((r) => route = r, dummyDef, [], factory).handle(
|
||||||
|
dummyHandler,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(called);
|
||||||
|
assert(route);
|
||||||
|
assertEquals(route!.matcher, dummyMatcher);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('handle: throws if matcher factory throws', () => {
|
||||||
|
const faultyFactory = () => {
|
||||||
|
throw new Error('matcher fail');
|
||||||
|
};
|
||||||
|
|
||||||
|
const builder = new RouteBuilder(() => {}, dummyDef, [], faultyFactory);
|
||||||
|
|
||||||
|
assertThrows(() => builder.handle(dummyHandler), Error, 'matcher fail');
|
||||||
|
});
|
54
src/__tests__/Utils.test.ts
Normal file
54
src/__tests__/Utils.test.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
assert,
|
||||||
|
assertEquals,
|
||||||
|
assertStrictEquals,
|
||||||
|
} from 'https://deno.land/std/assert/mod.ts';
|
||||||
|
import { IRouteDefinition } from '../Interfaces/mod.ts';
|
||||||
|
import { createRouteMatcher } from '../mod.ts';
|
||||||
|
|
||||||
|
// Dummy request
|
||||||
|
const dummyRequest = new Request('http://localhost');
|
||||||
|
|
||||||
|
Deno.test('createRouteMatcher: static route matches and extracts params', () => {
|
||||||
|
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
|
||||||
|
const matcher = createRouteMatcher(def);
|
||||||
|
|
||||||
|
const result = matcher(new URL('http://localhost/users/42'), dummyRequest);
|
||||||
|
|
||||||
|
assert(result);
|
||||||
|
assertEquals(result.params, { id: '42' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('createRouteMatcher: static route with multiple params', () => {
|
||||||
|
const def: IRouteDefinition = { method: 'GET', path: '/repo/:owner/:name' };
|
||||||
|
const matcher = createRouteMatcher(def);
|
||||||
|
|
||||||
|
const result = matcher(
|
||||||
|
new URL('http://localhost/repo/max/wiki'),
|
||||||
|
dummyRequest,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(result);
|
||||||
|
assertEquals(result.params, { owner: 'max', name: 'wiki' });
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('createRouteMatcher: static route does not match wrong path', () => {
|
||||||
|
const def: IRouteDefinition = { method: 'GET', path: '/users/:id' };
|
||||||
|
const matcher = createRouteMatcher(def);
|
||||||
|
|
||||||
|
const result = matcher(new URL('http://localhost/posts/42'), dummyRequest);
|
||||||
|
|
||||||
|
assertStrictEquals(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test('createRouteMatcher: uses custom matcher if provided', () => {
|
||||||
|
const def: IRouteDefinition = {
|
||||||
|
method: 'GET',
|
||||||
|
matcher: (url) => url.pathname === '/ping' ? { params: {} } : null,
|
||||||
|
};
|
||||||
|
const matcher = createRouteMatcher(def);
|
||||||
|
|
||||||
|
const result = matcher(new URL('http://localhost/ping'), dummyRequest);
|
||||||
|
assert(result);
|
||||||
|
assertEquals(result.params, {});
|
||||||
|
});
|
4
src/mod.ts
Normal file
4
src/mod.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// deno-coverage-ignore-file
|
||||||
|
export { HttpKernel } from './HttpKernel.ts';
|
||||||
|
export { RouteBuilder } from './RouteBuilder.ts';
|
||||||
|
export { createRouteMatcher } from './Utils.ts';
|
Reference in New Issue
Block a user