@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.
- package/CHANGELOG.md +8 -0
- package/package.json +1 -1
- package/src/cli/index.ts +7 -1
- package/src/config/config.module.ts +16 -2
- package/src/config/config.service.ts +20 -6
- package/src/core/container.ts +340 -154
- package/src/core/testing-module.ts +4 -0
- package/src/cqrs/cqrs.ts +93 -4
- package/src/database/sequelize.module.ts +239 -0
- package/src/event-emitter/decorators.ts +2 -2
- package/src/event-emitter/event-emitter.ts +3 -0
- package/src/http/application.ts +135 -6
- package/src/http/decorators.ts +21 -1
- package/src/http/exceptions.ts +97 -0
- package/src/http/factory.ts +3 -0
- package/src/http/router.ts +27 -4
- package/src/index.ts +1 -0
- package/src/microservices/exceptions.ts +10 -0
- package/src/microservices/index.ts +1 -0
- package/src/queue/queue.module.ts +73 -5
- package/src/terminus/terminus.ts +75 -2
- package/src/validation/compiler.ts +133 -10
- package/src/validation/decorators.ts +164 -2
- package/src/websockets/exceptions.ts +10 -0
- package/src/websockets/index.ts +1 -0
- package/tests/circular-di.test.ts +151 -0
- package/tests/di.test.ts +10 -2
- package/tests/nestjs-parity.test.ts +255 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/http/application.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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 ?
|
|
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
|
}
|
package/src/http/decorators.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import { METADATA_KEYS } from '../core/metadata.ts';
|
|
3
3
|
|
|
4
|
-
export
|
|
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
|
|