refactor(core): enhance HttpKernel pipeline and matcher system with full context and error handling

BREAKING CHANGE: `parseQuery` utility removed; `IRouteMatcher` now includes query parsing; `RouteBuilder.middleware` and `handle` are now strictly typed per builder instance.

- Add `isHandler` and `isMiddleware` runtime type guards for validation in `HttpKernel`.
- Introduce `createEmptyContext` for constructing default context objects.
- Support custom HTTP error handlers (`404`, `500`) via `IHttpKernelConfig.httpErrorHandlers`.
- Default error handlers return meaningful HTTP status text (e.g., "Not Found").
- Replace legacy `parseQuery` logic with integrated query extraction via `createRouteMatcher`.

- Strongly type `RouteBuilder.middleware()` and `.handle()` methods without generic overrides.
- Simplify `HttpKernel.handle()` and `executePipeline()` through precise control flow and validation.
- Remove deprecated `registerRoute.ts` and `HttpKernelConfig.ts` in favor of colocated type exports.

- Add tests for integrated query parsing in `createRouteMatcher`.
- Improve error handling tests: middleware/handler validation, double `next()` call, thrown exceptions.
- Replace `assertRejects` with plain response code checks (via updated error handling).

- Removed `parseQuery.ts` and all related tests — query parsing is now built into route matching.
- `IRouteMatcher` signature changed to return `{ params, query }` instead of only `params`.
- `HttpKernelConfig` now uses `DeepPartial` and includes `httpErrorHandlers`.
- `RouteBuilder`'s generics are simplified for better DX and improved type safety.

This refactor improves clarity, test coverage, and runtime safety of the request lifecycle while reducing boilerplate and eliminating duplicated query handling logic.

Signed-off-by: Max P. <Mail@MPassarello.de>
This commit is contained in:
2025-05-07 16:38:53 +02:00
parent 9059bdda62
commit b7410b44dd
33 changed files with 915 additions and 220 deletions

View File

@@ -1 +1,3 @@
// deno-coverage-ignore-file
export { InvalidHttpMethodError } from './InvalidHttpMethodError.ts';

View File

@@ -7,9 +7,17 @@ import {
IMiddleware,
IRouteBuilder,
IRouteDefinition,
isHandler,
isMiddleware,
} from './Interfaces/mod.ts';
import {
DeepPartial,
HTTP_404_NOT_FOUND,
HTTP_500_INTERNAL_SERVER_ERROR,
HttpStatusTextMap,
} from './Types/mod.ts';
import { RouteBuilder } from './RouteBuilder.ts';
import { parseQuery } from './Utils/mod.ts';
import { createEmptyContext, normalizeError } from './Utils/mod.ts';
/**
* The central HTTP kernel responsible for managing route definitions,
@@ -34,11 +42,25 @@ export class HttpKernel<TContext extends IContext = IContext>
* @param routeBuilderFactory - Optional factory for creating route builders. Defaults to using `RouteBuilder`.
*/
public constructor(
config?: Partial<IHttpKernelConfig<TContext>>,
config?: DeepPartial<IHttpKernelConfig<TContext>>,
) {
this.cfg = {
decorateResponse: (res) => res,
routeBuilderFactory: RouteBuilder,
httpErrorHandlers: {
[HTTP_404_NOT_FOUND]: () =>
new Response(HttpStatusTextMap[HTTP_404_NOT_FOUND], {
status: HTTP_404_NOT_FOUND,
}),
[HTTP_500_INTERNAL_SERVER_ERROR]: () =>
new Response(
HttpStatusTextMap[HTTP_500_INTERNAL_SERVER_ERROR],
{
status: HTTP_500_INTERNAL_SERVER_ERROR,
},
),
...(config?.httpErrorHandlers ?? {}),
},
...config,
} as IHttpKernelConfig<TContext>;
@@ -51,16 +73,16 @@ export class HttpKernel<TContext extends IContext = IContext>
*/
public route<_TContext extends IContext = TContext>(
definition: IRouteDefinition,
): IRouteBuilder {
): IRouteBuilder<_TContext> {
return new this.cfg.routeBuilderFactory(
this.registerRoute,
definition,
);
) as IRouteBuilder<_TContext>;
}
/**
* @inheritdoc
*/ public async handle<_TContext extends IContext = TContext>(
*/ public async handle(
request: Request,
): Promise<Response> {
const url = new URL(request.url);
@@ -70,21 +92,23 @@ export class HttpKernel<TContext extends IContext = IContext>
if (route.method !== method) continue;
const match = route.matcher(url, request);
if (match) {
const ctx: _TContext = {
const ctx: TContext = {
req: request,
params: match.params,
query: parseQuery(url.searchParams),
query: match.query,
state: {},
} as _TContext;
return await this.executePipeline<_TContext>(
} as TContext;
return await this.executePipeline(
ctx,
route.middlewares as unknown as IMiddleware<_TContext>[],
route.handler as unknown as IHandler<_TContext>,
route.middlewares,
route.handler,
);
}
}
return new Response('Not Found', { status: 404 });
return this.cfg.httpErrorHandlers[HTTP_404_NOT_FOUND](
createEmptyContext<TContext>(request),
);
}
/**
@@ -101,38 +125,71 @@ export class HttpKernel<TContext extends IContext = IContext>
}
/**
* Executes the middleware pipeline and final handler for a given request context.
* Executes the complete request pipeline: middleware chain, final handler, and optional response decoration.
*
* 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.
* Middleware functions are invoked sequentially in the order of registration. Each middleware
* receives a `next()` callback to advance to the next stage. If a middleware returns a `Response`
* directly, the pipeline short-circuits.
*
* The final response is passed through the `decorateResponse` function before being returned.
* After the final handler produces a response, it is passed through the configured response decorator,
* which may modify it (e.g., adding headers or logging metadata).
*
* @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.
* Internal error handling ensures:
* - That `next()` is not called multiple times.
* - That all middleware and handlers are properly typed.
* - That thrown exceptions are routed to the 500-error handler.
*
* @param ctx - The current request context, including request data and shared state.
* @param middleware - An ordered list of middleware functions to invoke.
* @param handler - The terminal request handler to produce the response.
* @returns The final decorated `Response` object.
*/
private async executePipeline<_TContext extends IContext = TContext>(
ctx: _TContext,
middleware: IMiddleware<_TContext>[],
handler: IHandler<_TContext>,
private async executePipeline(
ctx: TContext,
middleware: IMiddleware<TContext>[],
handler: IHandler<TContext>,
): 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<_TContext> | IHandler<_TContext> =
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<_TContext>)(ctx);
const handleInternalError = (ctx: TContext, err?: unknown) =>
this.cfg.httpErrorHandlers[HTTP_500_INTERNAL_SERVER_ERROR](
ctx,
normalizeError(err),
);
let lastIndex = -1;
const dispatch = async (currentIndex: number): Promise<Response> => {
// Prevent middleware from invoking next() multiple times
if (currentIndex <= lastIndex) {
throw new Error('Middleware called `next()` multiple times');
}
lastIndex = currentIndex;
const isWithinMiddleware = currentIndex < middleware.length;
const fn = isWithinMiddleware ? middleware[currentIndex] : handler;
if (isWithinMiddleware) {
if (!isMiddleware(fn)) {
throw new Error(
'Expected middleware function, but received invalid value',
);
}
return await fn(ctx, () => dispatch(currentIndex + 1));
}
if (!isHandler(fn)) {
throw new Error(
'Expected request handler, but received invalid value',
);
}
return await fn(ctx);
};
return this.cfg.decorateResponse(
await dispatch(0),
ctx as unknown as TContext,
);
try {
const response = await dispatch(0);
return this.cfg.decorateResponse(response, ctx);
} catch (e) {
return handleInternalError(ctx, e);
}
}
}

View File

@@ -21,3 +21,30 @@ export interface IHandler<TContext extends IContext = IContext> {
*/
(ctx: TContext): Promise<Response>;
}
/**
* Type guard to determine whether a given value is a valid `IHandler` function.
*
* This function checks whether the input is a function and whether it returns
* a `Promise<Response>` when called. Due to TypeScript's structural typing and
* the lack of runtime type information, only minimal runtime validation is possible.
*
* @param value - The value to test.
* @returns `true` if the value is a function that appears to conform to `IHandler`.
*
* @example
* ```ts
* const candidate = async (ctx: IContext) => new Response("ok");
* if (isHandler(candidate)) {
* // candidate is now typed as IHandler<IContext>
* }
* ```
*/
export function isHandler<TContext extends IContext = IContext>(
value: unknown,
): value is IHandler<TContext> {
return (
typeof value === 'function' &&
value.length === 1 // ctx
);
}

View File

@@ -0,0 +1,40 @@
import { IContext } from '../Interfaces/mod.ts';
import { HttpErrorHandler, validHttpErrorCodes } from '../Types/mod.ts';
/**
* A mapping of HTTP status codes to their corresponding error handlers.
*
* This interface defines required handlers for common critical status codes (404 and 500)
* and allows optional handlers for all other known error codes defined in `validHttpErrorCodes`.
*
* This hybrid approach ensures predictable handling for key failure cases,
* while remaining flexible for less common codes.
*
* @template TContext - The context type used in all error handlers.
*
* @example
* ```ts
* const errorHandlers: IHttpErrorHandlers = {
* 404: (ctx) => new Response("Not Found", { status: 404 }),
* 500: (ctx, err) => {
* console.error(err);
* return new Response("Internal Server Error", { status: 500 });
* },
* 429: (ctx) => new Response("Too Many Requests", { status: 429 }),
* };
* ```
*/
export interface IHttpErrorHandlers<TContext extends IContext = IContext>
extends
Partial<
Record<
Exclude<typeof validHttpErrorCodes[number], 404 | 500>,
HttpErrorHandler<TContext>
>
> {
/** Required error handler for HTTP 404 (Not Found). */
404: HttpErrorHandler<TContext>;
/** Required error handler for HTTP 500 (Internal Server Error). */
500: HttpErrorHandler<TContext>;
}

View File

@@ -44,7 +44,7 @@ export interface IHttpKernel<TContext extends IContext = IContext> {
* @param request - The incoming HTTP request to dispatch.
* @returns A promise resolving to the final HTTP response.
*/
handle<_TContext extends IContext = TContext>(
handle(
request: Request,
): Promise<Response>;
}

View File

@@ -1,8 +1,10 @@
import { ResponseDecorator } from '../Types/mod.ts';
import { IContext } from './IContext.ts';
import { IHttpErrorHandlers } from './IHttpErrorHandlers.ts';
import { IRouteBuilderFactory } from './IRouteBuilder.ts';
export interface IHttpKernelConfig<TContext extends IContext = IContext> {
decorateResponse: ResponseDecorator<TContext>;
routeBuilderFactory: IRouteBuilderFactory;
httpErrorHandlers: IHttpErrorHandlers<TContext>;
}

View File

@@ -1,7 +1,7 @@
import { HttpMethod } from '../Types/mod.ts';
import { IHandler } from './IHandler.ts';
import { IMiddleware } from './IMiddleware.ts';
import { IContext } from './mod.ts';
import { IContext, IRouteMatcher } from './mod.ts';
/**
* Represents an internally registered route within the HttpKernel.
@@ -27,10 +27,7 @@ export interface IInternalRoute<TContext extends IContext = IContext> {
* @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> };
matcher: IRouteMatcher;
/**
* An ordered list of middleware functions to be executed before the handler.

View File

@@ -23,3 +23,21 @@ export interface IMiddleware<TContext extends IContext = IContext> {
*/
(ctx: TContext, next: () => Promise<Response>): Promise<Response>;
}
/**
* Type guard to verify whether a given value is a valid `IMiddleware` function.
*
* This guard checks whether the input is a function that accepts exactly two arguments.
* Note: This is a structural check and cannot fully guarantee the semantics of a middleware.
*
* @param value - The value to test.
* @returns `true` if the value is structurally a valid middleware function.
*/
export function isMiddleware<TContext extends IContext = IContext>(
value: unknown,
): value is IMiddleware<TContext> {
return (
typeof value === 'function' &&
value.length === 2 // ctx, next
);
}

View File

@@ -24,9 +24,9 @@ export interface IRouteBuilder<TContext extends IContext = IContext> {
* @param mw - A middleware function.
* @returns The route builder for further chaining.
*/
middleware<_TContext extends IContext = TContext>(
mw: IMiddleware<_TContext>,
): IRouteBuilder<_TContext>;
middleware(
mw: IMiddleware<TContext>,
): IRouteBuilder<TContext>;
/**
* Sets the final request handler for the route.
@@ -34,7 +34,7 @@ export interface IRouteBuilder<TContext extends IContext = IContext> {
*
* @param handler - The function to execute when this route is matched.
*/
handle<_TContext extends IContext = TContext>(
handler: IHandler<_TContext>,
handle(
handler: IHandler<TContext>,
): void;
}

View File

@@ -1,4 +1,4 @@
import { HttpMethod } from '../Types/mod.ts';
import { HttpMethod, isHttpMethod } from '../Types/mod.ts';
import { IRouteMatcher } from './IRouteMatcher.ts';
/**
@@ -45,3 +45,47 @@ export interface IDynamicRouteDefinition {
* or a dynamic route with a custom matcher function for advanced matching logic.
*/
export type IRouteDefinition = IStaticRouteDefinition | IDynamicRouteDefinition;
/**
* Type guard to check whether a route definition is a valid static route definition.
*
* Ensures that the object:
* - has a `method` property of type `HttpMethod`
* - has a `path` property of type `string`
* - does NOT have a `matcher` function (to avoid ambiguous mixed types)
*/
export function isStaticRouteDefinition(
def: IRouteDefinition,
): def is IStaticRouteDefinition {
return (
def &&
typeof def === 'object' &&
'method' in def &&
isHttpMethod(def.method) &&
'path' in def &&
typeof (def as { path?: unknown }).path === 'string' &&
!('matcher' in def)
);
}
/**
* Type guard to check whether a route definition is a valid dynamic route definition.
*
* Ensures that the object:
* - has a `method` property of type `HttpMethod`
* - has a `matcher` property of type `function`
* - does NOT have a `path` property (to avoid ambiguous mixed types)
*/
export function isDynamicRouteDefinition(
def: IRouteDefinition,
): def is IDynamicRouteDefinition {
return (
def &&
typeof def === 'object' &&
'method' in def &&
isHttpMethod(def.method) &&
'matcher' in def &&
typeof (def as { matcher?: unknown }).matcher === 'function' &&
!('path' in def)
);
}

View File

@@ -0,0 +1,6 @@
import { Params, Query } from '../Types/mod.ts';
export interface IRouteMatch {
params?: Params;
query?: Query;
}

View File

@@ -1,4 +1,6 @@
import { Params } from '../Types/mod.ts';
import { IRouteDefinition } from './IRouteDefinition.ts';
import { IRouteMatch } from './IRouteMatch.ts';
/**
* Defines a route matcher function that evaluates whether a route applies to a given request.
@@ -14,7 +16,7 @@ export interface IRouteMatcher {
* @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> };
(url: URL, req: Request): null | IRouteMatch;
}
/**

View File

@@ -0,0 +1,43 @@
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
import {
IRouteDefinition,
isDynamicRouteDefinition,
isStaticRouteDefinition,
} from '../IRouteDefinition.ts';
Deno.test('isStaticRouteDefinition returns true for static route', () => {
const staticDef: IRouteDefinition = {
method: 'GET',
path: '/users/:id',
};
assertEquals(isStaticRouteDefinition(staticDef), true);
assertEquals(isDynamicRouteDefinition(staticDef), false);
});
Deno.test('isDynamicRouteDefinition returns true for dynamic route', () => {
const dynamicDef: IRouteDefinition = {
method: 'POST',
matcher: (_url, _req) => ({ params: {} }),
};
assertEquals(isDynamicRouteDefinition(dynamicDef), true);
assertEquals(isStaticRouteDefinition(dynamicDef), false);
});
Deno.test('isStaticRouteDefinition returns false for invalid object', () => {
const invalidDef = {
method: 'GET',
} as unknown as IRouteDefinition;
assertEquals(isStaticRouteDefinition(invalidDef), false);
});
Deno.test('isDynamicRouteDefinition returns false for object with no matcher', () => {
const def = {
method: 'DELETE',
path: '/something',
};
assertEquals(isDynamicRouteDefinition(def as IRouteDefinition), false);
});

View File

@@ -1,15 +1,23 @@
// deno-coverage-ignore-file
export type { IHttpKernelConfig } from './HttpKernelConfig.ts';
export type { IContext } from './IContext.ts';
export { isHandler } from './IHandler.ts';
export type { IHandler } from './IHandler.ts';
export type { IHttpErrorHandlers } from './IHttpErrorHandlers.ts';
export type { IHttpKernel } from './IHttpKernel.ts';
export type { IHttpKernelConfig } from './IHttpKernelConfig.ts';
export type { IInternalRoute } from './IInternalRoute.ts';
export { isMiddleware } from './IMiddleware.ts';
export type { IMiddleware } from './IMiddleware.ts';
export type { IRouteBuilder, IRouteBuilderFactory } from './IRouteBuilder.ts';
export {
isDynamicRouteDefinition,
isStaticRouteDefinition,
} from './IRouteDefinition.ts';
export type {
IDynamicRouteDefinition,
IRouteDefinition,
IStaticRouteDefinition,
} from './IRouteDefinition.ts';
export type { IRouteMatch } from './IRouteMatch.ts';
export type { IRouteMatcher, IRouteMatcherFactory } from './IRouteMatcher.ts';

View File

@@ -41,13 +41,13 @@ export class RouteBuilder<TContext extends IContext = IContext>
* @param mw - A middleware function to be executed before the handler.
* @returns A new `RouteBuilder` instance for continued chaining.
*/
middleware<_TContext extends IContext = TContext>(
mw: IMiddleware<_TContext>,
): IRouteBuilder<_TContext> {
return new RouteBuilder<_TContext>(
this.registerRoute as unknown as RegisterRoute<_TContext>,
middleware(
mw: IMiddleware<TContext>,
): IRouteBuilder<TContext> {
return new RouteBuilder<TContext>(
this.registerRoute,
this.def,
[...this.mws as unknown as IMiddleware<_TContext>[], mw],
[...this.mws, mw],
);
}
@@ -59,15 +59,15 @@ export class RouteBuilder<TContext extends IContext = IContext>
*
* @param handler - The final request handler for this route.
*/
handle<_TContext extends IContext = TContext>(
handler: IHandler<_TContext>,
handle(
handler: IHandler<TContext>,
): void {
const matcher = this.matcherFactory(this.def);
this.registerRoute({
method: this.def.method,
matcher,
middlewares: this.mws,
handler: handler as unknown as IHandler<TContext>,
handler: handler,
});
}
}

4
src/Types/DeepPartial.ts Normal file
View File

@@ -0,0 +1,4 @@
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]>
: T[P];
};

View File

@@ -0,0 +1,28 @@
import { IContext } from '../Interfaces/mod.ts';
/**
* Defines a handler function for errors that occur during the execution
* of middleware or route handlers within the HTTP kernel.
*
* This function receives both the request context and the thrown error,
* and is responsible for producing an appropriate HTTP `Response`.
*
* Typical use cases include:
* - Mapping known error types to specific HTTP status codes.
* - Generating structured error responses (e.g. JSON error payloads).
* - Logging errors centrally with request metadata.
*
* The handler may return the response synchronously or asynchronously.
*
* @template TContext - The specific request context type, allowing typed access to route parameters,
* query parameters, and per-request state when formatting error responses.
*
* @param context - The active request context at the time the error occurred.
* @param error - The exception or error that was thrown during request processing.
*
* @returns A `Response` object or a `Promise` resolving to one, to be sent to the client.
*/
export type HttpErrorHandler<TContext extends IContext = IContext> = (
context?: Partial<TContext>,
error?: Error,
) => Promise<Response> | Response;

189
src/Types/HttpStatusCode.ts Normal file
View File

@@ -0,0 +1,189 @@
// Informational responses
/** Indicates that the request was received and the client can continue. */
export const HTTP_100_CONTINUE = 100;
/** The server is switching protocols as requested by the client. */
export const HTTP_101_SWITCHING_PROTOCOLS = 101;
/** The server has received and is processing the request, but no response is available yet. */
export const HTTP_102_PROCESSING = 102;
// Successful responses
/** The request has succeeded. */
export const HTTP_200_OK = 200;
/** The request has succeeded and a new resource has been created as a result. */
export const HTTP_201_CREATED = 201;
/** The request has been accepted for processing, but the processing is not complete. */
export const HTTP_202_ACCEPTED = 202;
/** The server has successfully fulfilled the request and there is no content to send. */
export const HTTP_204_NO_CONTENT = 204;
// Redirection messages
/** The resource has been moved permanently to a new URI. */
export const HTTP_301_MOVED_PERMANENTLY = 301;
/** The resource resides temporarily under a different URI. */
export const HTTP_302_FOUND = 302;
/** Indicates that the resource has not been modified since the last request. */
export const HTTP_304_NOT_MODIFIED = 304;
// Client error responses
/** The server could not understand the request due to invalid syntax. */
export const HTTP_400_BAD_REQUEST = 400;
/** The request requires user authentication. */
export const HTTP_401_UNAUTHORIZED = 401;
/** The server understood the request but refuses to authorize it. */
export const HTTP_403_FORBIDDEN = 403;
/** The server cannot find the requested resource. */
export const HTTP_404_NOT_FOUND = 404;
/** The request method is known by the server but is not supported by the target resource. */
export const HTTP_405_METHOD_NOT_ALLOWED = 405;
/** The request could not be completed due to a conflict with the current state of the resource. */
export const HTTP_409_CONFLICT = 409;
/** The server understands the content type but was unable to process the contained instructions. */
export const HTTP_422_UNPROCESSABLE_ENTITY = 422;
/** The user has sent too many requests in a given amount of time. */
export const HTTP_429_TOO_MANY_REQUESTS = 429;
// Server error responses
/** The server encountered an unexpected condition that prevented it from fulfilling the request. */
export const HTTP_500_INTERNAL_SERVER_ERROR = 500;
/** The server does not support the functionality required to fulfill the request. */
export const HTTP_501_NOT_IMPLEMENTED = 501;
/** The server, while acting as a gateway or proxy, received an invalid response from the upstream server. */
export const HTTP_502_BAD_GATEWAY = 502;
/** The server is not ready to handle the request, often due to maintenance or overload. */
export const HTTP_503_SERVICE_UNAVAILABLE = 503;
/** The server is acting as a gateway and cannot get a response in time. */
export const HTTP_504_GATEWAY_TIMEOUT = 504;
/**
* A constant list of supported HTTP status codes used by this application.
*
* These constants are grouped by category and used to construct the union type `HttpStatusCode`.
*/
export const validHttpStatusCodes = [
// Informational
HTTP_100_CONTINUE,
HTTP_101_SWITCHING_PROTOCOLS,
HTTP_102_PROCESSING,
// Successful
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_202_ACCEPTED,
HTTP_204_NO_CONTENT,
// Redirection
HTTP_301_MOVED_PERMANENTLY,
HTTP_302_FOUND,
HTTP_304_NOT_MODIFIED,
// Client Errors
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_409_CONFLICT,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_429_TOO_MANY_REQUESTS,
// Server Errors
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_501_NOT_IMPLEMENTED,
HTTP_502_BAD_GATEWAY,
HTTP_503_SERVICE_UNAVAILABLE,
HTTP_504_GATEWAY_TIMEOUT,
] as const;
/**
* A constant list of HTTP error codes that are commonly used in the application.
*/
export const validHttpErrorCodes = [
// Client Errors
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_409_CONFLICT,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_429_TOO_MANY_REQUESTS,
// Server Errors
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_501_NOT_IMPLEMENTED,
HTTP_502_BAD_GATEWAY,
HTTP_503_SERVICE_UNAVAILABLE,
HTTP_504_GATEWAY_TIMEOUT,
] as const;
/**
* Maps each supported HTTP status code to its standard status message.
*
* Useful for logging, diagnostics, or building custom error responses.
*/
export const HttpStatusTextMap: Record<
typeof validHttpStatusCodes[number],
string
> = {
[HTTP_100_CONTINUE]: 'Continue',
[HTTP_101_SWITCHING_PROTOCOLS]: 'Switching Protocols',
[HTTP_102_PROCESSING]: 'Processing',
[HTTP_200_OK]: 'OK',
[HTTP_201_CREATED]: 'Created',
[HTTP_202_ACCEPTED]: 'Accepted',
[HTTP_204_NO_CONTENT]: 'No Content',
[HTTP_301_MOVED_PERMANENTLY]: 'Moved Permanently',
[HTTP_302_FOUND]: 'Found',
[HTTP_304_NOT_MODIFIED]: 'Not Modified',
[HTTP_400_BAD_REQUEST]: 'Bad Request',
[HTTP_401_UNAUTHORIZED]: 'Unauthorized',
[HTTP_403_FORBIDDEN]: 'Forbidden',
[HTTP_404_NOT_FOUND]: 'Not Found',
[HTTP_405_METHOD_NOT_ALLOWED]: 'Method Not Allowed',
[HTTP_409_CONFLICT]: 'Conflict',
[HTTP_422_UNPROCESSABLE_ENTITY]: 'Unprocessable Entity',
[HTTP_429_TOO_MANY_REQUESTS]: 'Too Many Requests',
[HTTP_500_INTERNAL_SERVER_ERROR]: 'Internal Server Error',
[HTTP_501_NOT_IMPLEMENTED]: 'Not Implemented',
[HTTP_502_BAD_GATEWAY]: 'Bad Gateway',
[HTTP_503_SERVICE_UNAVAILABLE]: 'Service Unavailable',
[HTTP_504_GATEWAY_TIMEOUT]: 'Gateway Timeout',
};
/**
* A union type representing commonly used HTTP status codes.
*
* This type ensures consistency between runtime and type-level status code handling.
*
* Example:
* ```ts
* const status: HttpStatusCode = 404; // ✅ valid
* const status: HttpStatusCode = 418; // ❌ Type error (unless added to list)
* ```
*/
export type HttpStatusCode = typeof validHttpStatusCodes[number];
/**
* Type guard to check whether a given value is a valid HTTP status code.
*
* This is useful for validating numeric values received from external input,
* ensuring they conform to known HTTP semantics.
*
* Example:
* ```ts
* if (isHttpStatusCode(value)) {
* // value is now typed as HttpStatusCode
* }
* ```
*
* @param value - The numeric value to check.
* @returns `true` if the value is a recognized HTTP status code, otherwise `false`.
*/
export function isHttpStatusCode(value: unknown): value is HttpStatusCode {
return typeof value === 'number' &&
validHttpStatusCodes.includes(value as HttpStatusCode);
}

View File

@@ -7,4 +7,4 @@
* All values are strings and should be considered read-only, as they are
* extracted by the router and should not be modified by application code.
*/
export type Params = Record<string, string>;
export type Params = Record<string, string | undefined>;

View File

@@ -0,0 +1,40 @@
import { assertEquals } from 'https://deno.land/std/assert/mod.ts';
import { isHttpMethod, validHttpMethods } from '../HttpMethod.ts';
Deno.test('isHttpMethod: returns true for all valid methods', () => {
for (const method of validHttpMethods) {
const result = isHttpMethod(method);
assertEquals(result, true, `Expected "${method}" to be valid`);
}
});
Deno.test('isHttpMethod: returns false for lowercase or unknown strings', () => {
const invalid = [
'get',
'post',
'FETCH',
'TRACE',
'CONNECT',
'INVALID',
'',
' ',
];
for (const method of invalid) {
const result = isHttpMethod(method);
assertEquals(result, false, `Expected "${method}" to be invalid`);
}
});
Deno.test('isHttpMethod: returns false for non-string inputs', () => {
const invalidInputs = [null, undefined, 123, {}, [], true, Symbol('GET')];
for (const input of invalidInputs) {
const result = isHttpMethod(input);
assertEquals(
result,
false,
`Expected non-string input to be invalid: ${String(input)}`,
);
}
});

View File

@@ -0,0 +1,35 @@
// src/Types/__tests__/HttpStatusCode.test.ts
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
import { isHttpStatusCode, validHttpStatusCodes } from '../HttpStatusCode.ts';
Deno.test('isHttpStatusCode: returns true for all valid status codes', () => {
for (const code of validHttpStatusCodes) {
assertEquals(
isHttpStatusCode(code),
true,
`Expected ${code} to be valid`,
);
}
});
Deno.test('isHttpStatusCode: returns false for invalid status codes', () => {
const invalidInputs = [99, 600, 1234, -1, 0, 999];
for (const val of invalidInputs) {
assertEquals(
isHttpStatusCode(val),
false,
`Expected ${val} to be invalid`,
);
}
});
Deno.test('isHttpStatusCode: returns false for non-numeric values', () => {
const invalid = ['200', null, undefined, {}, [], true];
for (const val of invalid) {
assertEquals(
isHttpStatusCode(val),
false,
`Expected ${val} to be invalid`,
);
}
});

View File

@@ -1,9 +1,41 @@
// deno-coverage-ignore-file
export type { DeepPartial } from './DeepPartial.ts';
export type { HttpErrorHandler } from './HttpErrorHandler.ts';
export { isHttpMethod, validHttpMethods } from './HttpMethod.ts';
export type { HttpMethod } from './HttpMethod.ts';
export {
HTTP_100_CONTINUE,
HTTP_101_SWITCHING_PROTOCOLS,
HTTP_102_PROCESSING,
HTTP_200_OK,
HTTP_201_CREATED,
HTTP_202_ACCEPTED,
HTTP_204_NO_CONTENT,
HTTP_301_MOVED_PERMANENTLY,
HTTP_302_FOUND,
HTTP_304_NOT_MODIFIED,
HTTP_400_BAD_REQUEST,
HTTP_401_UNAUTHORIZED,
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
HTTP_405_METHOD_NOT_ALLOWED,
HTTP_409_CONFLICT,
HTTP_422_UNPROCESSABLE_ENTITY,
HTTP_429_TOO_MANY_REQUESTS,
HTTP_500_INTERNAL_SERVER_ERROR,
HTTP_501_NOT_IMPLEMENTED,
HTTP_502_BAD_GATEWAY,
HTTP_503_SERVICE_UNAVAILABLE,
HTTP_504_GATEWAY_TIMEOUT,
HttpStatusTextMap,
isHttpStatusCode,
validHttpErrorCodes,
validHttpStatusCodes,
} from './HttpStatusCode.ts';
export type { HttpStatusCode } from './HttpStatusCode.ts';
export type { Params } from './Params.ts';
export type { Query } from './Query.ts';
export type { RegisterRoute } from './RegisterRoute.ts';
export type { ResponseDecorator } from './ResponseDecorator.ts';
export type { State } from './State.ts';
export type { RegisterRoute } from './registerRoute.ts';

View File

@@ -0,0 +1,28 @@
import { assertEquals } from 'https://deno.land/std/assert/mod.ts';
import { createEmptyContext } from '../createEmptyContext.ts';
import { IContext } from '../../Interfaces/mod.ts';
Deno.test('createEmptyContext: returns default-initialized context', () => {
const request = new Request('http://localhost');
const ctx = createEmptyContext(request);
assertEquals(ctx.req, request);
assertEquals(ctx.params, {});
assertEquals(ctx.query, {});
assertEquals(ctx.state, {});
});
Deno.test('createEmptyContext: preserves generic type compatibility', () => {
interface MyContext
extends
IContext<{ userId: string }, { id: string }, { verbose: string }> {}
const req = new Request('http://localhost');
const ctx = createEmptyContext<MyContext>(req);
// All properties exist and are empty
assertEquals(ctx.params, {} as MyContext['params']);
assertEquals(ctx.query, {} as MyContext['query']);
assertEquals(ctx.state, {} as MyContext['state']);
assertEquals(ctx.req, req);
});

View File

@@ -52,3 +52,67 @@ Deno.test('createRouteMatcher: uses custom matcher if provided', () => {
assert(result);
assertEquals(result.params, {});
});
Deno.test('createRouteMatcher: extracts single query param', () => {
const def: IRouteDefinition = { method: 'GET', path: '/search' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/search?q=deno');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, {}); // no path params
assertEquals(result.query, { q: 'deno' }); // single key → string
});
Deno.test('createRouteMatcher: duplicate query keys become array', () => {
const def: IRouteDefinition = { method: 'GET', path: '/tags' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/tags?tag=js&tag=ts&tag=deno');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, {});
assertEquals(result.query, { tag: ['js', 'ts', 'deno'] }); // multi → string[]
});
Deno.test('createRouteMatcher: mix of single and duplicate keys', () => {
const def: IRouteDefinition = { method: 'GET', path: '/filter/:type' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/filter/repo?lang=ts&lang=js&page=2');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, { type: 'repo' });
assertEquals(result.query, {
lang: ['ts', 'js'], // duplicated
page: '2', // single
});
});
Deno.test('createRouteMatcher: no query parameters returns empty object', () => {
const def: IRouteDefinition = { method: 'GET', path: '/info' };
const matcher = createRouteMatcher(def);
const url = new URL('http://localhost/info');
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.params, {});
assertEquals(result.query, {}); // empty
});
Deno.test('createRouteMatcher: retains array order of duplicate keys', () => {
const def: IRouteDefinition = { method: 'GET', path: '/order' };
const matcher = createRouteMatcher(def);
const url = new URL(
'http://localhost/order?item=first&item=second&item=third',
);
const result = matcher(url, dummyRequest);
assert(result);
assertEquals(result.query?.item, ['first', 'second', 'third']);
});

View File

@@ -0,0 +1,35 @@
import {
assertEquals,
assertInstanceOf,
} from 'https://deno.land/std/assert/mod.ts';
import { normalizeError } from '../normalizeError.ts';
Deno.test('normalizeError: preserves Error instances', () => {
const original = new Error('original');
const result = normalizeError(original);
assertInstanceOf(result, Error);
assertEquals(result, original);
});
Deno.test('normalizeError: converts string to Error', () => {
const result = normalizeError('something went wrong');
assertInstanceOf(result, Error);
assertEquals(result.message, 'something went wrong');
});
Deno.test('normalizeError: converts number to Error', () => {
const result = normalizeError(404);
assertInstanceOf(result, Error);
assertEquals(result.message, '404');
});
Deno.test('normalizeError: converts plain object to Error', () => {
const input = { error: true, msg: 'Invalid' };
const result = normalizeError(input);
assertInstanceOf(result, Error);
assertEquals(result.message, JSON.stringify(input));
});

View File

@@ -1,49 +0,0 @@
import { assertEquals } from 'https://deno.land/std/assert/mod.ts';
import { parseQuery } from '../parseQuery.ts';
Deno.test('parseQuery: single-value parameters are parsed as strings', () => {
const url = new URL('http://localhost?foo=bar&limit=10');
const result = parseQuery(url.searchParams);
assertEquals(result, {
foo: 'bar',
limit: '10',
});
});
Deno.test('parseQuery: multi-value parameters are parsed as string arrays', () => {
const url = new URL('http://localhost?tag=ts&tag=deno&tag=web');
const result = parseQuery(url.searchParams);
assertEquals(result, {
tag: ['ts', 'deno', 'web'],
});
});
Deno.test('parseQuery: mixed single and multi-value parameters', () => {
const url = new URL(
'http://localhost?sort=asc&filter=active&filter=pending',
);
const result = parseQuery(url.searchParams);
assertEquals(result, {
sort: 'asc',
filter: ['active', 'pending'],
});
});
Deno.test('parseQuery: empty query string returns empty object', () => {
const url = new URL('http://localhost');
const result = parseQuery(url.searchParams);
assertEquals(result, {});
});
Deno.test('parseQuery: repeated single-value keys are grouped', () => {
const url = new URL('http://localhost?a=1&a=2&a=3');
const result = parseQuery(url.searchParams);
assertEquals(result, {
a: ['1', '2', '3'],
});
});

View File

@@ -0,0 +1,30 @@
import { IContext } from '../Interfaces/mod.ts';
import { Params, Query, State } from '../Types/mod.ts';
/**
* Creates an empty request context suitable for fallback handlers (e.g., 404 or 500 errors).
*
* This function is primarily intended for cases where no route matched, but a context-compatible
* object is still needed to invoke a generic error handler. All context fields are initialized
* to their default empty values (`{}` for params, query, and state).
*
* @template TContext - The expected context type, typically extending `IContext`.
* @param req - The original HTTP request object from `Deno.serve()`.
* @returns A minimal context object compatible with `TContext`.
*
* @example
* ```ts
* const ctx = createEmptyContext<MyContext>(request);
* return httpErrorHandlers[404](ctx);
* ```
*/
export function createEmptyContext<TContext extends IContext = IContext>(
req: Request,
): TContext {
return {
req,
params: {} as Params,
query: {} as Query,
state: {} as State,
} as TContext;
}

View File

@@ -1,61 +1,52 @@
import { IRouteDefinition, IRouteMatcher } from '../Interfaces/mod.ts';
// createRouteMatcher.ts
import {
IRouteDefinition,
IRouteMatch,
IRouteMatcher,
isDynamicRouteDefinition,
} from '../Interfaces/mod.ts';
import { Params, Query } from '../Types/mod.ts';
/**
* Creates a matcher function from a given route definition.
* Transforms a route definition into a matcher using Deno's URLPattern API.
*
* 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" } }
* ```
* @param def - Static path pattern or custom matcher.
* @returns IRouteMatcher that returns `{ params, query }` or `null`.
*/
export function createRouteMatcher(
def: IRouteDefinition,
): IRouteMatcher {
if ('matcher' in def) {
// 1. Allow users to provide their own matcher
if (isDynamicRouteDefinition(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 };
};
}
// 2. Build URLPattern; supports :id, *wildcards, regex groups, etc.
const pattern = new URLPattern({ pathname: def.path });
// 3. The actual matcher closure
return (url: URL): IRouteMatch | null => {
const result = pattern.exec(url);
// 3a. Path did not match
if (!result) return null;
// 3b. Extract route params
const params: Params = {};
for (const [key, value] of Object.entries(result.pathname.groups)) {
params[key] = value;
}
// 3c. Extract query parameters – keep duplicates as arrays
const query: Query = {};
for (const key of url.searchParams.keys()) {
const values = url.searchParams.getAll(key); // → string[]
query[key] = values.length === 1
? values[0] // single → "foo"
: values; // multi → ["foo","bar"]
}
return { params, query };
};
}

View File

@@ -1,4 +1,5 @@
// deno-coverage-ignore-file
export { createEmptyContext } from './createEmptyContext.ts';
export { createRouteMatcher } from './createRouteMatcher.ts';
export { parseQuery } from './parseQuery.ts';
export { normalizeError } from './normalizeError.ts';

View File

@@ -0,0 +1,32 @@
/**
* Normalizes any thrown value to a proper `Error` instance.
*
* This is useful when handling unknown thrown values that may be:
* - strings (e.g. `throw "oops"`)
* - numbers (e.g. `throw 404`)
* - objects that are not instances of `Error`
*
* Ensures that downstream error handling logic always receives a consistent `Error` object.
*
* @param unknownError - Any value that might have been thrown.
* @returns A valid `Error` instance wrapping the original input.
*
* @example
* ```ts
* try {
* throw "something went wrong";
* } catch (e) {
* const err = normalizeError(e);
* console.error(err.message); // "something went wrong"
* }
* ```
*/
export function normalizeError(unknownError: unknown): Error {
return unknownError instanceof Error
? unknownError
: new Error(
typeof unknownError === 'string'
? unknownError
: JSON.stringify(unknownError),
);
}

View File

@@ -1,30 +0,0 @@
import { Query } from '../Types/Query.ts';
/**
* Parses a `URLSearchParams` object into an `IQuery` structure
* that preserves both single and multi-value semantics.
*
* For each query parameter key, this function checks how often the key appears:
* - If the key occurs once, the value is stored as a string.
* - If the key occurs multiple times, the values are stored as a string array.
*
* This ensures compatibility with the `IQuery` type definition,
* which allows both `string` and `string[]` as value types.
*
* Example:
* - ?tag=deno&tag=ts → { tag: ["deno", "ts"] }
* - ?page=2 → { page: "2" }
*
* @param searchParams - The `URLSearchParams` instance from a parsed URL.
* @returns An object conforming to `IQuery`, with normalized parameter values.
*/
export function parseQuery(searchParams: URLSearchParams): Query {
const query: Query = {};
for (const key of new Set(searchParams.keys())) {
const values = searchParams.getAll(key);
query[key] = values.length > 1 ? values : values[0];
}
return query;
}

View File

@@ -1,7 +1,4 @@
import {
assertEquals,
assertRejects,
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
import { HttpKernel } from '../HttpKernel.ts';
import { IRouteDefinition } from '../Interfaces/mod.ts';
@@ -11,7 +8,7 @@ Deno.test('HttpKernel: matches static route and executes handler', async () => {
const def: IRouteDefinition = { method: 'GET', path: '/hello' };
let called = false;
kernel.route(def).handle(() => {
kernel.route(def).handle((_ctx) => {
called = true;
return Promise.resolve(new Response('OK', { status: 200 }));
});
@@ -31,7 +28,7 @@ Deno.test('HttpKernel: supports dynamic matcher', async () => {
matcher: (url) => url.pathname === '/dyn' ? { params: {} } : null,
};
kernel.route(def).handle(() =>
kernel.route(def).handle((_ctx) =>
Promise.resolve(new Response('Dyn', { status: 200 }))
);
@@ -45,15 +42,15 @@ Deno.test('HttpKernel: calls middleware in order and passes to handler', async (
const calls: string[] = [];
kernel.route({ method: 'GET', path: '/test' })
.middleware(async (ctx, next) => {
.middleware(async (_ctx, next) => {
calls.push('mw1');
return await next();
})
.middleware(async (ctx, next) => {
.middleware(async (_ctx, next) => {
calls.push('mw2');
return await next();
})
.handle(() => {
.handle((_ctx) => {
calls.push('handler');
return Promise.resolve(new Response('done'));
});
@@ -70,15 +67,15 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
const calls: string[] = [];
kernel.route({ method: 'GET', path: '/stop' })
.middleware(() => {
.middleware((_ctx, _next) => {
calls.push('mw1');
return Promise.resolve(new Response('blocked', { status: 403 }));
})
.middleware(() => {
.middleware((_ctx, _next) => {
calls.push('mw2');
return Promise.resolve(new Response('should-not-call'));
})
.handle(() => {
.handle((_ctx) => {
calls.push('handler');
return Promise.resolve(new Response('ok'));
});
@@ -91,6 +88,32 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
assertEquals(calls, ['mw1']);
});
Deno.test('HttpKernel: invalid middleware or handler signature triggers 500', async () => {
const kernel = new HttpKernel();
// Middleware with wrong signature (missing ctx, next)
kernel.route({ method: 'GET', path: '/bad-mw' })
// @ts-expect-error invalid middleware
.middleware(() => new Response('invalid'))
.handle((_ctx) => Promise.resolve(new Response('ok')));
const res1 = await kernel.handle(new Request('http://localhost/bad-mw'));
assertEquals(res1.status, 500);
assertEquals(await res1.text(), 'Internal Server Error');
// Handler with wrong signature (no ctx)
kernel.route({ method: 'GET', path: '/bad-handler' })
.middleware(async (_ctx, next) => await next())
// @ts-expect-error invalid handler
.handle(() => new Response('invalid'));
const res2 = await kernel.handle(
new Request('http://localhost/bad-handler'),
);
assertEquals(res2.status, 500);
assertEquals(await res2.text(), 'Internal Server Error');
});
Deno.test('HttpKernel: 404 for unmatched route', async () => {
const kernel = new HttpKernel();
const res = await kernel.handle(new Request('http://localhost/nothing'));
@@ -113,18 +136,16 @@ Deno.test('HttpKernel: throws on next() called twice', async () => {
const kernel = new HttpKernel();
kernel.route({ method: 'GET', path: '/bad' })
.middleware(async (ctx, next) => {
.middleware(async (_ctx, next) => {
await next();
await next(); // ❌
return new Response('should never reach');
})
.handle(() => Promise.resolve(new Response('OK')));
.handle((_ctx) => Promise.resolve(new Response('OK')));
await assertRejects(
() => kernel.handle(new Request('http://localhost/bad')),
Error,
'next() called multiple times',
);
const res = await kernel.handle(new Request('http://localhost/bad'));
assertEquals(res.status, 500);
assertEquals(await res.text(), 'Internal Server Error');
});
Deno.test('HttpKernel: handler throws → error propagates', async () => {
@@ -135,11 +156,9 @@ Deno.test('HttpKernel: handler throws → error propagates', async () => {
throw new Error('fail!');
});
await assertRejects(
() => kernel.handle(new Request('http://localhost/throw')),
Error,
'fail!',
);
const res = await kernel.handle(new Request('http://localhost/throw'));
assertEquals(res.status, 500);
assertEquals(await res.text(), 'Internal Server Error');
});
Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async () => {
@@ -157,5 +176,5 @@ Deno.test('HttpKernel: returns 500 if no handler or middleware defined', async (
const res = await kernel.handle(new Request('http://localhost/fail'));
assertEquals(res.status, 500);
assertEquals(await res.text(), 'Internal error');
assertEquals(await res.text(), 'Internal Server Error');
});