Compare commits

10 Commits

Author SHA1 Message Date
d8a7686557 feat(ci): enhance CI workflow with granular steps and error handling
All checks were successful
Auto Changelog & Release / detect-version-change (push) Successful in 3s
Auto Changelog & Release / release (push) Has been skipped
Auto Changelog & Release / changelog-only (push) Successful in 7s
- Add separate steps for formatting, linting, testing, and benchmarking
- Introduce failure detection to halt workflow on step failure
- Improve maintainability and visibility of CI process outcomes
2025-05-27 14:56:14 +02:00
39e39a9699 chore(config): add CI task for local checks
- Introduces a new "ci" task to streamline local CI workflows
- Combines formatting, linting, testing, and benchmarking for convenience
2025-05-27 14:56:03 +02:00
801d06ebf8 test(routebuilder): add validation tests for handler and middleware
- Add tests to validate middleware and handler signatures
- Ensure TypeError is thrown for invalid signatures
2025-05-27 14:50:10 +02:00
b3ed8dd52c test(httpkernel): enforce compile-time validation for signatures
- Replace runtime 500 errors with compile-time validation for invalid
  middleware and handler signatures using `assertThrows`
- Improve test clarity by explicitly checking for expected exceptions
  and error messages
2025-05-27 14:50:01 +02:00
8243b07a5a refactor(kernel): simplify middleware and handler execution
- Replace middleware pipeline logic with route-specific execution
- Remove redundant type checks and streamline error handling
- Improve maintainability by delegating response decoration to routes
2025-05-27 14:49:47 +02:00
91708a0499 feat(route-builder): add middleware chain compilation
- Introduces a `compile` method to compose middleware and handler
  into a single executable function.
- Enhances runtime safety by ensuring `next()` is called only once
  per middleware and validating middleware/handler signatures.
- Improves code structure and maintainability for route execution.
2025-05-27 14:49:32 +02:00
beadcefc6a feat(interfaces): add runRoute method to IInternalRoute
- Introduces a statically compiled `runRoute` method to encapsulate
  the middleware chain and final handler execution for routes.
- Ensures consistent pipeline execution with safeguards like single
  `next()` invocation and a guaranteed `Response` return type.
2025-05-27 14:49:15 +02:00
ba3d2e33f2 test(bench): add parallel benchmarks for HTTP kernel
- Introduce parallel benchmarks for simple and complex HTTP requests
- Improve performance testing with higher concurrency (10,000 requests)
- Comment out single-request benchmarks for future reference
2025-05-27 14:48:05 +02:00
2d3dacc358 chore(gitignore): add .local directory to ignored files
- Updates .gitignore to include the .local directory
- Prevents accidental commits of local configuration files
2025-05-27 13:32:48 +02:00
af32f3b9f4 chore(ci): update deno tasks in CI workflow
- Replace individual deno commands with `deno task` equivalents
- Add benchmark task to the CI workflow for performance testing
2025-05-27 13:32:38 +02:00
9 changed files with 285 additions and 99 deletions

View File

@@ -17,13 +17,36 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Set up Deno
uses: denoland/setup-deno@v1
- uses: denoland/setup-deno@v1
with:
deno-version: v2.x
- name: Run CI Checks
- name: Format
id: format
continue-on-error: true
run: deno task fmt
- name: Lint
id: lint
continue-on-error: true
run: deno task lint
- name: Test
id: test
continue-on-error: true
run: deno task test
- name: Benchmark
id: benchmark
continue-on-error: true
run: deno task benchmark
- name: Fail if any step failed
if: |
steps.format.outcome != 'success' ||
steps.lint.outcome != 'success' ||
steps.test.outcome != 'success' ||
steps.benchmark.outcome != 'success'
run: |
deno fmt --check
deno lint
deno task test
echo "::error::One or more steps failed"
exit 1

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@
coverage/
logs/
.locale/
.local/
cache/
out.py
output.txt

View File

@@ -9,7 +9,8 @@
"test:watch": "deno test --watch --allow-net --allow-env --unstable-kv --allow-read --allow-write **/__tests__/*.test.ts",
"benchmark": "deno bench --allow-net --allow-env --unstable-kv --allow-read --allow-write **/__bench__/*.bench.ts",
"fmt": "deno fmt --check",
"lint": "deno lint"
"lint": "deno lint",
"ci": "deno task fmt && deno task lint && deno task test && deno task benchmark" // For local CI checks
},
"compilerOptions": {
"lib": [

View File

@@ -8,13 +8,9 @@ import type {
} from './Interfaces/mod.ts';
import {
type DeepPartial,
type Handler,
HTTP_404_NOT_FOUND,
HTTP_500_INTERNAL_SERVER_ERROR,
HttpStatusTextMap,
isHandler,
isMiddleware,
type Middleware,
} from './Types/mod.ts';
import { RouteBuilder } from './RouteBuilder.ts';
import { createEmptyContext, normalizeError } from './Utils/mod.ts';
@@ -108,11 +104,12 @@ export class HttpKernel<TContext extends IContext = IContext>
query: match.query,
state: {},
} as TContext;
return await this.executePipeline(
ctx,
route.middlewares,
route.handler,
);
try {
const response = await route.runRoute(ctx);
return this.cfg.decorateResponse(response, ctx);
} catch (e) {
return await this.handleInternalError(ctx, e);
}
}
}
@@ -135,65 +132,13 @@ export class HttpKernel<TContext extends IContext = IContext>
this.routes.push(route as unknown as IInternalRoute<TContext>);
}
/**
* Executes the middleware and handler pipeline for a matched route.
*
* This function:
* - Enforces linear middleware execution with `next()` tracking
* - Validates middleware and handler types at runtime
* - Applies the optional response decorator post-processing
* - Handles all runtime errors via the configured 500 handler
*
* @param ctx - The active request context passed to middleware and handler.
* @param middleware - Ordered middleware functions for this route.
* @param handler - The final handler responsible for generating a response.
* @returns The final HTTP `Response`, possibly decorated.
*/
private async executePipeline(
private handleInternalError = (
ctx: TContext,
middleware: Middleware<TContext>[],
handler: Handler<TContext>,
): Promise<Response> {
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> => {
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);
};
try {
const response = await dispatch(0);
return this.cfg.decorateResponse(response, ctx);
} catch (e) {
return handleInternalError(ctx, e);
}
}
err?: unknown,
): Response | Promise<Response> => {
return this.cfg.httpErrorHandlers[HTTP_500_INTERNAL_SERVER_ERROR](
ctx,
normalizeError(err),
);
};
}

View File

@@ -36,4 +36,29 @@ export interface IInternalRoute<TContext extends IContext = IContext> {
* The final handler that generates the HTTP response after all middleware has run.
*/
handler: Handler<TContext>;
/**
* The fully compiled execution pipeline for this route.
*
* This function is generated at route registration time and encapsulates the
* entire middleware chain as well as the final handler. It is called by the
* HttpKernel during request dispatch when a route has been matched.
*
* Internally, `runRoute` ensures that each middleware is invoked in the correct order
* and receives a `next()` callback to pass control downstream. The final handler is
* invoked once all middleware has completed or short-circuited the pipeline.
*
* It is guaranteed that:
* - The function is statically compiled and does not perform dynamic dispatching.
* - Each middleware can only call `next()` once; repeated invocations will throw.
* - The return value is either a `Response` or a Promise resolving to one.
*
* @param ctx - The context object carrying route, request, response and other scoped data.
* @returns A `Response` object or a Promise resolving to a `Response`.
*
* @throws {Error} If a middleware calls `next()` more than once.
*/
runRoute: (
ctx: TContext,
) => Promise<Response> | Response;
}

View File

@@ -1,10 +1,17 @@
import type { IRouteMatcherFactory } from './Interfaces/IRouteMatcher.ts';
import type {
IContext,
IInternalRoute,
IRouteBuilder,
IRouteDefinition,
} from './Interfaces/mod.ts';
import type { Handler, Middleware, RegisterRoute } from './Types/mod.ts';
import { isHandler } from './Types/Handler.ts';
import {
type Handler,
isMiddleware,
type Middleware,
type RegisterRoute,
} from './Types/mod.ts';
import { createRouteMatcher } from './Utils/createRouteMatcher.ts';
/**
@@ -66,6 +73,76 @@ export class RouteBuilder<TContext extends IContext = IContext>
matcher,
middlewares: this.mws,
handler: handler,
runRoute: this.compile({
middlewares: this.mws,
handler: handler,
}),
});
}
/**
* Compiles the middleware chain and handler into a single executable function.
*
* This method constructs a statically linked function chain by reducing all middleware
* and the final handler into one composed `runRoute` function. Each middleware receives
* a `next()` callback that invokes the next function in the chain.
*
* Additionally, the returned function ensures that `next()` can only be called once
* per middleware. If `next()` is invoked multiple times within the same middleware,
* a runtime `Error` is thrown, preventing unintended double-processing.
*
* Type safety is enforced at compile time:
* - If the final handler does not match the expected signature, a `TypeError` is thrown.
* - If any middleware does not conform to the middleware interface, a `TypeError` is thrown.
*
* @param route - A partial route object containing middleware and handler,
* excluding `matcher`, `method`, and `runRoute`.
* @returns A composed route execution function that takes a context object
* and returns a `Promise<Response>`.
*
* @throws {TypeError} If the handler or any middleware function is invalid.
* @throws {Error} If a middleware calls `next()` more than once during execution.
*/
private compile(
route: Omit<
IInternalRoute<TContext>,
'runRoute' | 'matcher' | 'method'
>,
): (
ctx: TContext,
) => Promise<Response> {
if (!isHandler<TContext>(route.handler)) {
throw new TypeError(
'Route handler must be a function returning a Promise<Response>.',
);
}
let composed = route.handler;
for (let i = route.middlewares.length - 1; i >= 0; i--) {
if (!isMiddleware<TContext>(route.middlewares[i])) {
throw new TypeError(
`Middleware at index ${i} is not a valid function.`,
);
}
const current = route.middlewares[i];
const next = composed;
composed = async (ctx: TContext): Promise<Response> => {
let called = false;
return await current(ctx, async () => {
if (called) {
throw new Error(
`next() called multiple times in middleware at index ${i}`,
);
}
called = true;
return await next(ctx);
});
};
}
return composed;
}
}

View File

@@ -0,0 +1,87 @@
import type { IRouteDefinition } from '../Interfaces/mod.ts';
import { HttpKernel } from '../mod.ts';
const CONCURRENT_REQUESTS = 10000;
// Deno.bench('Simple request', async (b) => {
// const kernel = new HttpKernel();
// const def: IRouteDefinition = { method: 'GET', path: '/hello' };
// kernel.route(def).handle((_ctx) => {
// return Promise.resolve(new Response('OK', { status: 200 }));
// });
// b.start();
// await kernel.handle(
// new Request('http://localhost/hello', { method: 'GET' }),
// );
// b.end();
// });
Deno.bench('Simple request (parallel)', async (b) => {
const kernel = new HttpKernel();
const def: IRouteDefinition = { method: 'GET', path: '/hello' };
kernel.route(def).handle((_ctx) => {
return Promise.resolve(new Response('OK', { status: 200 }));
});
const requests = Array.from(
{ length: CONCURRENT_REQUESTS },
() =>
kernel.handle(
new Request('http://localhost/hello', { method: 'GET' }),
),
);
b.start();
await Promise.all(requests);
b.end();
});
// Deno.bench('Complex request', async (b) => {
// const kernel = new HttpKernel();
// kernel.route({ method: 'GET', path: '/test' })
// .middleware(async (_ctx, next) => {
// return await next();
// })
// .middleware(async (_ctx, next) => {
// return await next();
// })
// .handle((_ctx) => {
// return Promise.resolve(new Response('done'));
// });
// b.start();
// await kernel.handle(
// new Request('http://localhost/test', { method: 'GET' }),
// );
// b.end();
// });
Deno.bench('Complex request (parallel)', async (b) => {
const kernel = new HttpKernel();
kernel.route({ method: 'GET', path: '/test' })
.middleware(async (_ctx, next) => {
return await next();
})
.middleware(async (_ctx, next) => {
return await next();
})
.handle((_ctx) => {
return Promise.resolve(new Response('done'));
});
const requests = Array.from(
{ length: CONCURRENT_REQUESTS },
() =>
kernel.handle(
new Request('http://localhost/test', { method: 'GET' }),
),
);
b.start();
await Promise.all(requests);
b.end();
});

View File

@@ -1,4 +1,7 @@
import { assertEquals } from 'https://deno.land/std@0.204.0/assert/mod.ts';
import {
assertEquals,
assertThrows,
} from 'https://deno.land/std@0.204.0/assert/mod.ts';
import { HttpKernel } from '../HttpKernel.ts';
import type { IRouteDefinition } from '../Interfaces/mod.ts';
@@ -88,30 +91,32 @@ Deno.test('HttpKernel: middleware short-circuits pipeline', async () => {
assertEquals(calls, ['mw1']);
});
Deno.test('HttpKernel: invalid middleware or handler signature triggers 500', async () => {
Deno.test('HttpKernel: invalid middleware or handler signature throws at compile time', () => {
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');
assertThrows(
() => {
kernel.route({ method: 'GET', path: '/bad-mw' })
// @ts-expect-error invalid middleware
.middleware(() => new Response('invalid'))
.handle((_ctx) => Promise.resolve(new Response('ok')));
},
TypeError,
'Middleware at index 0 is not a valid function.',
);
// 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'),
assertThrows(
() => {
kernel.route({ method: 'GET', path: '/bad-handler' })
.middleware(async (_ctx, next) => await next())
// @ts-expect-error invalid handler
.handle(() => new Response('invalid'));
},
TypeError,
'Route handler must be a function returning a Promise<Response>.',
);
assertEquals(res2.status, 500);
assertEquals(await res2.text(), 'Internal Server Error');
});
Deno.test('HttpKernel: 404 for unmatched route', async () => {
@@ -124,7 +129,7 @@ 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')));
.handle((_ctx) => Promise.resolve(new Response('nope')));
const res = await kernel.handle(
new Request('http://localhost/only-post', { method: 'GET' }),
@@ -152,7 +157,7 @@ Deno.test('HttpKernel: handler throws → error propagates', async () => {
const kernel = new HttpKernel();
kernel.route({ method: 'GET', path: '/throw' })
.handle(() => {
.handle((_ctx) => {
throw new Error('fail!');
});

View File

@@ -10,11 +10,24 @@ import type { Handler, Middleware } from '../Types/mod.ts';
// Dummy objects
// deno-lint-ignore require-await
const dummyHandler: Handler = async () => new Response('ok');
const dummyHandler: Handler = async (_) => new Response('ok');
// deno-lint-ignore require-await
const wrongHandler: Handler = async () => new Response('ok'); // Wrong signature, no ctx
const dummyMiddleware: Middleware = async (_, next) => await next();
// deno-lint-ignore require-await
const wrongMiddleware: Middleware = async () => new Response('ok'); // Wrong signature, no ctx, next
const dummyDef: IRouteDefinition = { method: 'GET', path: '/hello' };
const dummyMatcher = () => ({ params: {} });
Deno.test('middleware: throws if middleware signature is wrong', () => {
const builder = new RouteBuilder(() => {}, dummyDef);
assertThrows(
() => builder.middleware(wrongMiddleware).handle(dummyHandler),
TypeError,
'Middleware at index 0 is not a valid function.',
);
});
Deno.test('middleware: single middleware is registered correctly', () => {
let registered: IInternalRoute | null = null as IInternalRoute | null;
@@ -51,6 +64,15 @@ Deno.test('middleware: preserves order of middleware', () => {
assertEquals(result!.middlewares, [mw1, mw2]);
});
Deno.test('handle: throws if handler signature is wrong', () => {
const builder = new RouteBuilder(() => {}, dummyDef);
assertThrows(
() => builder.handle(wrongHandler),
TypeError,
'Route handler must be a function returning a Promise<Response>.',
);
});
Deno.test('handle: uppercases method', () => {
let result: IInternalRoute | null = null as IInternalRoute | null;