@martel/calyx 0.1.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/.github/workflows/release.yml +39 -0
- package/.releaserc.json +27 -0
- package/benchmarks/di-benchmark.ts +114 -0
- package/benchmarks/http-benchmark.ts +103 -0
- package/benchmarks/lifecycle-benchmark.ts +102 -0
- package/benchmarks/run-calyx-lifecycle.ts +58 -0
- package/benchmarks/run-calyx.ts +23 -0
- package/benchmarks/run-nest-lifecycle.ts +60 -0
- package/benchmarks/run-nest.ts +24 -0
- package/docs/controllers.md +122 -0
- package/docs/dependency-injection.md +112 -0
- package/docs/lifecycle.md +168 -0
- package/docs/migration.md +108 -0
- package/package.json +31 -0
- package/src/core/container.ts +387 -0
- package/src/core/decorators.ts +45 -0
- package/src/core/index.ts +4 -0
- package/src/core/metadata.ts +61 -0
- package/src/core/module-ref.ts +5 -0
- package/src/http/application.ts +588 -0
- package/src/http/decorators.ts +103 -0
- package/src/http/exceptions.ts +47 -0
- package/src/http/factory.ts +8 -0
- package/src/http/index.ts +5 -0
- package/src/http/router.ts +97 -0
- package/src/index.ts +4 -0
- package/src/lifecycle/context.ts +41 -0
- package/src/lifecycle/decorators.ts +37 -0
- package/src/lifecycle/index.ts +3 -0
- package/src/lifecycle/interfaces.ts +49 -0
- package/tests/di.test.ts +283 -0
- package/tests/dynamic-module.test.ts +53 -0
- package/tests/lifecycle.test.ts +169 -0
- package/tests/routing.test.ts +155 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { calyxContainer } from '../core/container.ts';
|
|
2
|
+
import { METADATA_KEYS } from '../core/metadata.ts';
|
|
3
|
+
import { RadixRouter } from './router.ts';
|
|
4
|
+
import { ParameterConfig } from './decorators.ts';
|
|
5
|
+
import { HttpException, NotFoundException } from './exceptions.ts';
|
|
6
|
+
import { ArgumentsHost, ExecutionContext } from '../lifecycle/interfaces.ts';
|
|
7
|
+
import { calyxArgumentsHost, calyxExecutionContext } from '../lifecycle/context.ts';
|
|
8
|
+
|
|
9
|
+
export class calyxResponse {
|
|
10
|
+
statusCode = 200;
|
|
11
|
+
headers = new Headers();
|
|
12
|
+
body: any = null;
|
|
13
|
+
sent = false;
|
|
14
|
+
|
|
15
|
+
status(code: number) {
|
|
16
|
+
this.statusCode = code;
|
|
17
|
+
return this;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
send(body: any) {
|
|
21
|
+
this.body = body;
|
|
22
|
+
this.sent = true;
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
json(body: any) {
|
|
27
|
+
this.headers.set('content-type', 'application/json');
|
|
28
|
+
this.body = JSON.stringify(body);
|
|
29
|
+
this.sent = true;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
set(name: string, value: string) {
|
|
34
|
+
this.headers.set(name, value);
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface HandlerConfig {
|
|
40
|
+
instance: any;
|
|
41
|
+
methodName: string;
|
|
42
|
+
paramsConfig: ParameterConfig[];
|
|
43
|
+
httpCode?: number;
|
|
44
|
+
headers: { name: string; value: string }[];
|
|
45
|
+
redirect?: { url: string; statusCode?: number };
|
|
46
|
+
hasBodyParam: boolean;
|
|
47
|
+
hasQueryParam: boolean;
|
|
48
|
+
hasResParam: boolean;
|
|
49
|
+
hasLifecycleMetadata: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class calyxApplication {
|
|
53
|
+
private container = new calyxContainer();
|
|
54
|
+
private router = new RadixRouter<HandlerConfig>();
|
|
55
|
+
private server: any;
|
|
56
|
+
|
|
57
|
+
private globalGuards: any[] = [];
|
|
58
|
+
private globalInterceptors: any[] = [];
|
|
59
|
+
private globalPipes: any[] = [];
|
|
60
|
+
private globalFilters: any[] = [];
|
|
61
|
+
|
|
62
|
+
useGlobalGuards(...guards: any[]) {
|
|
63
|
+
this.globalGuards.push(...guards);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
useGlobalInterceptors(...interceptors: any[]) {
|
|
67
|
+
this.globalInterceptors.push(...interceptors);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
useGlobalPipes(...pipes: any[]) {
|
|
71
|
+
this.globalPipes.push(...pipes);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
useGlobalFilters(...filters: any[]) {
|
|
75
|
+
this.globalFilters.push(...filters);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
constructor(private rootModule: any) {}
|
|
79
|
+
|
|
80
|
+
async init() {
|
|
81
|
+
// Bootstrap the dependency injection container
|
|
82
|
+
this.container.bootstrap(this.rootModule);
|
|
83
|
+
|
|
84
|
+
// Build the routing table from registered controllers
|
|
85
|
+
this.buildRoutes();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private buildRoutes() {
|
|
89
|
+
const modules = this.container.getModules();
|
|
90
|
+
for (const [moduleClass, record] of modules.entries()) {
|
|
91
|
+
for (const controllerClass of record.controllers) {
|
|
92
|
+
const prefix = Reflect.getMetadata(METADATA_KEYS.CONTROLLER, controllerClass) ?? '';
|
|
93
|
+
const instance = record.instances.get(controllerClass);
|
|
94
|
+
if (!instance) continue;
|
|
95
|
+
|
|
96
|
+
const methods = this.getMethods(controllerClass.prototype);
|
|
97
|
+
for (const method of methods) {
|
|
98
|
+
const routeMeta = Reflect.getMetadata(METADATA_KEYS.HTTP_METHOD, controllerClass.prototype, method);
|
|
99
|
+
if (!routeMeta) continue;
|
|
100
|
+
|
|
101
|
+
// Normalize prefix and path
|
|
102
|
+
const normalizedPrefix = prefix.replace(/^\/|\/$/g, '');
|
|
103
|
+
const normalizedPath = routeMeta.path.replace(/^\/|\/$/g, '');
|
|
104
|
+
const fullPath = '/' + [normalizedPrefix, normalizedPath].filter(Boolean).join('/');
|
|
105
|
+
|
|
106
|
+
const paramsConfig: ParameterConfig[] = Reflect.getMetadata(METADATA_KEYS.HTTP_PARAMS, controllerClass.prototype, method) || [];
|
|
107
|
+
// Sort parameters by index ascending so they map correctly to function arguments
|
|
108
|
+
paramsConfig.sort((a, b) => a.index - b.index);
|
|
109
|
+
|
|
110
|
+
const httpCode = Reflect.getMetadata(METADATA_KEYS.HTTP_CODE, controllerClass.prototype, method);
|
|
111
|
+
const headers = Reflect.getMetadata(METADATA_KEYS.HTTP_HEADERS, controllerClass.prototype, method) || [];
|
|
112
|
+
const redirect = Reflect.getMetadata(METADATA_KEYS.REDIRECT, controllerClass.prototype, method);
|
|
113
|
+
|
|
114
|
+
const hasBodyParam = paramsConfig.some((p) => p.type === 'body');
|
|
115
|
+
const hasQueryParam = paramsConfig.some((p) => p.type === 'query');
|
|
116
|
+
const hasResParam = paramsConfig.some((p) => p.type === 'res');
|
|
117
|
+
|
|
118
|
+
const hasLifecycleMetadata =
|
|
119
|
+
Reflect.hasMetadata(METADATA_KEYS.GUARDS, controllerClass) ||
|
|
120
|
+
Reflect.hasMetadata(METADATA_KEYS.GUARDS, controllerClass.prototype, method) ||
|
|
121
|
+
Reflect.hasMetadata(METADATA_KEYS.INTERCEPTORS, controllerClass) ||
|
|
122
|
+
Reflect.hasMetadata(METADATA_KEYS.INTERCEPTORS, controllerClass.prototype, method) ||
|
|
123
|
+
Reflect.hasMetadata(METADATA_KEYS.PIPES, controllerClass) ||
|
|
124
|
+
Reflect.hasMetadata(METADATA_KEYS.PIPES, controllerClass.prototype, method) ||
|
|
125
|
+
Reflect.hasMetadata(METADATA_KEYS.FILTERS, controllerClass) ||
|
|
126
|
+
Reflect.hasMetadata(METADATA_KEYS.FILTERS, controllerClass.prototype, method) ||
|
|
127
|
+
paramsConfig.some((p) => p.pipes && p.pipes.length > 0);
|
|
128
|
+
|
|
129
|
+
this.router.insert(routeMeta.method, fullPath, {
|
|
130
|
+
instance,
|
|
131
|
+
methodName: method,
|
|
132
|
+
paramsConfig,
|
|
133
|
+
httpCode,
|
|
134
|
+
headers,
|
|
135
|
+
redirect,
|
|
136
|
+
hasBodyParam,
|
|
137
|
+
hasQueryParam,
|
|
138
|
+
hasResParam,
|
|
139
|
+
hasLifecycleMetadata,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private getMethods(obj: any): string[] {
|
|
147
|
+
const properties = new Set<string>();
|
|
148
|
+
let currentObj = obj;
|
|
149
|
+
while (currentObj && currentObj !== Object.prototype) {
|
|
150
|
+
Object.getOwnPropertyNames(currentObj).forEach((item) => properties.add(item));
|
|
151
|
+
currentObj = Object.getPrototypeOf(currentObj);
|
|
152
|
+
}
|
|
153
|
+
return Array.from(properties).filter((item) => typeof obj[item] === 'function' && item !== 'constructor');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private hasGlobalLifecycle(): boolean {
|
|
157
|
+
return (
|
|
158
|
+
this.globalGuards.length > 0 ||
|
|
159
|
+
this.globalInterceptors.length > 0 ||
|
|
160
|
+
this.globalPipes.length > 0 ||
|
|
161
|
+
this.globalFilters.length > 0
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private handleRequest(req: Request): Response | Promise<Response> {
|
|
166
|
+
try {
|
|
167
|
+
const urlStr = req.url;
|
|
168
|
+
// Fast path parsing
|
|
169
|
+
let pathname = '/';
|
|
170
|
+
let search = '';
|
|
171
|
+
const protoIdx = urlStr.indexOf('://');
|
|
172
|
+
if (protoIdx !== -1) {
|
|
173
|
+
const slashIdx = urlStr.indexOf('/', protoIdx + 3);
|
|
174
|
+
if (slashIdx !== -1) {
|
|
175
|
+
pathname = urlStr.substring(slashIdx);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const queryIdx = pathname.indexOf('?');
|
|
179
|
+
if (queryIdx !== -1) {
|
|
180
|
+
search = pathname.substring(queryIdx);
|
|
181
|
+
pathname = pathname.substring(0, queryIdx);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const matched = this.router.match(req.method, pathname);
|
|
185
|
+
if (!matched) {
|
|
186
|
+
throw new NotFoundException(`Cannot ${req.method} ${pathname}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const { handler, params } = matched;
|
|
190
|
+
|
|
191
|
+
// Check if we need to run lifecycle hooks
|
|
192
|
+
const hasLifecycle = handler.hasLifecycleMetadata || this.hasGlobalLifecycle();
|
|
193
|
+
if (hasLifecycle) {
|
|
194
|
+
if (handler.hasBodyParam && req.body && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
195
|
+
return this.handleRequestPipelineAsync(req, handler, params, search);
|
|
196
|
+
}
|
|
197
|
+
return this.handleRequestPipeline(req, handler, params, search, null);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Check if we need to parse body (which makes it async)
|
|
201
|
+
if (handler.hasBodyParam && req.body && req.method !== 'GET' && req.method !== 'HEAD') {
|
|
202
|
+
return this.handleRequestAsync(req, handler, params, search);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return this.executeHandlerSync(req, handler, params, search, null);
|
|
206
|
+
} catch (err: any) {
|
|
207
|
+
return this.handleError(err);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private async handleRequestPipelineAsync(
|
|
212
|
+
req: Request,
|
|
213
|
+
handler: HandlerConfig,
|
|
214
|
+
params: Record<string, string>,
|
|
215
|
+
search: string
|
|
216
|
+
): Promise<Response> {
|
|
217
|
+
try {
|
|
218
|
+
let body: any = null;
|
|
219
|
+
const contentType = req.headers.get('content-type') || '';
|
|
220
|
+
if (contentType.includes('application/json')) {
|
|
221
|
+
try {
|
|
222
|
+
body = await req.json();
|
|
223
|
+
} catch {
|
|
224
|
+
body = {};
|
|
225
|
+
}
|
|
226
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
227
|
+
try {
|
|
228
|
+
const text = await req.text();
|
|
229
|
+
body = Object.fromEntries(new URLSearchParams(text).entries());
|
|
230
|
+
} catch {
|
|
231
|
+
body = {};
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
try {
|
|
235
|
+
body = await req.text();
|
|
236
|
+
} catch {
|
|
237
|
+
body = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return this.handleRequestPipeline(req, handler, params, search, body);
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
const resWrapper = new calyxResponse();
|
|
244
|
+
const host = new calyxArgumentsHost(req, resWrapper);
|
|
245
|
+
return this.handleLifecycleError(err, host, handler.instance.constructor, handler.methodName);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private async handleRequestPipeline(
|
|
250
|
+
req: Request,
|
|
251
|
+
handler: HandlerConfig,
|
|
252
|
+
params: Record<string, string>,
|
|
253
|
+
search: string,
|
|
254
|
+
body: any
|
|
255
|
+
): Promise<Response> {
|
|
256
|
+
const { instance, methodName, paramsConfig, httpCode, headers, redirect, hasQueryParam, hasResParam } = handler;
|
|
257
|
+
const controllerClass = instance.constructor;
|
|
258
|
+
const moduleClass = this.getControllerModule(controllerClass);
|
|
259
|
+
const resWrapper = new calyxResponse();
|
|
260
|
+
const host = new calyxArgumentsHost(req, resWrapper);
|
|
261
|
+
const context = new calyxExecutionContext(req, resWrapper, controllerClass, instance[methodName]);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
// 1. Run Guards
|
|
265
|
+
const classGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, controllerClass) || [];
|
|
266
|
+
const methodGuards = Reflect.getMetadata(METADATA_KEYS.GUARDS, controllerClass.prototype, methodName) || [];
|
|
267
|
+
const guards = [...this.globalGuards, ...classGuards, ...methodGuards];
|
|
268
|
+
|
|
269
|
+
for (const guard of guards) {
|
|
270
|
+
const guardInstance = this.resolveLifecycleInstance(moduleClass, guard);
|
|
271
|
+
const canActivate = await guardInstance.canActivate(context);
|
|
272
|
+
if (!canActivate) {
|
|
273
|
+
throw new HttpException('Forbidden resource', 403);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// 2. Run Interceptors & Pipes & Handler
|
|
278
|
+
const classInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, controllerClass) || [];
|
|
279
|
+
const methodInterceptors = Reflect.getMetadata(METADATA_KEYS.INTERCEPTORS, controllerClass.prototype, methodName) || [];
|
|
280
|
+
const interceptors = [...this.globalInterceptors, ...classInterceptors, ...methodInterceptors];
|
|
281
|
+
|
|
282
|
+
let query: any = null;
|
|
283
|
+
if (hasQueryParam && search) {
|
|
284
|
+
query = Object.fromEntries(new URLSearchParams(search).entries());
|
|
285
|
+
} else if (hasQueryParam) {
|
|
286
|
+
query = {};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const paramTypes = Reflect.getMetadata('design:paramtypes', controllerClass.prototype, methodName) || [];
|
|
290
|
+
|
|
291
|
+
// Execute pipes for each argument
|
|
292
|
+
const args: any[] = [];
|
|
293
|
+
for (const config of paramsConfig) {
|
|
294
|
+
let val: any;
|
|
295
|
+
switch (config.type) {
|
|
296
|
+
case 'req':
|
|
297
|
+
val = req;
|
|
298
|
+
break;
|
|
299
|
+
case 'res':
|
|
300
|
+
val = resWrapper;
|
|
301
|
+
break;
|
|
302
|
+
case 'param':
|
|
303
|
+
val = config.name ? params[config.name] : params;
|
|
304
|
+
break;
|
|
305
|
+
case 'query':
|
|
306
|
+
val = config.name ? query?.[config.name] : query;
|
|
307
|
+
break;
|
|
308
|
+
case 'body':
|
|
309
|
+
val = config.name ? body?.[config.name] : body;
|
|
310
|
+
break;
|
|
311
|
+
case 'headers':
|
|
312
|
+
val = config.name ? req.headers.get(config.name) : Object.fromEntries(req.headers.entries());
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Apply pipes
|
|
317
|
+
const classPipes = Reflect.getMetadata(METADATA_KEYS.PIPES, controllerClass) || [];
|
|
318
|
+
const methodPipes = Reflect.getMetadata(METADATA_KEYS.PIPES, controllerClass.prototype, methodName) || [];
|
|
319
|
+
const paramPipes = config.pipes || [];
|
|
320
|
+
const pipes = [...this.globalPipes, ...classPipes, ...methodPipes, ...paramPipes];
|
|
321
|
+
|
|
322
|
+
for (const pipe of pipes) {
|
|
323
|
+
const pipeInstance = this.resolveLifecycleInstance(moduleClass, pipe);
|
|
324
|
+
val = await pipeInstance.transform(val, {
|
|
325
|
+
type: config.type,
|
|
326
|
+
metatype: paramTypes[config.index],
|
|
327
|
+
data: config.name,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
args[config.index] = val;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let interceptorIndex = 0;
|
|
335
|
+
const executeHandler = async (): Promise<any> => {
|
|
336
|
+
if (interceptorIndex < interceptors.length) {
|
|
337
|
+
const interceptor = interceptors[interceptorIndex++];
|
|
338
|
+
const interceptorInstance = this.resolveLifecycleInstance(moduleClass, interceptor);
|
|
339
|
+
return interceptorInstance.intercept(context, { handle: () => executeHandler() });
|
|
340
|
+
}
|
|
341
|
+
return instance[methodName](...args);
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const result = await executeHandler();
|
|
345
|
+
|
|
346
|
+
return this.processResult(req, result, resWrapper, httpCode, headers, redirect);
|
|
347
|
+
} catch (err: any) {
|
|
348
|
+
return this.handleLifecycleError(err, host, controllerClass, methodName);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
private async handleLifecycleError(
|
|
353
|
+
err: any,
|
|
354
|
+
host: ArgumentsHost,
|
|
355
|
+
controllerClass: any,
|
|
356
|
+
methodName: string
|
|
357
|
+
): Promise<Response> {
|
|
358
|
+
const classFilters = Reflect.getMetadata(METADATA_KEYS.FILTERS, controllerClass) || [];
|
|
359
|
+
const methodFilters = Reflect.getMetadata(METADATA_KEYS.FILTERS, controllerClass.prototype, methodName) || [];
|
|
360
|
+
const filters = [...this.globalFilters, ...classFilters, ...methodFilters];
|
|
361
|
+
|
|
362
|
+
const moduleClass = this.getControllerModule(controllerClass);
|
|
363
|
+
|
|
364
|
+
for (const filter of filters) {
|
|
365
|
+
const filterInstance = this.resolveLifecycleInstance(moduleClass, filter);
|
|
366
|
+
const catchExceptions = Reflect.getMetadata(METADATA_KEYS.CATCH, filterInstance.constructor) || [];
|
|
367
|
+
|
|
368
|
+
const catchesException =
|
|
369
|
+
catchExceptions.length === 0 ||
|
|
370
|
+
catchExceptions.some((excClass: any) => err instanceof excClass);
|
|
371
|
+
|
|
372
|
+
if (catchesException) {
|
|
373
|
+
await filterInstance.catch(err, host);
|
|
374
|
+
const resWrapper = host.switchToHttp().getResponse<calyxResponse>();
|
|
375
|
+
if (resWrapper.sent) {
|
|
376
|
+
return new Response(resWrapper.body, {
|
|
377
|
+
status: resWrapper.statusCode,
|
|
378
|
+
headers: resWrapper.headers,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return this.handleError(err);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
private getControllerModule(controllerClass: any): any {
|
|
388
|
+
const modules = this.container.getModules();
|
|
389
|
+
for (const [moduleClass, record] of modules.entries()) {
|
|
390
|
+
if (record.controllers.has(controllerClass)) {
|
|
391
|
+
return moduleClass;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
private resolveLifecycleInstance(moduleClass: any, item: any): any {
|
|
398
|
+
if (typeof item === 'function') {
|
|
399
|
+
try {
|
|
400
|
+
return this.container.resolveTokenInModuleContext(moduleClass, item);
|
|
401
|
+
} catch {
|
|
402
|
+
return new item();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return item;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private async handleRequestAsync(
|
|
409
|
+
req: Request,
|
|
410
|
+
handler: HandlerConfig,
|
|
411
|
+
params: Record<string, string>,
|
|
412
|
+
search: string
|
|
413
|
+
): Promise<Response> {
|
|
414
|
+
try {
|
|
415
|
+
let body: any = null;
|
|
416
|
+
const contentType = req.headers.get('content-type') || '';
|
|
417
|
+
if (contentType.includes('application/json')) {
|
|
418
|
+
try {
|
|
419
|
+
body = await req.json();
|
|
420
|
+
} catch {
|
|
421
|
+
body = {};
|
|
422
|
+
}
|
|
423
|
+
} else if (contentType.includes('application/x-www-form-urlencoded')) {
|
|
424
|
+
try {
|
|
425
|
+
const text = await req.text();
|
|
426
|
+
body = Object.fromEntries(new URLSearchParams(text).entries());
|
|
427
|
+
} catch {
|
|
428
|
+
body = {};
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
try {
|
|
432
|
+
body = await req.text();
|
|
433
|
+
} catch {
|
|
434
|
+
body = null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return this.executeHandlerSync(req, handler, params, search, body);
|
|
439
|
+
} catch (err: any) {
|
|
440
|
+
return this.handleError(err);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private executeHandlerSync(
|
|
445
|
+
req: Request,
|
|
446
|
+
handler: HandlerConfig,
|
|
447
|
+
params: Record<string, string>,
|
|
448
|
+
search: string,
|
|
449
|
+
body: any
|
|
450
|
+
): Response | Promise<Response> {
|
|
451
|
+
const { instance, methodName, paramsConfig, httpCode, headers, redirect, hasQueryParam, hasResParam } = handler;
|
|
452
|
+
|
|
453
|
+
// Parse query on-demand
|
|
454
|
+
let query: any = null;
|
|
455
|
+
if (hasQueryParam && search) {
|
|
456
|
+
query = Object.fromEntries(new URLSearchParams(search).entries());
|
|
457
|
+
} else if (hasQueryParam) {
|
|
458
|
+
query = {};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const resWrapper = hasResParam ? new calyxResponse() : null;
|
|
462
|
+
const args: any[] = [];
|
|
463
|
+
|
|
464
|
+
for (const config of paramsConfig) {
|
|
465
|
+
let val: any;
|
|
466
|
+
switch (config.type) {
|
|
467
|
+
case 'req':
|
|
468
|
+
val = req;
|
|
469
|
+
break;
|
|
470
|
+
case 'res':
|
|
471
|
+
val = resWrapper;
|
|
472
|
+
break;
|
|
473
|
+
case 'param':
|
|
474
|
+
val = config.name ? params[config.name] : params;
|
|
475
|
+
break;
|
|
476
|
+
case 'query':
|
|
477
|
+
val = config.name ? query?.[config.name] : query;
|
|
478
|
+
break;
|
|
479
|
+
case 'body':
|
|
480
|
+
val = config.name ? body?.[config.name] : body;
|
|
481
|
+
break;
|
|
482
|
+
case 'headers':
|
|
483
|
+
val = config.name ? req.headers.get(config.name) : Object.fromEntries(req.headers.entries());
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
args[config.index] = val;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const result = instance[methodName](...args);
|
|
490
|
+
|
|
491
|
+
if (result instanceof Promise) {
|
|
492
|
+
return result.then((resolvedResult) => this.processResult(req, resolvedResult, resWrapper, httpCode, headers, redirect));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return this.processResult(req, result, resWrapper, httpCode, headers, redirect);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private processResult(
|
|
499
|
+
req: Request,
|
|
500
|
+
result: any,
|
|
501
|
+
resWrapper: calyxResponse | null,
|
|
502
|
+
httpCode: number | undefined,
|
|
503
|
+
headers: { name: string; value: string }[],
|
|
504
|
+
redirect: { url: string; statusCode?: number } | undefined
|
|
505
|
+
): Response {
|
|
506
|
+
if (redirect) {
|
|
507
|
+
return Response.redirect(redirect.url, redirect.statusCode || 302);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (resWrapper?.sent) {
|
|
511
|
+
return new Response(resWrapper.body, {
|
|
512
|
+
status: resWrapper.statusCode,
|
|
513
|
+
headers: resWrapper.headers,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const status = httpCode ?? (req.method === 'POST' ? 201 : 200);
|
|
518
|
+
|
|
519
|
+
// Build headers only if we have custom headers or content-type requirements
|
|
520
|
+
let responseHeaders: Headers | undefined = undefined;
|
|
521
|
+
if (headers.length > 0) {
|
|
522
|
+
responseHeaders = new Headers();
|
|
523
|
+
for (const header of headers) {
|
|
524
|
+
responseHeaders.set(header.name, header.value);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (result === undefined || result === null) {
|
|
529
|
+
return new Response(null, { status, headers: responseHeaders });
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (result instanceof Response) {
|
|
533
|
+
return result;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (typeof result === 'object') {
|
|
537
|
+
if (responseHeaders) {
|
|
538
|
+
responseHeaders.set('content-type', 'application/json');
|
|
539
|
+
return new Response(JSON.stringify(result), { status, headers: responseHeaders });
|
|
540
|
+
}
|
|
541
|
+
return Response.json(result, { status });
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
if (responseHeaders) {
|
|
545
|
+
return new Response(String(result), { status, headers: responseHeaders });
|
|
546
|
+
}
|
|
547
|
+
return new Response(String(result), { status });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
private handleError(err: any): Response {
|
|
551
|
+
if (err instanceof HttpException) {
|
|
552
|
+
const response = err.getResponse();
|
|
553
|
+
const status = err.getStatus();
|
|
554
|
+
const body = typeof response === 'object' ? JSON.stringify(response) : JSON.stringify({ statusCode: status, message: response });
|
|
555
|
+
return new Response(body, {
|
|
556
|
+
status,
|
|
557
|
+
headers: { 'content-type': 'application/json' },
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
console.error(err);
|
|
562
|
+
return new Response(
|
|
563
|
+
JSON.stringify({
|
|
564
|
+
statusCode: 500,
|
|
565
|
+
message: 'Internal server error',
|
|
566
|
+
}),
|
|
567
|
+
{
|
|
568
|
+
status: 500,
|
|
569
|
+
headers: { 'content-type': 'application/json' },
|
|
570
|
+
}
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
async listen(port: number): Promise<any> {
|
|
575
|
+
await this.init();
|
|
576
|
+
this.server = Bun.serve({
|
|
577
|
+
port,
|
|
578
|
+
fetch: (req) => this.handleRequest(req),
|
|
579
|
+
});
|
|
580
|
+
return this.server;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async close() {
|
|
584
|
+
if (this.server) {
|
|
585
|
+
this.server.stop();
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { METADATA_KEYS } from '../core/metadata.ts';
|
|
3
|
+
|
|
4
|
+
export function Controller(prefix = ''): ClassDecorator {
|
|
5
|
+
return (target) => {
|
|
6
|
+
Reflect.defineMetadata(METADATA_KEYS.CONTROLLER, prefix, target);
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createRouteDecorator(method: string) {
|
|
11
|
+
return (path = ''): MethodDecorator => {
|
|
12
|
+
return (target, propertyKey) => {
|
|
13
|
+
Reflect.defineMetadata(METADATA_KEYS.HTTP_METHOD, { method, path }, target, propertyKey);
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const Get = createRouteDecorator('GET');
|
|
19
|
+
export const Post = createRouteDecorator('POST');
|
|
20
|
+
export const Put = createRouteDecorator('PUT');
|
|
21
|
+
export const Delete = createRouteDecorator('DELETE');
|
|
22
|
+
export const Patch = createRouteDecorator('PATCH');
|
|
23
|
+
export const Options = createRouteDecorator('OPTIONS');
|
|
24
|
+
export const Head = createRouteDecorator('HEAD');
|
|
25
|
+
export const All = createRouteDecorator('ALL');
|
|
26
|
+
|
|
27
|
+
export interface ParameterConfig {
|
|
28
|
+
index: number;
|
|
29
|
+
type: 'req' | 'res' | 'param' | 'query' | 'body' | 'headers';
|
|
30
|
+
name?: string;
|
|
31
|
+
pipes?: any[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createParamDecorator(type: ParameterConfig['type'], name?: string, pipes: any[] = []): ParameterDecorator {
|
|
35
|
+
return (target, propertyKey, parameterIndex) => {
|
|
36
|
+
if (!propertyKey) return;
|
|
37
|
+
const existingParams: ParameterConfig[] =
|
|
38
|
+
Reflect.getOwnMetadata(METADATA_KEYS.HTTP_PARAMS, target, propertyKey) || [];
|
|
39
|
+
existingParams.push({ index: parameterIndex, type, name, pipes });
|
|
40
|
+
Reflect.defineMetadata(METADATA_KEYS.HTTP_PARAMS, existingParams, target, propertyKey);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseParamArgs(first?: any, ...rest: any[]) {
|
|
45
|
+
let name: string | undefined = undefined;
|
|
46
|
+
let pipes: any[] = [];
|
|
47
|
+
|
|
48
|
+
if (typeof first === 'string') {
|
|
49
|
+
name = first;
|
|
50
|
+
pipes = rest;
|
|
51
|
+
} else if (first !== undefined) {
|
|
52
|
+
pipes = [first, ...rest];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { name, pipes };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const Req = () => createParamDecorator('req');
|
|
59
|
+
export const Request = Req;
|
|
60
|
+
|
|
61
|
+
export const Res = () => createParamDecorator('res');
|
|
62
|
+
export const Response = Res;
|
|
63
|
+
|
|
64
|
+
export const Param = (first?: any, ...pipes: any[]) => {
|
|
65
|
+
const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
|
|
66
|
+
return createParamDecorator('param', name, parsedPipes);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const Query = (first?: any, ...pipes: any[]) => {
|
|
70
|
+
const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
|
|
71
|
+
return createParamDecorator('query', name, parsedPipes);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const Body = (first?: any, ...pipes: any[]) => {
|
|
75
|
+
const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
|
|
76
|
+
return createParamDecorator('body', name, parsedPipes);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export const Headers = (first?: any, ...pipes: any[]) => {
|
|
80
|
+
const { name, pipes: parsedPipes } = parseParamArgs(first, ...pipes);
|
|
81
|
+
return createParamDecorator('headers', name, parsedPipes);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export function HttpCode(code: number): MethodDecorator {
|
|
85
|
+
return (target, propertyKey) => {
|
|
86
|
+
Reflect.defineMetadata(METADATA_KEYS.HTTP_CODE, code, target, propertyKey);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function Header(name: string, value: string): MethodDecorator {
|
|
91
|
+
return (target, propertyKey) => {
|
|
92
|
+
const existingHeaders: { name: string; value: string }[] =
|
|
93
|
+
Reflect.getOwnMetadata(METADATA_KEYS.HTTP_HEADERS, target, propertyKey) || [];
|
|
94
|
+
existingHeaders.push({ name, value });
|
|
95
|
+
Reflect.defineMetadata(METADATA_KEYS.HTTP_HEADERS, existingHeaders, target, propertyKey);
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function Redirect(url: string, statusCode = 302): MethodDecorator {
|
|
100
|
+
return (target, propertyKey) => {
|
|
101
|
+
Reflect.defineMetadata(METADATA_KEYS.REDIRECT, { url, statusCode }, target, propertyKey);
|
|
102
|
+
};
|
|
103
|
+
}
|