@martel/calyx 1.2.4 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [1.3.0](https://github.com/bmartel/calyx/compare/v1.2.4...v1.3.0) (2026-07-01)
2
+
3
+
4
+ ### Features
5
+
6
+ * **http:** implement middleware support, JIT radix router compilation, and context pooling ([f0cf712](https://github.com/bmartel/calyx/commit/f0cf7125181749728eda29261e8423cd467b4977))
7
+
1
8
  ## [1.2.4](https://github.com/bmartel/calyx/compare/v1.2.3...v1.2.4) (2026-07-01)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@martel/calyx",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "description": "High-performance Bun-native NestJS-compatible framework",
5
5
  "main": "src/index.ts",
6
6
  "bin": {
@@ -5,6 +5,20 @@ import { ParameterConfig } from './decorators.ts';
5
5
  import { HttpException, NotFoundException } from './exceptions.ts';
6
6
  import { ArgumentsHost, ExecutionContext } from '../lifecycle/interfaces.ts';
7
7
  import { CalyxArgumentsHost, CalyxExecutionContext } from '../lifecycle/context.ts';
8
+ import { CalyxMiddlewareConsumer, MiddlewareConfiguration, RequestMethodMap, CompiledLifecycleItem as CompiledMiddlewareItem } from './middleware.ts';
9
+
10
+ class ObjectPool<T> {
11
+ private pool: T[] = [];
12
+ constructor(private readonly factory: () => T) {}
13
+
14
+ acquire(): T {
15
+ return this.pool.pop() ?? this.factory();
16
+ }
17
+
18
+ release(item: T) {
19
+ this.pool.push(item);
20
+ }
21
+ }
8
22
 
9
23
  export class CalyxResponse {
10
24
  statusCode = 200;
@@ -65,6 +79,7 @@ interface HandlerConfig {
65
79
  guardsList: CompiledLifecycleItem[];
66
80
  interceptorsList: CompiledLifecycleItem[];
67
81
  filtersList: CompiledLifecycleItem[];
82
+ middlewaresList: CompiledLifecycleItem[];
68
83
  }
69
84
 
70
85
  export class CalyxApplication {
@@ -76,6 +91,15 @@ export class CalyxApplication {
76
91
  private globalInterceptors: any[] = [];
77
92
  private globalPipes: any[] = [];
78
93
  private globalFilters: any[] = [];
94
+ private globalMiddlewares: any[] = [];
95
+ private middlewareConfigurations: MiddlewareConfiguration[] = [];
96
+ private hostPool = new ObjectPool<CalyxArgumentsHost>(() => new CalyxArgumentsHost());
97
+ private contextPool = new ObjectPool<CalyxExecutionContext>(() => new CalyxExecutionContext());
98
+
99
+ use(...middlewares: any[]) {
100
+ this.globalMiddlewares.push(...middlewares);
101
+ return this;
102
+ }
79
103
 
80
104
  useGlobalGuards(...guards: any[]) {
81
105
  this.globalGuards.push(...guards);
@@ -99,6 +123,9 @@ export class CalyxApplication {
99
123
  // Bootstrap the dependency injection container
100
124
  this.container.bootstrap(this.rootModule);
101
125
 
126
+ // Resolve registered modules middlewares
127
+ this.resolveMiddleware();
128
+
102
129
  // Build the routing table from registered controllers
103
130
  this.buildRoutes();
104
131
 
@@ -116,8 +143,8 @@ export class CalyxApplication {
116
143
  const prefix = Reflect.getMetadata(METADATA_KEYS.CONTROLLER, controllerClass) ?? '';
117
144
  const methods = this.getMethods(controllerClass.prototype);
118
145
  const controllerScope = this.container.getControllerScope(controllerClass);
119
- const isRequestScoped = controllerScope === Scope.REQUEST;
120
- const instance = isRequestScoped ? null : record.instances.get(controllerClass);
146
+ const isControllerRequestScoped = controllerScope === Scope.REQUEST;
147
+ const singletonInstance = isControllerRequestScoped ? null : record.instances.get(controllerClass);
121
148
 
122
149
  for (const method of methods) {
123
150
  const routeMeta = Reflect.getMetadata(METADATA_KEYS.HTTP_METHOD, controllerClass.prototype, method);
@@ -155,6 +182,8 @@ export class CalyxApplication {
155
182
  const methodFilters = Reflect.getMetadata(METADATA_KEYS.FILTERS, controllerClass.prototype, method) || [];
156
183
  const filtersList = this.compileLifecycleItems(moduleClass, [...this.globalFilters, ...classFilters, ...methodFilters]);
157
184
 
185
+ const middlewaresList = this.getMiddlewaresForRoute(routeMeta.method, fullPath, controllerClass, moduleClass);
186
+
158
187
  const paramTypes = Reflect.getMetadata('design:paramtypes', controllerClass.prototype, method) || [];
159
188
  const compiledParamsConfig: CompiledParameterConfig[] = paramsConfig.map((p) => {
160
189
  const classPipes = Reflect.getMetadata(METADATA_KEYS.PIPES, controllerClass) || [];
@@ -172,12 +201,27 @@ export class CalyxApplication {
172
201
  guardsList.length > 0 ||
173
202
  interceptorsList.length > 0 ||
174
203
  filtersList.length > 0 ||
204
+ middlewaresList.length > 0 ||
175
205
  compiledParamsConfig.some((p) => p.pipesList.length > 0);
176
206
 
207
+ const hasReqScopedGuard = guardsList.some((g) => g.isReqScoped);
208
+ const hasReqScopedInterceptor = interceptorsList.some((i) => i.isReqScoped);
209
+ const hasReqScopedFilter = filtersList.some((f) => f.isReqScoped);
210
+ const hasReqScopedPipe = compiledParamsConfig.some((p) => p.pipesList.some((pi) => pi.isReqScoped));
211
+ const hasReqScopedMiddleware = middlewaresList.some((m) => m.isReqScoped);
212
+
213
+ const isRouteRequestScoped =
214
+ isControllerRequestScoped ||
215
+ hasReqScopedGuard ||
216
+ hasReqScopedInterceptor ||
217
+ hasReqScopedFilter ||
218
+ hasReqScopedPipe ||
219
+ hasReqScopedMiddleware;
220
+
177
221
  this.router.insert(routeMeta.method, fullPath, {
178
222
  controllerClass,
179
223
  moduleClass,
180
- instance,
224
+ instance: singletonInstance,
181
225
  methodName: method,
182
226
  paramsConfig: compiledParamsConfig,
183
227
  httpCode,
@@ -187,17 +231,108 @@ export class CalyxApplication {
187
231
  hasQueryParam,
188
232
  hasResParam,
189
233
  isPassthrough,
190
- isRequestScoped,
234
+ isRequestScoped: isRouteRequestScoped,
191
235
  hasLifecycleMetadata,
192
236
  guardsList,
193
237
  interceptorsList,
194
238
  filtersList,
239
+ middlewaresList,
195
240
  });
196
241
  }
197
242
  }
198
243
  }
199
244
  }
200
245
 
246
+ private resolveMiddleware() {
247
+ const consumer = new CalyxMiddlewareConsumer();
248
+ const modules = this.container.getModules();
249
+ for (const [moduleClass, record] of modules.entries()) {
250
+ const instance = record.instances.get(moduleClass);
251
+ if (instance && typeof instance.configure === 'function') {
252
+ instance.configure(consumer);
253
+ }
254
+ }
255
+ this.middlewareConfigurations = consumer.getConfigurations();
256
+ }
257
+
258
+ private getMiddlewaresForRoute(
259
+ method: string,
260
+ routePath: string,
261
+ controllerClass: any,
262
+ moduleClass: any
263
+ ): CompiledLifecycleItem[] {
264
+ const matchedMiddlewares: any[] = [...this.globalMiddlewares];
265
+
266
+ for (const config of this.middlewareConfigurations) {
267
+ // 1. Check if excluded
268
+ let isExcluded = false;
269
+ for (const ex of config.exclude) {
270
+ if (typeof ex === 'string') {
271
+ if (this.routePathMatches(routePath, ex)) {
272
+ isExcluded = true;
273
+ break;
274
+ }
275
+ } else {
276
+ // RouteInfo
277
+ const exMethod = RequestMethodMap[ex.method];
278
+ if ((exMethod === 'ALL' || exMethod === method.toUpperCase()) && this.routePathMatches(routePath, ex.path)) {
279
+ isExcluded = true;
280
+ break;
281
+ }
282
+ }
283
+ }
284
+
285
+ if (isExcluded) continue;
286
+
287
+ // 2. Check if matched in forRoutes
288
+ let isMatched = false;
289
+ for (const rule of config.forRoutes) {
290
+ if (typeof rule === 'string') {
291
+ if (rule === '*' || this.routePathMatches(routePath, rule)) {
292
+ isMatched = true;
293
+ break;
294
+ }
295
+ } else if (typeof rule === 'function') {
296
+ // It's a controller class
297
+ if (rule === controllerClass) {
298
+ isMatched = true;
299
+ break;
300
+ }
301
+ } else {
302
+ // RouteInfo
303
+ const ruleMethod = RequestMethodMap[rule.method];
304
+ if ((ruleMethod === 'ALL' || ruleMethod === method.toUpperCase()) && this.routePathMatches(routePath, rule.path)) {
305
+ isMatched = true;
306
+ break;
307
+ }
308
+ }
309
+ }
310
+
311
+ if (isMatched) {
312
+ matchedMiddlewares.push(...config.middleware);
313
+ }
314
+ }
315
+
316
+ return this.compileLifecycleItems(moduleClass, matchedMiddlewares);
317
+ }
318
+
319
+ private routePathMatches(routePath: string, pattern: string): boolean {
320
+ const rPath = routePath.startsWith('/') ? routePath : '/' + routePath;
321
+ const pPath = pattern.startsWith('/') ? pattern : '/' + pattern;
322
+
323
+ if (pPath === '/*' || pPath === '*' || pPath === '/**') {
324
+ return true;
325
+ }
326
+
327
+ const regexStr = pPath
328
+ .replace(/\?/g, '\\?')
329
+ .replace(/\/\*/g, '(/.*)?')
330
+ .replace(/:[a-zA-Z0-9_]+/g, '[^/]+');
331
+
332
+ const regex = new RegExp(`^${regexStr}$`);
333
+ return regex.test(rPath);
334
+ }
335
+
201
336
  private getMethods(obj: any): string[] {
202
337
  const properties = new Set<string>();
203
338
  let currentObj = obj;
@@ -238,6 +373,9 @@ export class CalyxApplication {
238
373
 
239
374
  const matched = this.router.match(req.method, pathname);
240
375
  if (!matched) {
376
+ if (this.globalMiddlewares.length > 0) {
377
+ return this.handleGlobalMiddlewaresAnd404(req, pathname);
378
+ }
241
379
  throw new NotFoundException(`Cannot ${req.method} ${pathname}`);
242
380
  }
243
381
 
@@ -249,13 +387,18 @@ export class CalyxApplication {
249
387
  if (handler.isRequestScoped) {
250
388
  requestContext = new Map<any, any>();
251
389
  requestContext.set(REQUEST, req);
252
- instance = this.container.resolveControllerInRequestContext(handler.moduleClass, handler.controllerClass, requestContext);
390
+ const controllerScope = this.container.getControllerScope(handler.controllerClass);
391
+ if (controllerScope === Scope.REQUEST) {
392
+ instance = this.container.resolveControllerInRequestContext(handler.moduleClass, handler.controllerClass, requestContext);
393
+ }
253
394
  }
254
395
 
255
396
  // Check if we need to run lifecycle hooks
256
397
  const hasLifecycle = handler.hasLifecycleMetadata || this.hasGlobalLifecycle();
257
398
  if (hasLifecycle) {
258
- if (handler.hasBodyParam && req.body && req.method !== 'GET' && req.method !== 'HEAD') {
399
+ const needsBodyParsing = (handler.hasBodyParam || handler.middlewaresList.length > 0) &&
400
+ req.body && req.method !== 'GET' && req.method !== 'HEAD';
401
+ if (needsBodyParsing) {
259
402
  return this.handleRequestPipelineAsync(req, handler, params, search, instance, requestContext);
260
403
  }
261
404
  return this.handleRequestPipeline(req, handler, params, search, null, instance, requestContext);
@@ -304,14 +447,19 @@ export class CalyxApplication {
304
447
  }
305
448
  }
306
449
 
307
- return this.handleRequestPipeline(req, handler, params, search, body, instance, requestContext);
450
+ return await this.handleRequestPipeline(req, handler, params, search, body, instance, requestContext);
308
451
  } catch (err: any) {
309
452
  const resWrapper = new CalyxResponse();
310
- const host = new CalyxArgumentsHost(req, resWrapper);
311
- return this.handleLifecycleError(err, host, handler.controllerClass, handler.methodName, requestContext);
453
+ const host = this.hostPool.acquire();
454
+ host.reset(req, resWrapper);
455
+ try {
456
+ return await this.handleLifecycleError(err, host, handler, requestContext);
457
+ } finally {
458
+ host.clear();
459
+ this.hostPool.release(host);
460
+ }
312
461
  }
313
462
  }
314
-
315
463
  private async handleRequestPipeline(
316
464
  req: Request,
317
465
  handler: HandlerConfig,
@@ -325,10 +473,43 @@ export class CalyxApplication {
325
473
  const controllerClass = handler.controllerClass;
326
474
  const moduleClass = handler.moduleClass;
327
475
  const resWrapper = new CalyxResponse();
328
- const host = new CalyxArgumentsHost(req, resWrapper);
329
- const context = new CalyxExecutionContext(req, resWrapper, controllerClass, instance[methodName]);
476
+ const host = this.hostPool.acquire();
477
+ host.reset(req, resWrapper);
478
+ const context = this.contextPool.acquire();
479
+ context.resetContext(req, resWrapper, controllerClass, instance[methodName]);
330
480
 
331
481
  try {
482
+ // 0. Run Middleware
483
+ if (handler.middlewaresList.length > 0) {
484
+ let index = 0;
485
+ const next = async (err?: any): Promise<void> => {
486
+ if (err) throw err;
487
+ if (index < handler.middlewaresList.length) {
488
+ const item = handler.middlewaresList[index++];
489
+ const mwInstance = item.isReqScoped
490
+ ? this.container.resolveTokenInModuleContext(moduleClass, item.token, requestContext)
491
+ : item.instance;
492
+
493
+ if (typeof mwInstance.use === 'function') {
494
+ await mwInstance.use(req, resWrapper, next);
495
+ } else if (typeof mwInstance === 'function') {
496
+ await mwInstance(req, resWrapper, next);
497
+ } else {
498
+ await next();
499
+ }
500
+ }
501
+ };
502
+ await next();
503
+
504
+ // If middleware sent a response, return it directly
505
+ if (resWrapper.sent) {
506
+ return new Response(resWrapper.body, {
507
+ status: resWrapper.statusCode,
508
+ headers: resWrapper.headers,
509
+ });
510
+ }
511
+ }
512
+
332
513
  // 1. Run Guards
333
514
  for (const guard of handler.guardsList) {
334
515
  const guardInstance = guard.isReqScoped
@@ -411,7 +592,45 @@ export class CalyxApplication {
411
592
 
412
593
  return this.processResult(req, result, resWrapper, httpCode, headers, redirect, handler.isPassthrough, handler.hasResParam);
413
594
  } catch (err: any) {
414
- return this.handleLifecycleError(err, host, handler, requestContext);
595
+ return await this.handleLifecycleError(err, host, handler, requestContext);
596
+ } finally {
597
+ host.clear();
598
+ context.clearContext();
599
+ this.hostPool.release(host);
600
+ this.contextPool.release(context);
601
+ }
602
+ }
603
+
604
+ private async handleGlobalMiddlewaresAnd404(req: Request, pathname: string): Promise<Response> {
605
+ const resWrapper = new CalyxResponse();
606
+ try {
607
+ const compiled = this.compileLifecycleItems(this.rootModule, this.globalMiddlewares);
608
+ let index = 0;
609
+ const next = async (err?: any): Promise<void> => {
610
+ if (err) throw err;
611
+ if (index < compiled.length) {
612
+ const item = compiled[index++];
613
+ const mwInstance = item.instance;
614
+ if (typeof mwInstance.use === 'function') {
615
+ await mwInstance.use(req, resWrapper, next);
616
+ } else if (typeof mwInstance === 'function') {
617
+ await mwInstance(req, resWrapper, next);
618
+ } else {
619
+ await next();
620
+ }
621
+ }
622
+ };
623
+ await next();
624
+
625
+ if (resWrapper.sent) {
626
+ return new Response(resWrapper.body, {
627
+ status: resWrapper.statusCode,
628
+ headers: resWrapper.headers,
629
+ });
630
+ }
631
+ throw new NotFoundException(`Cannot ${req.method} ${pathname}`);
632
+ } catch (err: any) {
633
+ return this.handleError(err);
415
634
  }
416
635
  }
417
636
 
@@ -454,6 +673,22 @@ export class CalyxApplication {
454
673
  private compileLifecycleItems(moduleClass: any, items: any[]): CompiledLifecycleItem[] {
455
674
  return items.map((item) => {
456
675
  if (typeof item === 'function') {
676
+ const isClass =
677
+ item.prototype && (
678
+ typeof item.prototype.canActivate === 'function' ||
679
+ typeof item.prototype.intercept === 'function' ||
680
+ typeof item.prototype.transform === 'function' ||
681
+ typeof item.prototype.catch === 'function' ||
682
+ typeof item.prototype.use === 'function' ||
683
+ Reflect.hasMetadata(METADATA_KEYS.INJECTABLE, item) ||
684
+ Reflect.hasMetadata(METADATA_KEYS.CONTROLLER, item) ||
685
+ Reflect.hasMetadata(METADATA_KEYS.MODULE, item)
686
+ );
687
+
688
+ if (!isClass) {
689
+ return { isReqScoped: false, token: item, instance: item };
690
+ }
691
+
457
692
  const scope = this.container.getProviderScope(item);
458
693
  if (scope === Scope.REQUEST) {
459
694
  return { isReqScoped: true, token: item, instance: null };
@@ -609,7 +844,7 @@ export class CalyxApplication {
609
844
  : (httpCode ?? (req.method === 'POST' ? 201 : 200));
610
845
 
611
846
  // Build headers as a plain object Record<string, string>
612
- const responseHeaders: Record<string, string> = resWrapper && isPassthrough
847
+ const responseHeaders: Record<string, string> = resWrapper
613
848
  ? { ...resWrapper.headers }
614
849
  : {};
615
850
 
package/src/http/index.ts CHANGED
@@ -3,3 +3,4 @@ export * from './router.ts';
3
3
  export * from './exceptions.ts';
4
4
  export * from './application.ts';
5
5
  export * from './factory.ts';
6
+ export * from './middleware.ts';
@@ -0,0 +1,82 @@
1
+ import { Type } from '../core/metadata.ts';
2
+ import { CalyxResponse } from './application.ts';
3
+
4
+ export enum RequestMethod {
5
+ GET = 0,
6
+ POST = 1,
7
+ PUT = 2,
8
+ DELETE = 3,
9
+ PATCH = 4,
10
+ ALL = 5,
11
+ OPTIONS = 6,
12
+ HEAD = 7,
13
+ }
14
+
15
+ export const RequestMethodMap: Record<RequestMethod, string> = {
16
+ [RequestMethod.GET]: 'GET',
17
+ [RequestMethod.POST]: 'POST',
18
+ [RequestMethod.PUT]: 'PUT',
19
+ [RequestMethod.DELETE]: 'DELETE',
20
+ [RequestMethod.PATCH]: 'PATCH',
21
+ [RequestMethod.ALL]: 'ALL',
22
+ [RequestMethod.OPTIONS]: 'OPTIONS',
23
+ [RequestMethod.HEAD]: 'HEAD',
24
+ };
25
+
26
+ export interface RouteInfo {
27
+ path: string;
28
+ method: RequestMethod;
29
+ }
30
+
31
+ export interface NestMiddleware {
32
+ use(req: Request, res: CalyxResponse, next: (error?: any) => void | Promise<void>): any;
33
+ }
34
+
35
+ export interface MiddlewareConfigProxy {
36
+ exclude(...routes: (string | RouteInfo)[]): MiddlewareConfigProxy;
37
+ forRoutes(...routes: (string | Type<any> | RouteInfo)[]): MiddlewareConsumer;
38
+ }
39
+
40
+ export interface MiddlewareConsumer {
41
+ apply(...middleware: any[]): MiddlewareConfigProxy;
42
+ }
43
+
44
+ export interface NestModule {
45
+ configure(consumer: MiddlewareConsumer): void;
46
+ }
47
+
48
+ export interface MiddlewareConfiguration {
49
+ middleware: any[];
50
+ forRoutes: (string | Type<any> | RouteInfo)[];
51
+ exclude: (string | RouteInfo)[];
52
+ }
53
+
54
+ export class CalyxMiddlewareConsumer implements MiddlewareConsumer {
55
+ private configurations: MiddlewareConfiguration[] = [];
56
+
57
+ apply(...middleware: any[]): MiddlewareConfigProxy {
58
+ const config: MiddlewareConfiguration = {
59
+ middleware,
60
+ forRoutes: [],
61
+ exclude: [],
62
+ };
63
+ this.configurations.push(config);
64
+
65
+ const proxy: MiddlewareConfigProxy = {
66
+ exclude: (...routes: (string | RouteInfo)[]) => {
67
+ config.exclude.push(...routes);
68
+ return proxy;
69
+ },
70
+ forRoutes: (...routes: (string | Type<any> | RouteInfo)[]) => {
71
+ config.forRoutes.push(...routes);
72
+ return this;
73
+ },
74
+ };
75
+
76
+ return proxy;
77
+ }
78
+
79
+ getConfigurations(): MiddlewareConfiguration[] {
80
+ return this.configurations;
81
+ }
82
+ }
@@ -14,6 +14,8 @@ class RouterNode<T> {
14
14
  export class RadixRouter<T> {
15
15
  private root = new RouterNode<T>();
16
16
  private staticRoutes = new Map<string, T>();
17
+ private handlersArray: T[] = [];
18
+ private compiledMatch: ((method: string, path: string) => RouteMatch<T> | null) | null = null;
17
19
 
18
20
  insert(method: string, path: string, handler: T) {
19
21
  const hasParams = path.includes(':') || path.includes('*');
@@ -49,61 +51,144 @@ export class RadixRouter<T> {
49
51
  }
50
52
 
51
53
  node.handlers.set(method.toUpperCase(), handler);
54
+ this.compiledMatch = null;
52
55
  }
53
56
 
54
57
  match(method: string, path: string): RouteMatch<T> | null {
55
- const key = method.toUpperCase() + ' ' + path;
56
- const staticHandler = this.staticRoutes.get(key);
57
- if (staticHandler) {
58
- return { handler: staticHandler, params: {} };
58
+ if (this.compiledMatch) {
59
+ return this.compiledMatch(method, path);
59
60
  }
61
+ this.compile();
62
+ return this.compiledMatch!(method, path);
63
+ }
60
64
 
61
- const segments = path.split('/').filter(Boolean);
62
- const params: Record<string, string> = {};
63
- const matchNode = this.matchSegment(this.root, segments, 0, params);
65
+ compile() {
66
+ this.handlersArray = [];
67
+ const registerHandler = (handler: T): number => {
68
+ let idx = this.handlersArray.indexOf(handler);
69
+ if (idx === -1) {
70
+ idx = this.handlersArray.length;
71
+ this.handlersArray.push(handler);
72
+ }
73
+ return idx;
74
+ };
75
+
76
+ // 1. Compile static routes check
77
+ const staticRoutesByMethod: Record<string, Record<string, number>> = {};
78
+ for (const [key, handler] of this.staticRoutes.entries()) {
79
+ const spaceIdx = key.indexOf(' ');
80
+ const method = key.substring(0, spaceIdx);
81
+ const path = key.substring(spaceIdx + 1);
82
+ const handlerIdx = registerHandler(handler);
83
+
84
+ if (!staticRoutesByMethod[method]) {
85
+ staticRoutesByMethod[method] = {};
86
+ }
87
+ staticRoutesByMethod[method][path] = handlerIdx;
88
+ }
64
89
 
65
- if (!matchNode) return null;
90
+ let staticCheckCode = '';
91
+ if (Object.keys(staticRoutesByMethod).length > 0) {
92
+ staticCheckCode = `
93
+ switch (method) {
94
+ `;
95
+ for (const [method, routes] of Object.entries(staticRoutesByMethod)) {
96
+ staticCheckCode += ` case '${method}':\n switch (path) {\n`;
97
+ for (const [path, handlerIdx] of Object.entries(routes)) {
98
+ staticCheckCode += ` case '${path}': return { handler: handlers[${handlerIdx}], params: {} };\n`;
99
+ }
100
+ staticCheckCode += ` }\n break;\n`;
101
+ }
102
+ staticCheckCode += `}\n`;
103
+ }
66
104
 
67
- const handler = matchNode.handlers.get(method.toUpperCase()) ?? matchNode.handlers.get('ALL');
68
- if (!handler) return null;
105
+ // 2. Compile dynamic routes recursively
106
+ const compileNode = (node: RouterNode<T>, segmentIdx: number, startVar: string, collectedParams: string[]): string => {
107
+ const parts: string[] = [];
108
+ const nextStartVar = `s${segmentIdx + 1}`;
109
+ const nextEndVar = `e${segmentIdx + 1}`;
110
+
111
+ parts.push(`const ${nextEndVar} = path.indexOf('/', ${startVar});`);
112
+ parts.push(`const ${nextStartVar} = ${nextEndVar} === -1 ? len : ${nextEndVar};`);
113
+ parts.push(`const len${segmentIdx} = ${nextStartVar} - (${startVar});`);
114
+
115
+ // Static children check
116
+ if (node.children.size > 0) {
117
+ for (const [segment, childNode] of node.children.entries()) {
118
+ parts.push(`if (len${segmentIdx} === ${segment.length} && path.startsWith('${segment}', ${startVar})) {`);
119
+ parts.push(compileNode(childNode, segmentIdx + 1, `${nextStartVar} + 1`, collectedParams));
120
+ parts.push(`}`);
121
+ }
122
+ }
69
123
 
70
- return { handler, params };
71
- }
124
+ // Parameter child check
125
+ if (node.paramChild && node.paramName) {
126
+ parts.push(`if (len${segmentIdx} > 0) {`);
127
+ parts.push(` const p_${node.paramName} = path.substring(${startVar}, ${nextStartVar});`);
128
+ parts.push(compileNode(node.paramChild, segmentIdx + 1, `${nextStartVar} + 1`, [...collectedParams, node.paramName]));
129
+ parts.push(`}`);
130
+ }
72
131
 
73
- private matchSegment(
74
- node: RouterNode<T>,
75
- segments: string[],
76
- index: number,
77
- params: Record<string, string>
78
- ): RouterNode<T> | null {
79
- if (index === segments.length) {
80
- // If we've reached the end of segments, the current node must have handlers
81
- return node.handlers.size > 0 ? node : null;
82
- }
132
+ // Wildcard child check
133
+ if (node.wildcardChild) {
134
+ parts.push(`const p_wildcard = path.substring(${startVar});`);
135
+ const paramsEntries = [...collectedParams.map(p => `${p}: p_${p}`), `'*': p_wildcard`].join(', ');
136
+ parts.push(`const params = { ${paramsEntries} };`);
137
+ parts.push(`switch (method) {`);
138
+ for (const [method, handler] of node.wildcardChild.handlers.entries()) {
139
+ const handlerIdx = registerHandler(handler);
140
+ parts.push(` case '${method}': return { handler: handlers[${handlerIdx}], params };`);
141
+ }
142
+ const fallbackWildcardHandler = node.wildcardChild.handlers.get('ALL') ?? node.wildcardChild.handlers.values().next().value;
143
+ if (fallbackWildcardHandler) {
144
+ const handlerIdx = registerHandler(fallbackWildcardHandler);
145
+ parts.push(` default: return { handler: handlers[${handlerIdx}], params };`);
146
+ }
147
+ parts.push(`}`);
148
+ }
149
+
150
+ // Terminal handler check
151
+ if (node.handlers.size > 0) {
152
+ parts.push(`if (${nextStartVar} === len) {`);
153
+ const paramsEntries = collectedParams.map(p => `${p}: p_${p}`).join(', ');
154
+ parts.push(` const params = { ${paramsEntries} };`);
155
+ parts.push(` switch (method) {`);
156
+ for (const [method, handler] of node.handlers.entries()) {
157
+ const handlerIdx = registerHandler(handler);
158
+ parts.push(` case '${method}': return { handler: handlers[${handlerIdx}], params };`);
159
+ }
160
+ const fallbackHandler = node.handlers.get('ALL') ?? node.handlers.values().next().value;
161
+ if (fallbackHandler) {
162
+ const handlerIdx = registerHandler(fallbackHandler);
163
+ parts.push(` default: return { handler: handlers[${handlerIdx}], params };`);
164
+ }
165
+ parts.push(` }`);
166
+ parts.push(`}`);
167
+ }
83
168
 
84
- const segment = segments[index];
169
+ return parts.join('\n');
170
+ };
85
171
 
86
- // 1. Try static match
87
- const staticChild = node.children.get(segment);
88
- if (staticChild) {
89
- const match = this.matchSegment(staticChild, segments, index + 1, params);
90
- if (match) return match;
91
- }
172
+ const dynamicMatchCode = compileNode(this.root, 0, '1', []);
92
173
 
93
- // 2. Try parameter match
94
- if (node.paramChild && node.paramName) {
95
- params[node.paramName] = segment;
96
- const match = this.matchSegment(node.paramChild, segments, index + 1, params);
97
- if (match) return match;
98
- delete params[node.paramName]; // backtrack
99
- }
174
+ const fnBody = `
175
+ const len = path.length;
176
+ ${staticCheckCode}
177
+ ${dynamicMatchCode}
178
+ return null;
179
+ `;
100
180
 
101
- // 3. Try wildcard match
102
- if (node.wildcardChild) {
103
- params['*'] = segments.slice(index).join('/');
104
- return node.wildcardChild;
105
- }
106
181
 
107
- return null;
182
+ try {
183
+ this.compiledMatch = new Function('handlers', `
184
+ return function match(method, path) {
185
+ ${fnBody}
186
+ }
187
+ `)(this.handlersArray);
188
+ } catch (err) {
189
+ console.error('Failed to compile radix router matcher:', err);
190
+ console.error('Generated function body was:', fnBody);
191
+ throw err;
192
+ }
108
193
  }
109
194
  }
@@ -2,7 +2,24 @@ import { ArgumentsHost, HttpArgumentsHost, ExecutionContext } from './interfaces
2
2
  import { Type } from '../core/metadata.ts';
3
3
 
4
4
  export class CalyxArgumentsHost implements ArgumentsHost {
5
- constructor(private readonly req: Request, private readonly res: any) {}
5
+ protected req!: Request;
6
+ protected res!: any;
7
+
8
+ constructor(req?: Request, res?: any) {
9
+ if (req && res) {
10
+ this.reset(req, res);
11
+ }
12
+ }
13
+
14
+ reset(req: Request, res: any) {
15
+ this.req = req;
16
+ this.res = res;
17
+ }
18
+
19
+ clear() {
20
+ this.req = null as any;
21
+ this.res = null;
22
+ }
6
23
 
7
24
  getArgs<T extends any[] = any[]>(): T {
8
25
  return [this.req, this.res] as unknown as T;
@@ -22,13 +39,36 @@ export class CalyxArgumentsHost implements ArgumentsHost {
22
39
  }
23
40
 
24
41
  export class CalyxExecutionContext extends CalyxArgumentsHost implements ExecutionContext {
42
+ protected targetClass!: Type<any>;
43
+ protected handlerMethod!: Function;
44
+
25
45
  constructor(
46
+ req?: Request,
47
+ res?: any,
48
+ targetClass?: Type<any>,
49
+ handlerMethod?: Function
50
+ ) {
51
+ super(req, res);
52
+ if (targetClass && handlerMethod) {
53
+ this.resetContext(req!, res, targetClass, handlerMethod);
54
+ }
55
+ }
56
+
57
+ resetContext(
26
58
  req: Request,
27
59
  res: any,
28
- private readonly targetClass: Type<any>,
29
- private readonly handlerMethod: Function
60
+ targetClass: Type<any>,
61
+ handlerMethod: Function
30
62
  ) {
31
- super(req, res);
63
+ this.reset(req, res);
64
+ this.targetClass = targetClass;
65
+ this.handlerMethod = handlerMethod;
66
+ }
67
+
68
+ clearContext() {
69
+ this.clear();
70
+ this.targetClass = null as any;
71
+ this.handlerMethod = null as any;
32
72
  }
33
73
 
34
74
  getClass<T = any>(): Type<T> {
@@ -0,0 +1,160 @@
1
+ import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Controller,
5
+ Get,
6
+ Post,
7
+ Injectable,
8
+ CalyxFactory,
9
+ CalyxResponse,
10
+ NestMiddleware,
11
+ MiddlewareConsumer,
12
+ NestModule,
13
+ RequestMethod,
14
+ Scope,
15
+ } from '../src/index.ts';
16
+
17
+ // 1. Functional Global Middleware
18
+ function globalMiddleware(req: Request, res: CalyxResponse, next: () => void) {
19
+ res.headers['x-global-middleware'] = 'run';
20
+ next();
21
+ }
22
+
23
+ // 2. Class Middleware (Injectable)
24
+ @Injectable()
25
+ class LoggerMiddleware implements NestMiddleware {
26
+ use(req: Request, res: CalyxResponse, next: () => void) {
27
+ res.headers['x-logger-middleware'] = 'run';
28
+ next();
29
+ }
30
+ }
31
+
32
+ // 3. Request Scoped Middleware
33
+ let requestScopedCount = 0;
34
+
35
+ @Injectable({ scope: Scope.REQUEST })
36
+ class RequestScopedMiddleware implements NestMiddleware {
37
+ constructor() {
38
+ requestScopedCount++;
39
+ }
40
+ use(req: Request, res: CalyxResponse, next: () => void) {
41
+ res.headers['x-request-scoped-count'] = String(requestScopedCount);
42
+ next();
43
+ }
44
+ }
45
+
46
+ // 4. Short-circuiting Middleware
47
+ @Injectable()
48
+ class AuthMiddleware implements NestMiddleware {
49
+ use(req: Request, res: CalyxResponse, next: () => void) {
50
+ const token = req.headers.get('x-auth-token');
51
+ if (token !== 'secret') {
52
+ res.status(401).json({ message: 'Unauthorized Middleware' });
53
+ return; // Do not call next()
54
+ }
55
+ next();
56
+ }
57
+ }
58
+
59
+ @Controller('users')
60
+ class UsersController {
61
+ @Get()
62
+ findAll() {
63
+ return { data: 'users' };
64
+ }
65
+
66
+ @Get(':id')
67
+ findOne() {
68
+ return { data: 'user' };
69
+ }
70
+
71
+ @Post()
72
+ create() {
73
+ return { created: true };
74
+ }
75
+ }
76
+
77
+ @Controller('admin')
78
+ class AdminController {
79
+ @Get('dashboard')
80
+ dashboard() {
81
+ return { admin: true };
82
+ }
83
+ }
84
+
85
+ @Module({
86
+ controllers: [UsersController, AdminController],
87
+ providers: [LoggerMiddleware, RequestScopedMiddleware, AuthMiddleware],
88
+ })
89
+ class AppModule implements NestModule {
90
+ configure(consumer: MiddlewareConsumer) {
91
+ // Apply LoggerMiddleware to all routes under UsersController
92
+ consumer.apply(LoggerMiddleware).forRoutes(UsersController);
93
+
94
+ // Apply RequestScopedMiddleware to users POST route
95
+ consumer.apply(RequestScopedMiddleware).forRoutes({ path: 'users', method: RequestMethod.POST });
96
+
97
+ // Apply AuthMiddleware to dashboard, but exclude specific checks if needed
98
+ consumer.apply(AuthMiddleware)
99
+ .exclude('admin/dashboard/free')
100
+ .forRoutes('admin/*');
101
+ }
102
+ }
103
+
104
+ describe('NestJS Middleware Compatibility', () => {
105
+ let app: any;
106
+ let baseUrl: string;
107
+ const PORT = 3855;
108
+
109
+ beforeAll(async () => {
110
+ app = await CalyxFactory.create(AppModule);
111
+ app.use(globalMiddleware);
112
+ await app.listen(PORT);
113
+ baseUrl = `http://localhost:${PORT}`;
114
+ });
115
+
116
+ afterAll(async () => {
117
+ await app.close();
118
+ });
119
+
120
+ test('should run global functional middleware on any request', async () => {
121
+ const res = await fetch(`${baseUrl}/users`);
122
+ expect(res.status).toBe(200);
123
+ expect(res.headers.get('x-global-middleware')).toBe('run');
124
+ });
125
+
126
+ test('should apply class middleware to controller routes configured via forRoutes(Controller)', async () => {
127
+ const res = await fetch(`${baseUrl}/users`);
128
+ expect(res.headers.get('x-logger-middleware')).toBe('run');
129
+ });
130
+
131
+ test('should apply request-scoped middleware to specific route configured with RequestMethod', async () => {
132
+ const prevCount = requestScopedCount;
133
+ const res = await fetch(`${baseUrl}/users`, {
134
+ method: 'POST',
135
+ body: JSON.stringify({}),
136
+ headers: { 'Content-Type': 'application/json' },
137
+ });
138
+ expect(res.status).toBe(201);
139
+ expect(res.headers.get('x-request-scoped-count')).toBeDefined();
140
+ expect(requestScopedCount).toBe(prevCount + 1);
141
+ });
142
+
143
+ test('should short-circuit pipeline and return custom response if middleware does not call next()', async () => {
144
+ const res = await fetch(`${baseUrl}/admin/dashboard`, {
145
+ headers: { 'x-auth-token': 'bad' },
146
+ });
147
+ expect(res.status).toBe(401);
148
+ const body = await res.json();
149
+ expect(body).toEqual({ message: 'Unauthorized Middleware' });
150
+ });
151
+
152
+ test('should continue to handler if short-circuiting middleware passes', async () => {
153
+ const res = await fetch(`${baseUrl}/admin/dashboard`, {
154
+ headers: { 'x-auth-token': 'secret' },
155
+ });
156
+ expect(res.status).toBe(200);
157
+ const body = await res.json();
158
+ expect(body).toEqual({ admin: true });
159
+ });
160
+ });