@martel/calyx 1.7.0 → 1.9.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 +15 -0
- package/README.md +71 -27
- package/benchmarks/graphql-benchmark.ts +81 -0
- package/benchmarks/index.ts +32 -0
- package/benchmarks/openapi-benchmark.ts +168 -0
- package/benchmarks/serialization-benchmark.ts +52 -0
- package/benchmarks/techniques-benchmark.ts +84 -0
- package/benchmarks/validation-benchmark.ts +74 -0
- package/bun.lock +14 -0
- package/package.json +8 -6
- package/src/cli/index.ts +19 -3
- package/src/compression/compression.middleware.ts +7 -0
- package/src/cookies/cookies.ts +69 -0
- package/src/database/mongoose.module.ts +250 -0
- package/src/database/typeorm.module.ts +276 -0
- package/src/file-upload/file-upload.interceptor.ts +93 -0
- package/src/file-upload/index.ts +1 -0
- package/src/graphql/decorators.ts +132 -0
- package/src/graphql/graphql.module.ts +316 -0
- package/src/graphql/index.ts +2 -0
- package/src/http/application.ts +380 -70
- package/src/http/factory.ts +1 -0
- package/src/http/router.ts +13 -0
- package/src/http-client/http-client.module.ts +124 -0
- package/src/http-client/index.ts +1 -0
- package/src/index.ts +15 -0
- package/src/logger/index.ts +1 -0
- package/src/logger/logger.service.ts +118 -0
- package/src/mvc/index.ts +1 -0
- package/src/mvc/mvc.ts +22 -0
- package/src/openapi/decorators.ts +203 -0
- package/src/openapi/index.ts +2 -0
- package/src/openapi/swagger.module.ts +326 -0
- package/src/queue/queue.module.ts +174 -0
- package/src/session/index.ts +1 -0
- package/src/session/session.middleware.ts +82 -0
- package/src/sse/index.ts +1 -0
- package/src/sse/sse.ts +18 -0
- package/src/streaming/index.ts +1 -0
- package/src/streaming/streamable-file.ts +32 -0
- package/src/validation/pipe.ts +79 -10
- package/src/versioning/versioning.ts +46 -0
- package/tests/graphql.test.ts +176 -0
- package/tests/openapi.test.ts +162 -0
- package/tests/techniques.test.ts +471 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { CalyxApplication } from '../http/application.ts';
|
|
2
|
+
|
|
3
|
+
export class DocumentBuilder {
|
|
4
|
+
private document: any = {
|
|
5
|
+
openapi: '3.0.0',
|
|
6
|
+
info: {
|
|
7
|
+
title: 'Calyx Application',
|
|
8
|
+
version: '1.0.0',
|
|
9
|
+
description: '',
|
|
10
|
+
},
|
|
11
|
+
paths: {},
|
|
12
|
+
components: {
|
|
13
|
+
schemas: {},
|
|
14
|
+
securitySchemes: {},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
setTitle(title: string) {
|
|
19
|
+
this.document.info.title = title;
|
|
20
|
+
return this;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
setVersion(version: string) {
|
|
24
|
+
this.document.info.version = version;
|
|
25
|
+
return this;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setDescription(description: string) {
|
|
29
|
+
this.document.info.description = description;
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
addBearerAuth(options: any = { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, name = 'bearer') {
|
|
34
|
+
this.document.components.securitySchemes[name] = options;
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
addBasicAuth(options: any = { type: 'http', scheme: 'basic' }, name = 'basic') {
|
|
39
|
+
this.document.components.securitySchemes[name] = options;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
addOAuth2(options: any = { type: 'oauth2', flows: {} }, name = 'oauth2') {
|
|
44
|
+
this.document.components.securitySchemes[name] = options;
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
addSecurity(name: string, scheme: any) {
|
|
49
|
+
this.document.components.securitySchemes[name] = scheme;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
build() {
|
|
54
|
+
return this.document;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class SwaggerModule {
|
|
59
|
+
static createDocument(app: CalyxApplication, config: any): any {
|
|
60
|
+
const document = { ...config };
|
|
61
|
+
if (!document.paths) document.paths = {};
|
|
62
|
+
if (!document.components) document.components = {};
|
|
63
|
+
if (!document.components.schemas) document.components.schemas = {};
|
|
64
|
+
if (!document.components.securitySchemes) {
|
|
65
|
+
document.components.securitySchemes = config.components?.securitySchemes || {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const routes = app.getRoutes();
|
|
69
|
+
|
|
70
|
+
function registerSchema(typeClass: any) {
|
|
71
|
+
if (!typeClass || typeof typeClass !== 'function') return;
|
|
72
|
+
const schemaName = typeClass.name;
|
|
73
|
+
if (document.components.schemas[schemaName]) return;
|
|
74
|
+
|
|
75
|
+
const props = Reflect.getMetadata('calyx:api_properties', typeClass) || [];
|
|
76
|
+
const schemaProps: Record<string, any> = {};
|
|
77
|
+
const requiredProps: string[] = [];
|
|
78
|
+
|
|
79
|
+
for (const p of props) {
|
|
80
|
+
let pType = 'string';
|
|
81
|
+
if (p.type) {
|
|
82
|
+
pType = p.type.name ? p.type.name.toLowerCase() : String(p.type).toLowerCase();
|
|
83
|
+
}
|
|
84
|
+
schemaProps[p.propertyKey] = {
|
|
85
|
+
type: pType === 'number' || pType === 'boolean' || pType === 'object' || pType === 'array' ? pType : 'string',
|
|
86
|
+
description: p.description,
|
|
87
|
+
};
|
|
88
|
+
if (p.required) {
|
|
89
|
+
requiredProps.push(p.propertyKey);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
document.components.schemas[schemaName] = {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: schemaProps,
|
|
96
|
+
...(requiredProps.length > 0 ? { required: requiredProps } : {}),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const route of routes) {
|
|
101
|
+
const { method, path, handler } = route;
|
|
102
|
+
|
|
103
|
+
const swaggerPath = path.replace(/:([a-zA-Z0-9_]+)/g, '{$1}');
|
|
104
|
+
|
|
105
|
+
if (!document.paths[swaggerPath]) {
|
|
106
|
+
document.paths[swaggerPath] = {};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const operationMeta =
|
|
110
|
+
Reflect.getMetadata('calyx:api_operation', handler.controllerClass.prototype, handler.methodName) || {};
|
|
111
|
+
const tags =
|
|
112
|
+
Reflect.getMetadata('calyx:api_tags', handler.controllerClass.prototype, handler.methodName) ||
|
|
113
|
+
Reflect.getMetadata('calyx:api_tags', handler.controllerClass) ||
|
|
114
|
+
[];
|
|
115
|
+
const responsesMeta =
|
|
116
|
+
Reflect.getMetadata('calyx:api_responses', handler.controllerClass.prototype, handler.methodName) || [];
|
|
117
|
+
const securityMeta =
|
|
118
|
+
Reflect.getMetadata('calyx:api_security', handler.controllerClass.prototype, handler.methodName) ||
|
|
119
|
+
Reflect.getMetadata('calyx:api_security', handler.controllerClass) ||
|
|
120
|
+
[];
|
|
121
|
+
|
|
122
|
+
// Extra Models
|
|
123
|
+
const extraModels = Reflect.getMetadata('calyx:api_extra_models', handler.controllerClass) || [];
|
|
124
|
+
for (const model of extraModels) {
|
|
125
|
+
registerSchema(model);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const parameters: any[] = [];
|
|
129
|
+
|
|
130
|
+
// Parse Path Params (Regex match)
|
|
131
|
+
const pathParams = [...path.matchAll(/:([a-zA-Z0-9_]+)/g)].map((m) => m[1]);
|
|
132
|
+
const pathParamDecorators = Reflect.getMetadata('calyx:api_params', handler.controllerClass.prototype, handler.methodName) || [];
|
|
133
|
+
|
|
134
|
+
for (const name of pathParams) {
|
|
135
|
+
const dec = pathParamDecorators.find((d: any) => d.name === name) || {};
|
|
136
|
+
parameters.push({
|
|
137
|
+
name,
|
|
138
|
+
in: 'path',
|
|
139
|
+
required: true,
|
|
140
|
+
description: dec.description || '',
|
|
141
|
+
schema: {
|
|
142
|
+
type: dec.type ? dec.type.name.toLowerCase() : 'string',
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Parse Http Parameter Decorators (@Query, @Headers, etc.)
|
|
148
|
+
const httpParams: any[] = Reflect.getMetadata('calyx:http_params', handler.controllerClass.prototype, handler.methodName) || [];
|
|
149
|
+
const paramTypes = Reflect.getMetadata('design:paramtypes', handler.controllerClass.prototype, handler.methodName) || [];
|
|
150
|
+
const apiQueries = Reflect.getMetadata('calyx:api_queries', handler.controllerClass.prototype, handler.methodName) || [];
|
|
151
|
+
const apiHeaders = Reflect.getMetadata('calyx:api_headers', handler.controllerClass.prototype, handler.methodName) || [];
|
|
152
|
+
let requestBody: any = undefined;
|
|
153
|
+
|
|
154
|
+
for (const param of httpParams) {
|
|
155
|
+
const paramType = paramTypes[param.index];
|
|
156
|
+
|
|
157
|
+
if (param.type === 'query') {
|
|
158
|
+
const qName = param.name;
|
|
159
|
+
if (qName) {
|
|
160
|
+
const dec = apiQueries.find((q: any) => q.name === qName) || {};
|
|
161
|
+
parameters.push({
|
|
162
|
+
name: qName,
|
|
163
|
+
in: 'query',
|
|
164
|
+
required: dec.required ?? false,
|
|
165
|
+
description: dec.description || '',
|
|
166
|
+
schema: {
|
|
167
|
+
type: dec.type ? dec.type.name.toLowerCase() : (paramType ? paramType.name.toLowerCase() : 'string'),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
} else if (param.type === 'headers') {
|
|
172
|
+
const hName = param.name;
|
|
173
|
+
if (hName) {
|
|
174
|
+
const dec = apiHeaders.find((h: any) => h.name === hName) || {};
|
|
175
|
+
parameters.push({
|
|
176
|
+
name: hName,
|
|
177
|
+
in: 'header',
|
|
178
|
+
required: dec.required ?? false,
|
|
179
|
+
description: dec.description || '',
|
|
180
|
+
schema: {
|
|
181
|
+
type: 'string',
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
} else if (param.type === 'body') {
|
|
186
|
+
const bodyDec = Reflect.getMetadata('calyx:api_body', handler.controllerClass.prototype, handler.methodName) || {};
|
|
187
|
+
const targetType = bodyDec.type || paramType;
|
|
188
|
+
|
|
189
|
+
if (targetType) {
|
|
190
|
+
registerSchema(targetType);
|
|
191
|
+
requestBody = {
|
|
192
|
+
description: bodyDec.description || '',
|
|
193
|
+
required: bodyDec.required ?? true,
|
|
194
|
+
content: {
|
|
195
|
+
'application/json': {
|
|
196
|
+
schema: {
|
|
197
|
+
$ref: `#/components/schemas/${targetType.name}`,
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Add manual @ApiQuery annotations if they weren't matched to method params
|
|
207
|
+
for (const q of apiQueries) {
|
|
208
|
+
if (!parameters.some((p) => p.in === 'query' && p.name === q.name)) {
|
|
209
|
+
parameters.push({
|
|
210
|
+
name: q.name,
|
|
211
|
+
in: 'query',
|
|
212
|
+
required: q.required ?? false,
|
|
213
|
+
description: q.description || '',
|
|
214
|
+
schema: {
|
|
215
|
+
type: q.type ? q.type.name.toLowerCase() : 'string',
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Add manual @ApiHeader annotations if they weren't matched to method params
|
|
222
|
+
for (const h of apiHeaders) {
|
|
223
|
+
if (!parameters.some((p) => p.in === 'header' && p.name === h.name)) {
|
|
224
|
+
parameters.push({
|
|
225
|
+
name: h.name,
|
|
226
|
+
in: 'header',
|
|
227
|
+
required: h.required ?? false,
|
|
228
|
+
description: h.description || '',
|
|
229
|
+
schema: {
|
|
230
|
+
type: 'string',
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const responses: Record<string, any> = {};
|
|
237
|
+
if (responsesMeta.length > 0) {
|
|
238
|
+
for (const res of responsesMeta) {
|
|
239
|
+
responses[String(res.status)] = {
|
|
240
|
+
description: res.description,
|
|
241
|
+
};
|
|
242
|
+
if (res.type) {
|
|
243
|
+
const schemaName = res.type.name;
|
|
244
|
+
responses[String(res.status)].content = {
|
|
245
|
+
'application/json': {
|
|
246
|
+
schema: { $ref: `#/components/schemas/${schemaName}` },
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
registerSchema(res.type);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} else {
|
|
253
|
+
responses['200'] = { description: 'OK' };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
document.paths[swaggerPath][method.toLowerCase()] = {
|
|
257
|
+
summary: operationMeta.summary || '',
|
|
258
|
+
description: operationMeta.description || '',
|
|
259
|
+
tags,
|
|
260
|
+
parameters,
|
|
261
|
+
...(requestBody ? { requestBody } : {}),
|
|
262
|
+
responses,
|
|
263
|
+
...(securityMeta.length > 0 ? { security: securityMeta } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return document;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
static setup(path: string, app: CalyxApplication, document: any) {
|
|
271
|
+
const jsonPath = `/${path}-json`.replace(/\/\/+/g, '/');
|
|
272
|
+
const uiPath = `/${path}`.replace(/\/\/+/g, '/');
|
|
273
|
+
|
|
274
|
+
app.use((req: any, res: any, next: any) => {
|
|
275
|
+
const url = new URL(req.url);
|
|
276
|
+
if (url.pathname === jsonPath && req.method === 'GET') {
|
|
277
|
+
res.status(200);
|
|
278
|
+
res.set('content-type', 'application/json');
|
|
279
|
+
res.send(JSON.stringify(document));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
next();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
const html = `
|
|
286
|
+
<!DOCTYPE html>
|
|
287
|
+
<html lang="en">
|
|
288
|
+
<head>
|
|
289
|
+
<meta charset="utf-8" />
|
|
290
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
291
|
+
<title>Calyx Swagger UI</title>
|
|
292
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui.css" />
|
|
293
|
+
</head>
|
|
294
|
+
<body>
|
|
295
|
+
<div id="swagger-ui"></div>
|
|
296
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-bundle.js"></script>
|
|
297
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/5.11.0/swagger-ui-standalone-preset.js"></script>
|
|
298
|
+
<script>
|
|
299
|
+
window.onload = () => {
|
|
300
|
+
window.ui = SwaggerUIBundle({
|
|
301
|
+
url: '${jsonPath}',
|
|
302
|
+
dom_id: '#swagger-ui',
|
|
303
|
+
presets: [
|
|
304
|
+
SwaggerUIBundle.presets.apis,
|
|
305
|
+
SwaggerUIStandalonePreset
|
|
306
|
+
],
|
|
307
|
+
layout: "BaseLayout"
|
|
308
|
+
});
|
|
309
|
+
};
|
|
310
|
+
</script>
|
|
311
|
+
</body>
|
|
312
|
+
</html>
|
|
313
|
+
`;
|
|
314
|
+
|
|
315
|
+
app.use((req: any, res: any, next: any) => {
|
|
316
|
+
const url = new URL(req.url);
|
|
317
|
+
if (url.pathname === uiPath && req.method === 'GET') {
|
|
318
|
+
res.status(200);
|
|
319
|
+
res.set('content-type', 'text/html');
|
|
320
|
+
res.send(html);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
next();
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { Module, DynamicModule, Inject } from '../core/decorators.ts';
|
|
2
|
+
import { METADATA_KEYS } from '../core/metadata.ts';
|
|
3
|
+
|
|
4
|
+
export interface Job<T = any> {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
data: T;
|
|
8
|
+
opts?: any;
|
|
9
|
+
status: 'waiting' | 'active' | 'completed' | 'failed';
|
|
10
|
+
result?: any;
|
|
11
|
+
error?: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const PROCESSOR_METADATA_KEY = 'calyx:processor';
|
|
15
|
+
export const PROCESS_METADATA_KEY = 'calyx:process';
|
|
16
|
+
|
|
17
|
+
export function Processor(queueName?: string): ClassDecorator {
|
|
18
|
+
return (target) => {
|
|
19
|
+
Reflect.defineMetadata(PROCESSOR_METADATA_KEY, queueName ?? target.name, target);
|
|
20
|
+
Reflect.defineMetadata(METADATA_KEYS.INJECTABLE, true, target);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ProcessOptions {
|
|
25
|
+
name?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function Process(options?: ProcessOptions | string): MethodDecorator {
|
|
29
|
+
return (target, propertyKey) => {
|
|
30
|
+
const name = typeof options === 'string' ? options : options?.name;
|
|
31
|
+
const existing = Reflect.getMetadata(PROCESS_METADATA_KEY, target.constructor) || [];
|
|
32
|
+
existing.push({ name: name ?? '', propertyKey });
|
|
33
|
+
Reflect.defineMetadata(PROCESS_METADATA_KEY, existing, target.constructor);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function InjectQueue(name: string): ParameterDecorator & PropertyDecorator {
|
|
38
|
+
return Inject(`Queue_${name}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class QueueManager {
|
|
42
|
+
private static processors = new Map<string, { instance: any; handlers: { name: string; propertyKey: string | symbol }[] }>();
|
|
43
|
+
private static queues = new Map<string, Queue>();
|
|
44
|
+
|
|
45
|
+
static registerProcessor(queueName: string, instance: any) {
|
|
46
|
+
const handlers = Reflect.getMetadata(PROCESS_METADATA_KEY, instance.constructor) || [];
|
|
47
|
+
this.processors.set(queueName, { instance, handlers });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static getOrCreateQueue(name: string): Queue {
|
|
51
|
+
let q = this.queues.get(name);
|
|
52
|
+
if (!q) {
|
|
53
|
+
q = new Queue(name);
|
|
54
|
+
this.queues.set(name, q);
|
|
55
|
+
}
|
|
56
|
+
return q;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static async dispatch(queueName: string, job: Job) {
|
|
60
|
+
const proc = this.processors.get(queueName);
|
|
61
|
+
if (!proc) {
|
|
62
|
+
// No processor registered yet, mark job as completed (dummy/mock queue mode)
|
|
63
|
+
job.status = 'completed';
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
job.status = 'active';
|
|
68
|
+
const handler = proc.handlers.find((h) => h.name === job.name || (h.name === '' && !job.name));
|
|
69
|
+
if (handler) {
|
|
70
|
+
try {
|
|
71
|
+
const res = proc.instance[handler.propertyKey](job);
|
|
72
|
+
if (res instanceof Promise) {
|
|
73
|
+
job.result = await res;
|
|
74
|
+
} else {
|
|
75
|
+
job.result = res;
|
|
76
|
+
}
|
|
77
|
+
job.status = 'completed';
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
job.error = err;
|
|
80
|
+
job.status = 'failed';
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Fallback: match any handler or first one
|
|
84
|
+
const fallback = proc.handlers[0];
|
|
85
|
+
if (fallback) {
|
|
86
|
+
try {
|
|
87
|
+
const res = proc.instance[fallback.propertyKey](job);
|
|
88
|
+
if (res instanceof Promise) {
|
|
89
|
+
job.result = await res;
|
|
90
|
+
} else {
|
|
91
|
+
job.result = res;
|
|
92
|
+
}
|
|
93
|
+
job.status = 'completed';
|
|
94
|
+
} catch (err: any) {
|
|
95
|
+
job.error = err;
|
|
96
|
+
job.status = 'failed';
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
job.status = 'completed';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export class Queue {
|
|
106
|
+
private jobs: Job[] = [];
|
|
107
|
+
private jobCounter = 0;
|
|
108
|
+
|
|
109
|
+
constructor(public readonly name: string) {}
|
|
110
|
+
|
|
111
|
+
async add<T = any>(name: string, data: T, opts?: any): Promise<Job<T>>;
|
|
112
|
+
async add<T = any>(data: T, opts?: any): Promise<Job<T>>;
|
|
113
|
+
async add<T = any>(first: any, second?: any, third?: any): Promise<Job<T>> {
|
|
114
|
+
let name = '';
|
|
115
|
+
let data: T;
|
|
116
|
+
let opts: any;
|
|
117
|
+
|
|
118
|
+
if (typeof first === 'string') {
|
|
119
|
+
name = first;
|
|
120
|
+
data = second;
|
|
121
|
+
opts = third;
|
|
122
|
+
} else {
|
|
123
|
+
data = first;
|
|
124
|
+
opts = second;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
this.jobCounter++;
|
|
128
|
+
const job: Job<T> = {
|
|
129
|
+
id: `${this.name}_job_${this.jobCounter}`,
|
|
130
|
+
name,
|
|
131
|
+
data,
|
|
132
|
+
opts,
|
|
133
|
+
status: 'waiting',
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
this.jobs.push(job);
|
|
137
|
+
|
|
138
|
+
// Run dispatch asynchronously in the background
|
|
139
|
+
queueMicrotask(() => {
|
|
140
|
+
QueueManager.dispatch(this.name, job);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return job;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async getJobs(): Promise<Job[]> {
|
|
147
|
+
return this.jobs;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@Module({})
|
|
152
|
+
export class QueueModule {
|
|
153
|
+
static registerQueue(...configs: { name: string }[]): DynamicModule {
|
|
154
|
+
const providers = configs.map((c) => {
|
|
155
|
+
return {
|
|
156
|
+
provide: `Queue_${c.name}`,
|
|
157
|
+
useFactory: () => {
|
|
158
|
+
return QueueManager.getOrCreateQueue(c.name);
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
module: QueueModule,
|
|
165
|
+
providers,
|
|
166
|
+
exports: configs.map((c) => `Queue_${c.name}`),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Automatic processor registration on initialization
|
|
171
|
+
onApplicationBootstrap() {
|
|
172
|
+
// Handled in CalyxApplication initialization dynamically
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './session.middleware.ts';
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createParamDecorator } from '../http/decorators.ts';
|
|
2
|
+
import { formatCookie } from '../cookies/cookies.ts';
|
|
3
|
+
|
|
4
|
+
export interface SessionData {
|
|
5
|
+
id: string;
|
|
6
|
+
data: Record<string, any>;
|
|
7
|
+
expiresAt: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class MemorySessionStore {
|
|
11
|
+
private store = new Map<string, SessionData>();
|
|
12
|
+
|
|
13
|
+
get(sid: string): SessionData | undefined {
|
|
14
|
+
const session = this.store.get(sid);
|
|
15
|
+
if (!session) return undefined;
|
|
16
|
+
if (session.expiresAt < Date.now()) {
|
|
17
|
+
this.store.delete(sid);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
return session;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
set(sid: string, data: Record<string, any>, ttlSeconds: number) {
|
|
24
|
+
this.store.set(sid, {
|
|
25
|
+
id: sid,
|
|
26
|
+
data,
|
|
27
|
+
expiresAt: Date.now() + ttlSeconds * 1000,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
destroy(sid: string) {
|
|
32
|
+
this.store.delete(sid);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const sessionStore = new MemorySessionStore();
|
|
37
|
+
|
|
38
|
+
export interface SessionOptions {
|
|
39
|
+
secret?: string;
|
|
40
|
+
name?: string;
|
|
41
|
+
ttl?: number; // TTL in seconds, default 1 day
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function session(options: SessionOptions = {}) {
|
|
45
|
+
const cookieName = options.name ?? 'calyx_sid';
|
|
46
|
+
const ttl = options.ttl ?? 86400; // 1 day
|
|
47
|
+
|
|
48
|
+
return (req: any, res: any, next: any) => {
|
|
49
|
+
const cookies = req.cookies || {};
|
|
50
|
+
let sid = cookies[cookieName];
|
|
51
|
+
let sessionData = sid ? sessionStore.get(sid) : undefined;
|
|
52
|
+
|
|
53
|
+
if (!sessionData) {
|
|
54
|
+
sid = crypto.randomUUID();
|
|
55
|
+
sessionData = {
|
|
56
|
+
id: sid,
|
|
57
|
+
data: {},
|
|
58
|
+
expiresAt: Date.now() + ttl * 1000,
|
|
59
|
+
};
|
|
60
|
+
sessionStore.set(sid, sessionData.data, ttl);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
req.session = sessionData.data;
|
|
64
|
+
|
|
65
|
+
// Attach finalizeSession hook to request
|
|
66
|
+
req.finalizeSession = (responseHeaders: Headers) => {
|
|
67
|
+
sessionStore.set(sid, req.session, ttl);
|
|
68
|
+
responseHeaders.append('Set-Cookie', formatCookie(cookieName, sid, {
|
|
69
|
+
maxAge: ttl,
|
|
70
|
+
httpOnly: true,
|
|
71
|
+
path: '/',
|
|
72
|
+
}));
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
next();
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const Session = createParamDecorator((data, ctx) => {
|
|
80
|
+
const req = ctx.switchToHttp().getRequest();
|
|
81
|
+
return (req as any).session;
|
|
82
|
+
});
|
package/src/sse/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './sse.ts';
|
package/src/sse/sse.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { METADATA_KEYS } from '../core/metadata.ts';
|
|
3
|
+
|
|
4
|
+
export interface MessageEvent {
|
|
5
|
+
data: string | object;
|
|
6
|
+
id?: string;
|
|
7
|
+
type?: string;
|
|
8
|
+
retry?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function Sse(path = ''): MethodDecorator {
|
|
12
|
+
return (target: any, propertyKey: string | symbol, descriptor: any) => {
|
|
13
|
+
// SSE routes are GET requests
|
|
14
|
+
Reflect.defineMetadata(METADATA_KEYS.HTTP_METHOD, { method: 'GET', path }, target, propertyKey);
|
|
15
|
+
Reflect.defineMetadata('calyx:sse', true, target, propertyKey);
|
|
16
|
+
return descriptor;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './streamable-file.ts';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export interface StreamableFileOptions {
|
|
2
|
+
type?: string;
|
|
3
|
+
disposition?: string;
|
|
4
|
+
length?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class StreamableFile {
|
|
8
|
+
constructor(
|
|
9
|
+
public readonly streamOrBuffer: any,
|
|
10
|
+
public readonly options: StreamableFileOptions = {}
|
|
11
|
+
) {}
|
|
12
|
+
|
|
13
|
+
getStream(): any {
|
|
14
|
+
return this.streamOrBuffer;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
getHeaders(): Record<string, string> {
|
|
18
|
+
const headers: Record<string, string> = {};
|
|
19
|
+
if (this.options.type) {
|
|
20
|
+
headers['content-type'] = this.options.type;
|
|
21
|
+
} else {
|
|
22
|
+
headers['content-type'] = 'application/octet-stream';
|
|
23
|
+
}
|
|
24
|
+
if (this.options.disposition) {
|
|
25
|
+
headers['content-disposition'] = this.options.disposition;
|
|
26
|
+
}
|
|
27
|
+
if (this.options.length !== undefined) {
|
|
28
|
+
headers['content-length'] = String(this.options.length);
|
|
29
|
+
}
|
|
30
|
+
return headers;
|
|
31
|
+
}
|
|
32
|
+
}
|