@martel/calyx 1.12.0 → 1.13.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.
@@ -15,6 +15,10 @@ export class TestingModule extends ModuleRef {
15
15
  return app;
16
16
  }
17
17
 
18
+ createNestApplication(options?: any): CalyxApplication {
19
+ return this.createCalyxApplication();
20
+ }
21
+
18
22
  get<T>(token: InjectionToken, options?: { strict: boolean }): T {
19
23
  const strict = options?.strict ?? false;
20
24
  if (strict) {
package/src/cqrs/cqrs.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Module, Injectable } from '../core/decorators.ts';
2
2
  import { Type } from '../core/metadata.ts';
3
3
  import { ModuleRef } from '../core/module-ref.ts';
4
+ import { Subject, Observable } from 'rxjs';
4
5
 
5
6
  export interface ICommand {}
6
7
  export interface ICommandHandler<TCommand extends ICommand = any, TResult = any> {
@@ -22,6 +23,7 @@ import { METADATA_KEYS } from '../core/metadata.ts';
22
23
  export const COMMAND_HANDLER_METADATA = 'cqrs:command_handler';
23
24
  export const QUERY_HANDLER_METADATA = 'cqrs:query_handler';
24
25
  export const EVENT_HANDLER_METADATA = 'cqrs:event_handler';
26
+ export const SAGA_METADATA = 'cqrs:saga';
25
27
 
26
28
  export const CommandHandler = (command: Type<ICommand>): ClassDecorator => {
27
29
  return (target) => {
@@ -44,10 +46,24 @@ export const EventsHandler = (...events: Type<IEvent>[]): ClassDecorator => {
44
46
  };
45
47
  };
46
48
 
49
+ export function Saga(): PropertyDecorator {
50
+ return (target: any, propertyKey: string | symbol) => {
51
+ const constructor = target.constructor;
52
+ const existing = Reflect.getOwnMetadata(SAGA_METADATA, constructor) || [];
53
+ existing.push(propertyKey);
54
+ Reflect.defineMetadata(SAGA_METADATA, existing, constructor);
55
+ };
56
+ }
57
+
58
+ @Injectable()
59
+ export class UnhandledExceptionBus extends Subject<any> {}
60
+
47
61
  @Injectable()
48
62
  export class CommandBus {
49
63
  private handlers = new Map<any, ICommandHandler>();
50
64
 
65
+ constructor(private readonly exceptionBus: UnhandledExceptionBus) {}
66
+
51
67
  register(command: any, handler: ICommandHandler) {
52
68
  this.handlers.set(command, handler);
53
69
  }
@@ -58,7 +74,12 @@ export class CommandBus {
58
74
  if (!handler) {
59
75
  throw new Error(`CommandHandler not found for command: ${commandClass.name}`);
60
76
  }
61
- return (await handler.execute(command)) as R;
77
+ try {
78
+ return (await handler.execute(command)) as R;
79
+ } catch (err) {
80
+ this.exceptionBus.next({ error: err, command });
81
+ throw err;
82
+ }
62
83
  }
63
84
  }
64
85
 
@@ -66,6 +87,8 @@ export class CommandBus {
66
87
  export class QueryBus {
67
88
  private handlers = new Map<any, IQueryHandler>();
68
89
 
90
+ constructor(private readonly exceptionBus: UnhandledExceptionBus) {}
91
+
69
92
  register(query: any, handler: IQueryHandler) {
70
93
  this.handlers.set(query, handler);
71
94
  }
@@ -76,12 +99,18 @@ export class QueryBus {
76
99
  if (!handler) {
77
100
  throw new Error(`QueryHandler not found for query: ${queryClass.name}`);
78
101
  }
79
- return (await handler.execute(query)) as R;
102
+ try {
103
+ return (await handler.execute(query)) as R;
104
+ } catch (err) {
105
+ this.exceptionBus.next({ error: err, query });
106
+ throw err;
107
+ }
80
108
  }
81
109
  }
82
110
 
83
111
  @Injectable()
84
112
  export class EventBus {
113
+ public readonly subject$ = new Subject<any>();
85
114
  private handlers = new Map<any, IEventHandler[]>();
86
115
 
87
116
  register(event: any, handler: IEventHandler) {
@@ -100,6 +129,7 @@ export class EventBus {
100
129
  console.error(`Error in event handler:`, err);
101
130
  }
102
131
  }
132
+ this.subject$.next(event);
103
133
  }
104
134
  }
105
135
 
@@ -134,9 +164,47 @@ export class EventPublisher {
134
164
  }
135
165
  }
136
166
 
167
+ export class AggregateRoot<EventBase extends IEvent = IEvent> {
168
+ private readonly _events: EventBase[] = [];
169
+ autoCommit = false;
170
+
171
+ publish(event: EventBase) {}
172
+ publishAll(events: EventBase[]) {}
173
+
174
+ commit() {
175
+ this.publishAll(this._events);
176
+ this._events.length = 0;
177
+ }
178
+
179
+ uncommit() {
180
+ this._events.length = 0;
181
+ }
182
+
183
+ getUncommittedEvents(): EventBase[] {
184
+ return this._events;
185
+ }
186
+
187
+ loadFromHistory(history: EventBase[]) {
188
+ history.forEach((event) => this.apply(event, true));
189
+ }
190
+
191
+ apply(event: EventBase, isFromHistory = false) {
192
+ if (!isFromHistory) {
193
+ this._events.push(event);
194
+ }
195
+ const handlerName = `on${event.constructor.name}`;
196
+ if ((this as any)[handlerName]) {
197
+ (this as any)[handlerName](event);
198
+ }
199
+ if (this.autoCommit) {
200
+ this.commit();
201
+ }
202
+ }
203
+ }
204
+
137
205
  @Module({
138
- providers: [CommandBus, QueryBus, EventBus, EventPublisher],
139
- exports: [CommandBus, QueryBus, EventBus, EventPublisher],
206
+ providers: [CommandBus, QueryBus, EventBus, EventPublisher, UnhandledExceptionBus],
207
+ exports: [CommandBus, QueryBus, EventBus, EventPublisher, UnhandledExceptionBus],
140
208
  })
141
209
  export class CqrsModule {
142
210
  constructor(
@@ -170,6 +238,27 @@ export class CqrsModule {
170
238
  this.eventBus.register(event, inst);
171
239
  }
172
240
  }
241
+
242
+ // Check Sagas
243
+ const sagas: (string | symbol)[] = Reflect.getMetadata(SAGA_METADATA, inst.constructor) || [];
244
+ for (const sagaKey of sagas) {
245
+ const sagaFn = inst[sagaKey];
246
+ if (typeof sagaFn === 'function') {
247
+ const commands$ = sagaFn(this.eventBus.subject$);
248
+ if (commands$ && typeof commands$.subscribe === 'function') {
249
+ commands$.subscribe({
250
+ next: (cmd: any) => {
251
+ this.commandBus.execute(cmd).catch((err) => {
252
+ console.error(`Saga error executing command:`, err);
253
+ });
254
+ },
255
+ error: (err: any) => {
256
+ console.error(`Saga stream error:`, err);
257
+ }
258
+ });
259
+ }
260
+ }
261
+ }
173
262
  }
174
263
  }
175
264
  }
@@ -0,0 +1,239 @@
1
+ import { Database } from 'bun:sqlite';
2
+ import { Module, DynamicModule, Inject } from '../core/decorators.ts';
3
+ import { ConnectionManager } from './typeorm.module.ts';
4
+
5
+ let isSequelizeAvailable = false;
6
+ try {
7
+ require.resolve('sequelize');
8
+ isSequelizeAvailable = true;
9
+ } catch {
10
+ // ignore
11
+ }
12
+
13
+ export class Model<T extends Record<string, any> = any> {
14
+ static tableName: string;
15
+ static db: Database;
16
+ static isNative = true;
17
+ static realModel: any = null;
18
+
19
+ constructor(values?: Partial<T>) {
20
+ if (values) {
21
+ Object.assign(this, values);
22
+ }
23
+ }
24
+
25
+ static async findAll(options?: any): Promise<any[]> {
26
+ if (!this.isNative && this.realModel) {
27
+ const results = await this.realModel.findAll(options);
28
+ return results.map((r: any) => {
29
+ const inst = new this();
30
+ Object.assign(inst, r.toJSON ? r.toJSON() : r);
31
+ return inst;
32
+ });
33
+ }
34
+ const tableName = this.tableName || this.name.toLowerCase();
35
+ const db = this.db || ConnectionManager.getOrCreate();
36
+ const rows = db.query(`SELECT id, data FROM ${tableName}`).all() as any[];
37
+ return rows.map((row) => {
38
+ const dataObj = JSON.parse(row.data);
39
+ dataObj.id = row.id;
40
+ const inst = new this();
41
+ Object.assign(inst, dataObj);
42
+ return inst;
43
+ });
44
+ }
45
+
46
+ static async findOne(options?: any): Promise<any | null> {
47
+ const list = await this.findAll(options);
48
+ return list.length > 0 ? list[0] : null;
49
+ }
50
+
51
+ static async findByPk(id: number | string): Promise<any | null> {
52
+ if (!this.isNative && this.realModel) {
53
+ const r = await this.realModel.findByPk(id);
54
+ if (!r) return null;
55
+ const inst = new this();
56
+ Object.assign(inst, r.toJSON ? r.toJSON() : r);
57
+ return inst;
58
+ }
59
+ const tableName = this.tableName || this.name.toLowerCase();
60
+ const db = this.db || ConnectionManager.getOrCreate();
61
+ const row = db.query(`SELECT id, data FROM ${tableName} WHERE id = $id`).get({ $id: Number(id) }) as any;
62
+ if (!row) return null;
63
+ const dataObj = JSON.parse(row.data);
64
+ dataObj.id = row.id;
65
+ const inst = new this();
66
+ Object.assign(inst, dataObj);
67
+ return inst;
68
+ }
69
+
70
+ static async create(values: any): Promise<any> {
71
+ if (!this.isNative && this.realModel) {
72
+ const r = await this.realModel.create(values);
73
+ const inst = new this();
74
+ Object.assign(inst, r.toJSON ? r.toJSON() : r);
75
+ return inst;
76
+ }
77
+ const tableName = this.tableName || this.name.toLowerCase();
78
+ const db = this.db || ConnectionManager.getOrCreate();
79
+ db.run(`
80
+ CREATE TABLE IF NOT EXISTS ${tableName} (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ data TEXT
83
+ )
84
+ `);
85
+ const dataCopy = { ...values };
86
+ delete dataCopy.id;
87
+ const result = db.query(`INSERT INTO ${tableName} (data) VALUES ($data) RETURNING id`).get({
88
+ $data: JSON.stringify(dataCopy)
89
+ }) as any;
90
+ const inst = new this();
91
+ Object.assign(inst, values, { id: result.id });
92
+ return inst;
93
+ }
94
+
95
+ async save(): Promise<this> {
96
+ const modelClass = this.constructor as any;
97
+ if (!modelClass.isNative && modelClass.realModel) {
98
+ const realInst = await modelClass.realModel.build(this);
99
+ await realInst.save();
100
+ Object.assign(this, realInst.toJSON());
101
+ return this;
102
+ }
103
+ const tableName = modelClass.tableName || modelClass.name.toLowerCase();
104
+ const db = modelClass.db || ConnectionManager.getOrCreate();
105
+ const self = this as any;
106
+ const dataCopy = { ...this };
107
+ const id = self.id;
108
+ delete (dataCopy as any).id;
109
+
110
+ if (id !== undefined && id !== null) {
111
+ db.query(`UPDATE ${tableName} SET data = $data WHERE id = $id`).run({
112
+ $data: JSON.stringify(dataCopy),
113
+ $id: id
114
+ });
115
+ } else {
116
+ const result = db.query(`INSERT INTO ${tableName} (data) VALUES ($data) RETURNING id`).get({
117
+ $data: JSON.stringify(dataCopy)
118
+ }) as any;
119
+ self.id = result.id;
120
+ }
121
+ return this;
122
+ }
123
+
124
+ async update(values: any): Promise<this> {
125
+ Object.assign(this, values);
126
+ return await this.save();
127
+ }
128
+
129
+ async destroy(): Promise<void> {
130
+ const modelClass = this.constructor as any;
131
+ if (!modelClass.isNative && modelClass.realModel) {
132
+ const realInst = await modelClass.realModel.findByPk((this as any).id);
133
+ if (realInst) await realInst.destroy();
134
+ return;
135
+ }
136
+ const tableName = modelClass.tableName || modelClass.name.toLowerCase();
137
+ const db = modelClass.db || ConnectionManager.getOrCreate();
138
+ const id = (this as any).id;
139
+ if (id !== undefined && id !== null) {
140
+ db.query(`DELETE FROM ${tableName} WHERE id = $id`).run({ $id: id });
141
+ }
142
+ }
143
+ }
144
+
145
+ export function InjectModel(model: any): ParameterDecorator & PropertyDecorator {
146
+ return Inject(`Sequelize_Model_${model.name}`);
147
+ }
148
+
149
+ export interface SequelizeModuleOptions {
150
+ dialect?: string;
151
+ storage?: string;
152
+ host?: string;
153
+ port?: number;
154
+ username?: string;
155
+ password?: string;
156
+ database?: string;
157
+ models?: any[];
158
+ autoLoadModels?: boolean;
159
+ synchronize?: boolean;
160
+ [key: string]: any;
161
+ }
162
+
163
+ @Module({})
164
+ export class SequelizeModule {
165
+ static forRoot(options: SequelizeModuleOptions = {}): DynamicModule {
166
+ const isUsingSequelize = isSequelizeAvailable && options.dialect !== 'sqlite-native';
167
+
168
+ let seqPromise: Promise<any>;
169
+ if (isUsingSequelize) {
170
+ seqPromise = (async () => {
171
+ const { Sequelize } = await import('sequelize');
172
+ const sequelize = new Sequelize(options as any);
173
+ if (options.models) {
174
+ sequelize.addModels(options.models);
175
+ }
176
+ await sequelize.authenticate();
177
+ if (options.synchronize !== false) {
178
+ await sequelize.sync();
179
+ }
180
+ return sequelize;
181
+ })();
182
+ } else {
183
+ seqPromise = Promise.resolve(ConnectionManager.getOrCreate(options.storage));
184
+ }
185
+
186
+ return {
187
+ module: SequelizeModule,
188
+ providers: [
189
+ {
190
+ provide: 'Calyx_Sequelize_Instance',
191
+ useValue: seqPromise,
192
+ },
193
+ {
194
+ provide: 'Calyx_Sequelize_IsNative',
195
+ useValue: !isUsingSequelize,
196
+ },
197
+ ],
198
+ exports: ['Calyx_Sequelize_Instance', 'Calyx_Sequelize_IsNative'],
199
+ global: true,
200
+ };
201
+ }
202
+
203
+ static forFeature(models: any[] = []): DynamicModule {
204
+ const providers = models.map((model) => {
205
+ return {
206
+ provide: `Sequelize_Model_${model.name}`,
207
+ useFactory: (seqInstancePromise: Promise<any>, isNative: boolean) => {
208
+ if (isNative) {
209
+ const db = ConnectionManager.getOrCreate();
210
+ model.db = db;
211
+ model.tableName = model.name.toLowerCase();
212
+ model.isNative = true;
213
+ db.run(`
214
+ CREATE TABLE IF NOT EXISTS ${model.tableName} (
215
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
216
+ data TEXT
217
+ )
218
+ `);
219
+ return model;
220
+ } else {
221
+ return seqInstancePromise.then((seq) => {
222
+ const realModel = seq.models[model.name];
223
+ model.realModel = realModel;
224
+ model.isNative = false;
225
+ return model;
226
+ });
227
+ }
228
+ },
229
+ inject: ['Calyx_Sequelize_Instance', 'Calyx_Sequelize_IsNative'],
230
+ };
231
+ });
232
+
233
+ return {
234
+ module: SequelizeModule,
235
+ providers,
236
+ exports: models.map((model) => `Sequelize_Model_${model.name}`),
237
+ };
238
+ }
239
+ }
@@ -1,10 +1,10 @@
1
1
  import 'reflect-metadata';
2
2
 
3
- export function OnEvent(event: string): MethodDecorator {
3
+ export function OnEvent(event: string, options?: any): MethodDecorator {
4
4
  return (target, propertyKey) => {
5
5
  const constructor = target.constructor;
6
6
  const existing = Reflect.getOwnMetadata('calyx:on_event', constructor) || [];
7
- existing.push({ event, propertyKey });
7
+ existing.push({ event, propertyKey, options });
8
8
  Reflect.defineMetadata('calyx:on_event', existing, constructor);
9
9
  };
10
10
  }
@@ -59,3 +59,6 @@ export class EventEmitter {
59
59
  return Promise.all(promises);
60
60
  }
61
61
  }
62
+
63
+ export const EventEmitter2 = EventEmitter;
64
+
@@ -18,6 +18,8 @@ import { parseCookies, formatCookie } from '../cookies/cookies.ts';
18
18
  import { StreamableFile } from '../streaming/streamable-file.ts';
19
19
  import { defaultRenderEngine, ViewEngine } from '../mvc/mvc.ts';
20
20
  import { join } from 'path';
21
+ import { CalyxMicroservice } from '../microservices/microservice.ts';
22
+
21
23
 
22
24
  class ObjectPool<T> {
23
25
  private pool: T[] = [];
@@ -158,12 +160,54 @@ export class CalyxApplication {
158
160
  private graphqlQueryCache = new Map<string, any>();
159
161
  private isInitialized = false;
160
162
  private versioningOptions?: VersioningOptions;
163
+ private globalPrefix?: string;
164
+ private globalPrefixOptions?: { exclude?: (string | { path: string; method: string })[] };
165
+ private connectedMicroservices: any[] = [];
166
+ private websocketAdapter: any = null;
161
167
 
162
168
  enableVersioning(options: VersioningOptions) {
163
169
  this.versioningOptions = options;
164
170
  return this;
165
171
  }
166
172
 
173
+ setGlobalPrefix(prefix: string, options?: { exclude?: (string | { path: string; method: string })[] }) {
174
+ this.globalPrefix = prefix;
175
+ this.globalPrefixOptions = options;
176
+ return this;
177
+ }
178
+
179
+ getHttpServer() {
180
+ return this.server;
181
+ }
182
+
183
+ getHttpAdapter() {
184
+ return {
185
+ getInstance: () => this.server,
186
+ getHttpServer: () => this.server,
187
+ };
188
+ }
189
+
190
+ async getUrl(): Promise<string> {
191
+ return `http://localhost:${this.serverPort}`;
192
+ }
193
+
194
+ useWebSocketAdapter(adapter: any) {
195
+ this.websocketAdapter = adapter;
196
+ return this;
197
+ }
198
+
199
+ connectMicroservice(options: any) {
200
+ const microservice = new CalyxMicroservice(this.rootModule, options);
201
+ this.connectedMicroservices.push(microservice);
202
+ return microservice;
203
+ }
204
+
205
+ async startAllMicroservices(): Promise<void> {
206
+ for (const ms of this.connectedMicroservices) {
207
+ await ms.listen();
208
+ }
209
+ }
210
+
167
211
  private compressionEnabled = false;
168
212
 
169
213
  enableCompression() {
@@ -268,6 +312,7 @@ export class CalyxApplication {
268
312
  const isControllerRequestScoped = controllerScope === Scope.REQUEST;
269
313
  const singletonInstance = isControllerRequestScoped ? null : record.instances.get(controllerClass);
270
314
 
315
+ const controllerHost = Reflect.getMetadata('calyx:controller_host', controllerClass);
271
316
  for (const method of methods) {
272
317
  const routeMeta = Reflect.getMetadata(METADATA_KEYS.HTTP_METHOD, controllerClass.prototype, method);
273
318
  if (!routeMeta) continue;
@@ -275,7 +320,13 @@ export class CalyxApplication {
275
320
  // Normalize prefix and path
276
321
  const normalizedPrefix = prefix.replace(/^\/|\/$/g, '');
277
322
  const normalizedPath = routeMeta.path.replace(/^\/|\/$/g, '');
278
- const fullPath = '/' + [normalizedPrefix, normalizedPath].filter(Boolean).join('/');
323
+ const baseRoutePath = '/' + [normalizedPrefix, normalizedPath].filter(Boolean).join('/');
324
+
325
+ let fullPath = baseRoutePath;
326
+ if (this.globalPrefix && !this.isRouteExcludedFromGlobalPrefix(baseRoutePath, routeMeta.method)) {
327
+ const cleanGlobal = this.globalPrefix.replace(/^\/|\/$/g, '');
328
+ fullPath = '/' + [cleanGlobal, normalizedPrefix, normalizedPath].filter(Boolean).join('/');
329
+ }
279
330
 
280
331
  const paramsConfig: ParameterConfig[] = Reflect.getMetadata(METADATA_KEYS.HTTP_PARAMS, controllerClass.prototype, method) || [];
281
332
  // Sort parameters by index ascending so they map correctly to function arguments
@@ -361,7 +412,14 @@ export class CalyxApplication {
361
412
  Reflect.hasMetadata('calyx:sse', methodFn);
362
413
 
363
414
  const insertRoute = (methodStr: string, pathStr: string) => {
364
- this.router.insert(methodStr, pathStr, {
415
+ let finalMethod = methodStr;
416
+ if (controllerHost) {
417
+ const hasParams = controllerHost.includes(':');
418
+ finalMethod = hasParams
419
+ ? `${methodStr}:host-dynamic:${controllerHost}`
420
+ : `${methodStr}:host:${controllerHost}`;
421
+ }
422
+ this.router.insert(finalMethod, pathStr, {
365
423
  controllerClass,
366
424
  moduleClass,
367
425
  instance: singletonInstance,
@@ -388,7 +446,11 @@ export class CalyxApplication {
388
446
  if (this.versioningOptions && this.versioningOptions.type === VersioningType.URI && versions.length > 0) {
389
447
  for (const version of versions) {
390
448
  const versionPrefix = `/v${version}`;
391
- const versionedPath = '/' + [versionPrefix.replace(/^\/|\/$/g, ''), normalizedPrefix, normalizedPath].filter(Boolean).join('/');
449
+ let versionedPath = '/' + [versionPrefix.replace(/^\/|\/$/g, ''), normalizedPrefix, normalizedPath].filter(Boolean).join('/');
450
+ if (this.globalPrefix && !this.isRouteExcludedFromGlobalPrefix(versionedPath, routeMeta.method)) {
451
+ const cleanGlobal = this.globalPrefix.replace(/^\/|\/$/g, '');
452
+ versionedPath = '/' + [cleanGlobal, versionPrefix.replace(/^\/|\/$/g, ''), normalizedPrefix, normalizedPath].filter(Boolean).join('/');
453
+ }
392
454
  insertRoute(routeMeta.method, versionedPath);
393
455
  }
394
456
  } else if (this.versioningOptions && (this.versioningOptions.type === VersioningType.HEADER || this.versioningOptions.type === VersioningType.MEDIA_TYPE) && versions.length > 0) {
@@ -540,12 +602,27 @@ export class CalyxApplication {
540
602
  }
541
603
 
542
604
  let matched = null;
605
+ const hostHeader = req.headers.get('host') || '';
606
+
543
607
  if (this.versioningOptions && this.versioningOptions.type !== VersioningType.URI) {
544
608
  const version = VersionExtractor.extract(req, this.versioningOptions.type, this.versioningOptions);
545
609
  if (version) {
546
- matched = this.router.match(`${req.method}:v${version}`.toUpperCase(), pathname);
610
+ const vMethod = `${req.method}:v${version}`.toUpperCase();
611
+ matched = this.router.match(`${vMethod}:host:${hostHeader}`.toUpperCase(), pathname);
612
+ if (!matched) {
613
+ matched = this.matchDynamicHost(vMethod, hostHeader, pathname);
614
+ }
615
+ if (!matched) {
616
+ matched = this.router.match(vMethod, pathname);
617
+ }
547
618
  }
548
619
  }
620
+ if (!matched) {
621
+ matched = this.router.match(`${req.method}:host:${hostHeader}`.toUpperCase(), pathname);
622
+ }
623
+ if (!matched) {
624
+ matched = this.matchDynamicHost(req.method.toUpperCase(), hostHeader, pathname);
625
+ }
549
626
  if (!matched) {
550
627
  matched = this.router.match(req.method, pathname);
551
628
  }
@@ -723,7 +800,7 @@ export class CalyxApplication {
723
800
  case 'headers': val = config.name ? req.headers.get(config.name) : Object.fromEntries(req.headers.entries()); break;
724
801
  case 'session': val = (req as any).session; break;
725
802
  case 'ip': val = this.server ? (this.server.requestIP(req)?.address ?? req.headers.get('x-forwarded-for') ?? '') : (req.headers.get('x-forwarded-for') ?? ''); break;
726
- case 'hostparam': val = config.name ? req.headers.get('host') : req.headers.get('host'); break;
803
+ case 'hostparam': val = config.name ? params[config.name] : params; break;
727
804
  case 'next': val = next; break;
728
805
  case 'custom': val = config.factory ? config.factory(config.name, context) : undefined; break;
729
806
  }
@@ -986,7 +1063,7 @@ export class CalyxApplication {
986
1063
  val = this.server ? (this.server.requestIP(req)?.address ?? req.headers.get('x-forwarded-for') ?? '') : (req.headers.get('x-forwarded-for') ?? '');
987
1064
  break;
988
1065
  case 'hostparam':
989
- val = config.name ? req.headers.get('host') : req.headers.get('host');
1066
+ val = config.name ? params[config.name] : params;
990
1067
  break;
991
1068
  case 'next':
992
1069
  val = () => {};
@@ -1904,6 +1981,58 @@ export class CalyxApplication {
1904
1981
  }
1905
1982
  }
1906
1983
 
1984
+ private isRouteExcludedFromGlobalPrefix(path: string, method: string): boolean {
1985
+ if (!this.globalPrefixOptions?.exclude) return false;
1986
+ const cleanPath = path.replace(/^\/|\/$/g, '');
1987
+ for (const exclusion of this.globalPrefixOptions.exclude) {
1988
+ if (typeof exclusion === 'string') {
1989
+ if (exclusion.replace(/^\/|\/$/g, '') === cleanPath) return true;
1990
+ } else {
1991
+ const cleanExclPath = exclusion.path.replace(/^\/|\/$/g, '');
1992
+ const exclMethod = String(exclusion.method).toUpperCase();
1993
+ if (cleanExclPath === cleanPath && (exclMethod === 'ALL' || exclMethod === method.toUpperCase())) {
1994
+ return true;
1995
+ }
1996
+ }
1997
+ }
1998
+ return false;
1999
+ }
2000
+
2001
+ private matchDynamicHost(method: string, hostHeader: string, pathname: string): any {
2002
+ const routes = this.router.getRoutes();
2003
+ for (const route of routes) {
2004
+ if (route.method.startsWith(method + ':host-dynamic:')) {
2005
+ const hostPattern = route.method.substring((method + ':host-dynamic:').length);
2006
+ const params = this.matchHostPattern(hostPattern, hostHeader);
2007
+ if (params) {
2008
+ const matched = this.router.match(route.method, pathname);
2009
+ if (matched) {
2010
+ matched.params = { ...matched.params, ...params };
2011
+ return matched;
2012
+ }
2013
+ }
2014
+ }
2015
+ }
2016
+ return null;
2017
+ }
2018
+
2019
+ private matchHostPattern(pattern: string, host: string): Record<string, string> | null {
2020
+ const patternSegments = pattern.split('.');
2021
+ const hostSegments = host.split('.');
2022
+ if (patternSegments.length !== hostSegments.length) return null;
2023
+ const params: Record<string, string> = {};
2024
+ for (let i = 0; i < patternSegments.length; i++) {
2025
+ const pSeg = patternSegments[i];
2026
+ const hSeg = hostSegments[i];
2027
+ if (pSeg.startsWith(':')) {
2028
+ params[pSeg.slice(1)] = hSeg;
2029
+ } else if (pSeg !== hSeg) {
2030
+ return null;
2031
+ }
2032
+ }
2033
+ return params;
2034
+ }
2035
+
1907
2036
  getRoutes() {
1908
2037
  return this.router.getRoutes();
1909
2038
  }
@@ -1,9 +1,29 @@
1
1
  import 'reflect-metadata';
2
2
  import { METADATA_KEYS } from '../core/metadata.ts';
3
3
 
4
- export function Controller(prefix = ''): ClassDecorator {
4
+ export interface ControllerOptions {
5
+ path?: string;
6
+ host?: string;
7
+ scope?: any;
8
+ }
9
+
10
+ export function Controller(prefixOrOptions?: string | ControllerOptions): ClassDecorator {
5
11
  return (target) => {
12
+ let prefix = '';
13
+ let host: string | undefined = undefined;
14
+ if (typeof prefixOrOptions === 'string') {
15
+ prefix = prefixOrOptions;
16
+ } else if (prefixOrOptions && typeof prefixOrOptions === 'object') {
17
+ prefix = prefixOrOptions.path ?? '';
18
+ host = prefixOrOptions.host;
19
+ if (prefixOrOptions.scope) {
20
+ Reflect.defineMetadata(METADATA_KEYS.SCOPE, prefixOrOptions.scope, target);
21
+ }
22
+ }
6
23
  Reflect.defineMetadata(METADATA_KEYS.CONTROLLER, prefix, target);
24
+ if (host !== undefined) {
25
+ Reflect.defineMetadata('calyx:controller_host', host, target);
26
+ }
7
27
  };
8
28
  }
9
29