@martel/calyx 0.1.0 → 1.0.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.
@@ -26,12 +26,13 @@ export const All = createRouteDecorator('ALL');
26
26
 
27
27
  export interface ParameterConfig {
28
28
  index: number;
29
- type: 'req' | 'res' | 'param' | 'query' | 'body' | 'headers';
29
+ type: 'req' | 'res' | 'param' | 'query' | 'body' | 'headers' | 'custom';
30
30
  name?: string;
31
31
  pipes?: any[];
32
+ factory?: (data: any, ctx: any) => any;
32
33
  }
33
34
 
34
- function createParamDecorator(type: ParameterConfig['type'], name?: string, pipes: any[] = []): ParameterDecorator {
35
+ function createHttpParamDecorator(type: ParameterConfig['type'], name?: string, pipes: any[] = []): ParameterDecorator {
35
36
  return (target, propertyKey, parameterIndex) => {
36
37
  if (!propertyKey) return;
37
38
  const existingParams: ParameterConfig[] =
@@ -55,30 +56,30 @@ function parseParamArgs(first?: any, ...rest: any[]) {
55
56
  return { name, pipes };
56
57
  }
57
58
 
58
- export const Req = () => createParamDecorator('req');
59
+ export const Req = () => createHttpParamDecorator('req');
59
60
  export const Request = Req;
60
61
 
61
- export const Res = () => createParamDecorator('res');
62
+ export const Res = (options?: { passthrough: boolean }) => createHttpParamDecorator('res', options?.passthrough ? 'passthrough' : undefined);
62
63
  export const Response = Res;
63
64
 
64
65
  export const Param = (first?: any, ...pipes: any[]) => {
65
66
  const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
66
- return createParamDecorator('param', name, parsedPipes);
67
+ return createHttpParamDecorator('param', name, parsedPipes);
67
68
  };
68
69
 
69
70
  export const Query = (first?: any, ...pipes: any[]) => {
70
71
  const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
71
- return createParamDecorator('query', name, parsedPipes);
72
+ return createHttpParamDecorator('query', name, parsedPipes);
72
73
  };
73
74
 
74
75
  export const Body = (first?: any, ...pipes: any[]) => {
75
76
  const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
76
- return createParamDecorator('body', name, parsedPipes);
77
+ return createHttpParamDecorator('body', name, parsedPipes);
77
78
  };
78
79
 
79
80
  export const Headers = (first?: any, ...pipes: any[]) => {
80
81
  const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
81
- return createParamDecorator('headers', name, parsedPipes);
82
+ return createHttpParamDecorator('headers', name, parsedPipes);
82
83
  };
83
84
 
84
85
  export function HttpCode(code: number): MethodDecorator {
@@ -101,3 +102,23 @@ export function Redirect(url: string, statusCode = 302): MethodDecorator {
101
102
  Reflect.defineMetadata(METADATA_KEYS.REDIRECT, { url, statusCode }, target, propertyKey);
102
103
  };
103
104
  }
105
+
106
+ export function createParamDecorator<Data = any, Return = any>(
107
+ factory: (data: Data, ctx: any) => Return
108
+ ): (data?: Data, ...pipes: any[]) => ParameterDecorator {
109
+ return (data?: Data, ...pipes: any[]): ParameterDecorator => {
110
+ return (target, propertyKey, parameterIndex) => {
111
+ if (!propertyKey) return;
112
+ const existingParams: ParameterConfig[] =
113
+ Reflect.getOwnMetadata(METADATA_KEYS.HTTP_PARAMS, target, propertyKey) || [];
114
+ existingParams.push({
115
+ index: parameterIndex,
116
+ type: 'custom',
117
+ name: data as any,
118
+ pipes,
119
+ factory,
120
+ });
121
+ Reflect.defineMetadata(METADATA_KEYS.HTTP_PARAMS, existingParams, target, propertyKey);
122
+ };
123
+ };
124
+ }
@@ -1,8 +1,8 @@
1
- import { calyxApplication } from './application.ts';
1
+ import { CalyxApplication } from './application.ts';
2
2
 
3
- export class calyxFactory {
4
- static async create(rootModule: any): Promise<calyxApplication> {
5
- const app = new calyxApplication(rootModule);
3
+ export class CalyxFactory {
4
+ static async create(rootModule: any): Promise<CalyxApplication> {
5
+ const app = new CalyxApplication(rootModule);
6
6
  return app;
7
7
  }
8
8
  }
@@ -13,8 +13,14 @@ class RouterNode<T> {
13
13
 
14
14
  export class RadixRouter<T> {
15
15
  private root = new RouterNode<T>();
16
+ private staticRoutes = new Map<string, T>();
16
17
 
17
18
  insert(method: string, path: string, handler: T) {
19
+ const hasParams = path.includes(':') || path.includes('*');
20
+ if (!hasParams) {
21
+ this.staticRoutes.set(method.toUpperCase() + ' ' + path, handler);
22
+ }
23
+
18
24
  const segments = path.split('/').filter(Boolean);
19
25
  let node = this.root;
20
26
 
@@ -46,6 +52,12 @@ export class RadixRouter<T> {
46
52
  }
47
53
 
48
54
  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: {} };
59
+ }
60
+
49
61
  const segments = path.split('/').filter(Boolean);
50
62
  const params: Record<string, string> = {};
51
63
  const matchNode = this.matchSegment(this.root, segments, 0, params);
@@ -1,7 +1,7 @@
1
1
  import { ArgumentsHost, HttpArgumentsHost, ExecutionContext } from './interfaces.ts';
2
2
  import { Type } from '../core/metadata.ts';
3
3
 
4
- export class calyxArgumentsHost implements ArgumentsHost {
4
+ export class CalyxArgumentsHost implements ArgumentsHost {
5
5
  constructor(private readonly req: Request, private readonly res: any) {}
6
6
 
7
7
  getArgs<T extends any[] = any[]>(): T {
@@ -21,7 +21,7 @@ export class calyxArgumentsHost implements ArgumentsHost {
21
21
  }
22
22
  }
23
23
 
24
- export class calyxExecutionContext extends calyxArgumentsHost implements ExecutionContext {
24
+ export class CalyxExecutionContext extends CalyxArgumentsHost implements ExecutionContext {
25
25
  constructor(
26
26
  req: Request,
27
27
  res: any,
@@ -47,3 +47,23 @@ export interface PipeTransform<T = any, R = any> {
47
47
  export interface ExceptionFilter<T = any> {
48
48
  catch(exception: T, host: ArgumentsHost): any;
49
49
  }
50
+
51
+ export interface OnModuleInit {
52
+ onModuleInit(): any | Promise<any>;
53
+ }
54
+
55
+ export interface OnApplicationBootstrap {
56
+ onApplicationBootstrap(): any | Promise<any>;
57
+ }
58
+
59
+ export interface OnModuleDestroy {
60
+ onModuleDestroy(): any | Promise<any>;
61
+ }
62
+
63
+ export interface BeforeApplicationShutdown {
64
+ beforeApplicationShutdown(signal?: string): any | Promise<any>;
65
+ }
66
+
67
+ export interface OnApplicationShutdown {
68
+ onApplicationShutdown(signal?: string): any | Promise<any>;
69
+ }
package/tests/di.test.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  Optional,
7
7
  Global,
8
8
  forwardRef,
9
- calyxContainer,
9
+ CalyxContainer,
10
10
  ModuleRef,
11
11
  } from '../src/index.ts';
12
12
 
@@ -29,7 +29,7 @@ describe('Dependency Injection System', () => {
29
29
  })
30
30
  class RootModule {}
31
31
 
32
- const container = new calyxContainer();
32
+ const container = new CalyxContainer();
33
33
  container.bootstrap(RootModule);
34
34
 
35
35
  const target = container.getGlobalOrAnyInstance(TargetService);
@@ -57,7 +57,7 @@ describe('Dependency Injection System', () => {
57
57
  })
58
58
  class RootModule {}
59
59
 
60
- const container = new calyxContainer();
60
+ const container = new CalyxContainer();
61
61
  container.bootstrap(RootModule);
62
62
 
63
63
  const consumer = container.getGlobalOrAnyInstance(ConfigConsumer);
@@ -94,7 +94,7 @@ describe('Dependency Injection System', () => {
94
94
  })
95
95
  class RootModule {}
96
96
 
97
- const container = new calyxContainer();
97
+ const container = new CalyxContainer();
98
98
  container.bootstrap(RootModule);
99
99
 
100
100
  const consumer = container.getGlobalOrAnyInstance(Consumer);
@@ -127,7 +127,7 @@ describe('Dependency Injection System', () => {
127
127
  })
128
128
  class RootModule {}
129
129
 
130
- const container = new calyxContainer();
130
+ const container = new CalyxContainer();
131
131
  container.bootstrap(RootModule);
132
132
 
133
133
  const consumer = container.getGlobalOrAnyInstance(Consumer);
@@ -145,7 +145,7 @@ describe('Dependency Injection System', () => {
145
145
  })
146
146
  class RootModule {}
147
147
 
148
- const container = new calyxContainer();
148
+ const container = new CalyxContainer();
149
149
  container.bootstrap(RootModule);
150
150
 
151
151
  const consumer = container.getGlobalOrAnyInstance(Consumer);
@@ -163,7 +163,7 @@ describe('Dependency Injection System', () => {
163
163
  })
164
164
  class RootModule {}
165
165
 
166
- const container = new calyxContainer();
166
+ const container = new CalyxContainer();
167
167
  expect(() => container.bootstrap(RootModule)).toThrow(/Cannot resolve dependency/);
168
168
  });
169
169
 
@@ -183,7 +183,7 @@ describe('Dependency Injection System', () => {
183
183
  })
184
184
  class RootModule {}
185
185
 
186
- const container = new calyxContainer();
186
+ const container = new CalyxContainer();
187
187
  expect(() => container.bootstrap(RootModule)).toThrow(/Circular dependency detected/);
188
188
  });
189
189
 
@@ -210,7 +210,7 @@ describe('Dependency Injection System', () => {
210
210
  })
211
211
  class AppModule {}
212
212
 
213
- const container = new calyxContainer();
213
+ const container = new CalyxContainer();
214
214
  container.bootstrap(AppModule);
215
215
 
216
216
  const appService = container.getGlobalOrAnyInstance(AppService);
@@ -243,7 +243,7 @@ describe('Dependency Injection System', () => {
243
243
  })
244
244
  class AppModule {}
245
245
 
246
- const container = new calyxContainer();
246
+ const container = new CalyxContainer();
247
247
  container.bootstrap(AppModule);
248
248
 
249
249
  const appService = container.getGlobalOrAnyInstance(AppService);
@@ -272,7 +272,7 @@ describe('Dependency Injection System', () => {
272
272
  })
273
273
  class RootModule {}
274
274
 
275
- const container = new calyxContainer();
275
+ const container = new CalyxContainer();
276
276
  container.bootstrap(RootModule);
277
277
 
278
278
  const consumer = container.getGlobalOrAnyInstance(ConsumerService);
@@ -3,7 +3,7 @@ import {
3
3
  Module,
4
4
  Injectable,
5
5
  Inject,
6
- calyxContainer,
6
+ CalyxContainer,
7
7
  DynamicModule,
8
8
  } from '../src/index.ts';
9
9
 
@@ -42,7 +42,7 @@ class AppModule {}
42
42
 
43
43
  describe('Dynamic Modules', () => {
44
44
  test('should resolve providers defined dynamically in a DynamicModule', () => {
45
- const container = new calyxContainer();
45
+ const container = new CalyxContainer();
46
46
  container.bootstrap(AppModule);
47
47
 
48
48
  const appService = container.getGlobalOrAnyInstance(AppService);
@@ -18,11 +18,11 @@ import {
18
18
  ExceptionFilter,
19
19
  ArgumentsHost,
20
20
  ExecutionContext,
21
- calyxFactory,
21
+ CalyxFactory,
22
22
  HttpException,
23
23
  ForbiddenException,
24
24
  BadRequestException,
25
- calyxResponse,
25
+ CalyxResponse,
26
26
  } from '../src/index.ts';
27
27
 
28
28
  // 1. Guard
@@ -67,7 +67,7 @@ class CustomError extends Error {
67
67
  @Catch(CustomError)
68
68
  class CustomExceptionFilter implements ExceptionFilter {
69
69
  catch(exception: CustomError, host: ArgumentsHost) {
70
- const res = host.switchToHttp().getResponse<calyxResponse>();
70
+ const res = host.switchToHttp().getResponse<CalyxResponse>();
71
71
  res.status(418).json({
72
72
  customFilter: true,
73
73
  errorMessage: exception.message,
@@ -112,7 +112,7 @@ describe('Request Lifecycle Pipeline (Guards, Interceptors, Pipes, Filters)', ()
112
112
  const PORT = 3848;
113
113
 
114
114
  beforeAll(async () => {
115
- app = await calyxFactory.create(AppModule);
115
+ app = await CalyxFactory.create(AppModule);
116
116
  await app.listen(PORT);
117
117
  baseUrl = `http://localhost:${PORT}`;
118
118
  });
@@ -0,0 +1,143 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import {
3
+ Module,
4
+ Injectable,
5
+ Inject,
6
+ Controller,
7
+ Get,
8
+ CalyxContainer,
9
+ CalyxFactory,
10
+ createParamDecorator,
11
+ ExecutionContext,
12
+ OnModuleInit,
13
+ OnApplicationBootstrap,
14
+ OnModuleDestroy,
15
+ BeforeApplicationShutdown,
16
+ OnApplicationShutdown,
17
+ } from '../src/index.ts';
18
+
19
+ describe('Phase 1: Extended DI & Lifecycle Features', () => {
20
+
21
+ // 1. Property Injection Test
22
+ test('should resolve property-level injections', () => {
23
+ const VALUE_TOKEN = 'VALUE_TOKEN';
24
+
25
+ @Injectable()
26
+ class ValueService {
27
+ constructor() {}
28
+ getData() { return 'property_value'; }
29
+ }
30
+
31
+ @Injectable()
32
+ class ConsumerService {
33
+ @Inject(VALUE_TOKEN)
34
+ public customVal!: string;
35
+
36
+ @Inject()
37
+ public service!: ValueService;
38
+ }
39
+
40
+ @Module({
41
+ providers: [
42
+ ConsumerService,
43
+ ValueService,
44
+ {
45
+ provide: VALUE_TOKEN,
46
+ useValue: 'injected_string',
47
+ },
48
+ ],
49
+ })
50
+ class RootModule {}
51
+
52
+ const container = new CalyxContainer();
53
+ container.bootstrap(RootModule);
54
+
55
+ const consumer = container.getGlobalOrAnyInstance(ConsumerService);
56
+ expect(consumer.customVal).toBe('injected_string');
57
+ expect(consumer.service).toBeInstanceOf(ValueService);
58
+ expect(consumer.service.getData()).toBe('property_value');
59
+ });
60
+
61
+ // 2. Custom Param Decorators Test
62
+ test('should support custom route parameter decorators', async () => {
63
+ const CustomUser = createParamDecorator((data: string, ctx: ExecutionContext) => {
64
+ const req = ctx.switchToHttp().getRequest<Request>();
65
+ const customHeader = req.headers.get('x-user-id');
66
+ return {
67
+ id: customHeader,
68
+ role: data,
69
+ };
70
+ });
71
+
72
+ @Controller('test-custom-param')
73
+ class TestController {
74
+ @Get()
75
+ getUser(@CustomUser('admin') user: any) {
76
+ return user;
77
+ }
78
+ }
79
+
80
+ @Module({
81
+ controllers: [TestController],
82
+ })
83
+ class RootModule {}
84
+
85
+ const app = await CalyxFactory.create(RootModule);
86
+ await app.listen(3990);
87
+
88
+ try {
89
+ const res = await fetch('http://localhost:3990/test-custom-param', {
90
+ headers: { 'x-user-id': 'user_123' },
91
+ });
92
+ expect(res.status).toBe(200);
93
+ const body = await res.json();
94
+ expect(body).toEqual({ id: 'user_123', role: 'admin' });
95
+ } finally {
96
+ await app.close();
97
+ }
98
+ });
99
+
100
+ // 3. Lifecycle Hooks Test
101
+ test('should run all lifecycle hooks in order', async () => {
102
+ const hookSequence: string[] = [];
103
+
104
+ @Injectable()
105
+ class LifecycleService implements OnModuleInit, OnApplicationBootstrap, OnModuleDestroy, BeforeApplicationShutdown, OnApplicationShutdown {
106
+ onModuleInit() {
107
+ hookSequence.push('init');
108
+ }
109
+ onApplicationBootstrap() {
110
+ hookSequence.push('bootstrap');
111
+ }
112
+ onModuleDestroy() {
113
+ hookSequence.push('destroy');
114
+ }
115
+ beforeApplicationShutdown(signal?: string) {
116
+ hookSequence.push(`beforeShutdown_${signal}`);
117
+ }
118
+ onApplicationShutdown(signal?: string) {
119
+ hookSequence.push(`shutdown_${signal}`);
120
+ }
121
+ }
122
+
123
+ @Module({
124
+ providers: [LifecycleService],
125
+ })
126
+ class RootModule {}
127
+
128
+ const app = await CalyxFactory.create(RootModule);
129
+ await app.listen(3991);
130
+
131
+ expect(hookSequence).toEqual(['init', 'bootstrap']);
132
+
133
+ await app.close('SIGTERM');
134
+
135
+ expect(hookSequence).toEqual([
136
+ 'init',
137
+ 'bootstrap',
138
+ 'destroy',
139
+ 'beforeShutdown_SIGTERM',
140
+ 'shutdown_SIGTERM'
141
+ ]);
142
+ });
143
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { of, delay } from 'rxjs';
3
+ import {
4
+ Module,
5
+ Controller,
6
+ Get,
7
+ Res,
8
+ CalyxFactory,
9
+ CalyxResponse,
10
+ } from '../src/index.ts';
11
+
12
+ describe('Phase 2: Execution Context, RxJS, and Passthrough Response', () => {
13
+
14
+ // 1. RxJS Observable Test
15
+ test('should automatically subscribe and resolve RxJS Observables', async () => {
16
+ @Controller('rxjs')
17
+ class RxjsController {
18
+ @Get('sync')
19
+ getSync() {
20
+ return of({ value: 'hello_rxjs' });
21
+ }
22
+
23
+ @Get('async')
24
+ getAsync() {
25
+ // Return an observable that resolves after 10ms
26
+ return of({ value: 'hello_async_rxjs' }).pipe(delay(10));
27
+ }
28
+ }
29
+
30
+ @Module({
31
+ controllers: [RxjsController],
32
+ })
33
+ class RootModule {}
34
+
35
+ const app = await CalyxFactory.create(RootModule);
36
+ await app.listen(3992);
37
+
38
+ try {
39
+ // Test sync observable
40
+ const resSync = await fetch('http://localhost:3992/rxjs/sync');
41
+ expect(resSync.status).toBe(200);
42
+ expect(await resSync.json()).toEqual({ value: 'hello_rxjs' });
43
+
44
+ // Test async observable
45
+ const resAsync = await fetch('http://localhost:3992/rxjs/async');
46
+ expect(resAsync.status).toBe(200);
47
+ expect(await resAsync.json()).toEqual({ value: 'hello_async_rxjs' });
48
+ } finally {
49
+ await app.close();
50
+ }
51
+ });
52
+
53
+ // 2. Passthrough Response vs Standard Response
54
+ test('should differentiate between passthrough and standard @Res injection', async () => {
55
+ @Controller('res-test')
56
+ class ResController {
57
+ @Get('standard')
58
+ getStandard(@Res() res: CalyxResponse) {
59
+ res.status(202).json({ manual: true });
60
+ // The return value should be ignored
61
+ return { ignored: true };
62
+ }
63
+
64
+ @Get('standard-norender')
65
+ getStandardNoRender(@Res() res: CalyxResponse) {
66
+ // User does not call res.send or res.json, return is ignored, returns empty
67
+ return { ignored: true };
68
+ }
69
+
70
+ @Get('passthrough')
71
+ getPassthrough(@Res({ passthrough: true }) res: CalyxResponse) {
72
+ res.status(206);
73
+ res.set('x-custom-pass', 'yes');
74
+ // Return value should be automatically serialized and sent
75
+ return { automatic: true };
76
+ }
77
+ }
78
+
79
+ @Module({
80
+ controllers: [ResController],
81
+ })
82
+ class RootModule {}
83
+
84
+ const app = await CalyxFactory.create(RootModule);
85
+ await app.listen(3993);
86
+
87
+ try {
88
+ // Standard @Res - manual response
89
+ const resStd = await fetch('http://localhost:3993/res-test/standard');
90
+ expect(resStd.status).toBe(202);
91
+ expect(await resStd.json()).toEqual({ manual: true });
92
+
93
+ // Standard @Res without render - ignored return value
94
+ const resNoRender = await fetch('http://localhost:3993/res-test/standard-norender');
95
+ expect(resNoRender.status).toBe(200);
96
+ expect(await resNoRender.text()).toBe('');
97
+
98
+ // Passthrough @Res - sets code/headers, serializes return value
99
+ const resPass = await fetch('http://localhost:3993/res-test/passthrough');
100
+ expect(resPass.status).toBe(206);
101
+ expect(resPass.headers.get('x-custom-pass')).toBe('yes');
102
+ expect(await resPass.json()).toEqual({ automatic: true });
103
+ } finally {
104
+ await app.close();
105
+ }
106
+ });
107
+ });