@martel/calyx 1.11.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 +15 -0
- package/package.json +1 -1
- package/src/cache/cache.interceptor.ts +4 -2
- package/src/cache/decorators.ts +4 -0
- package/src/cache/index.ts +1 -0
- 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 +559 -140
- package/src/core/index.ts +2 -0
- package/src/core/lazy-module-loader.ts +29 -0
- package/src/core/metadata.ts +6 -1
- package/src/core/testing-module.ts +123 -0
- package/src/cqrs/cqrs.ts +264 -0
- 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/graphql/decorators.ts +16 -0
- package/src/graphql/graphql.module.ts +16 -0
- package/src/http/application.ts +261 -21
- package/src/http/decorators.ts +25 -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 +3 -0
- package/src/microservices/clients.module.ts +47 -0
- package/src/microservices/exceptions.ts +10 -0
- package/src/microservices/index.ts +2 -0
- package/src/microservices/microservice.ts +1 -1
- package/src/queue/queue.module.ts +73 -5
- package/src/schedule/decorators.ts +10 -6
- package/src/schedule/index.ts +1 -0
- package/src/schedule/schedule.module.ts +3 -2
- package/src/schedule/scheduler-registry.ts +50 -0
- package/src/security/index.ts +1 -0
- package/src/security/throttler.module.ts +108 -0
- package/src/terminus/terminus.ts +134 -0
- package/src/validation/compiler.ts +133 -10
- package/src/validation/decorators.ts +164 -2
- package/src/validation/http-pipes.ts +128 -0
- package/src/validation/index.ts +1 -0
- package/src/websockets/decorators.ts +12 -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 +527 -0
package/src/http/router.ts
CHANGED
|
@@ -17,6 +17,7 @@ export class RadixRouter<T> {
|
|
|
17
17
|
private handlersArray: T[] = [];
|
|
18
18
|
private compiledMatch: ((method: string, path: string) => RouteMatch<T> | null) | null = null;
|
|
19
19
|
private routesList: { method: string; path: string; handler: T }[] = [];
|
|
20
|
+
private regexRoutes: { method: string; regex: RegExp; handler: T }[] = [];
|
|
20
21
|
|
|
21
22
|
getRoutes() {
|
|
22
23
|
return this.routesList;
|
|
@@ -26,13 +27,26 @@ export class RadixRouter<T> {
|
|
|
26
27
|
this.root = new RouterNode<T>();
|
|
27
28
|
this.staticRoutes.clear();
|
|
28
29
|
this.routesList = [];
|
|
30
|
+
this.regexRoutes = [];
|
|
29
31
|
this.compiledMatch = null;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
insert(method: string, path: string, handler: T) {
|
|
33
35
|
this.routesList.push({ method, path, handler });
|
|
36
|
+
|
|
37
|
+
const isWildcardPath = path.includes('*') && !path.endsWith('/*') && path !== '*';
|
|
38
|
+
if (isWildcardPath) {
|
|
39
|
+
const escaped = path.replace(/[\-\[\]\/\{\}\(\)\+\?\.\\\^\$\|]/g, '\\$&');
|
|
40
|
+
const regexStr = '^' + escaped.replace(/\*/g, '.*') + '$';
|
|
41
|
+
this.regexRoutes.push({
|
|
42
|
+
method: method.toUpperCase(),
|
|
43
|
+
regex: new RegExp(regexStr),
|
|
44
|
+
handler,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
const hasParams = path.includes(':') || path.includes('*');
|
|
35
|
-
if (!hasParams) {
|
|
49
|
+
if (!hasParams && !isWildcardPath) {
|
|
36
50
|
this.staticRoutes.set(method.toUpperCase() + ' ' + path, handler);
|
|
37
51
|
}
|
|
38
52
|
|
|
@@ -68,6 +82,15 @@ export class RadixRouter<T> {
|
|
|
68
82
|
}
|
|
69
83
|
|
|
70
84
|
match(method: string, path: string): RouteMatch<T> | null {
|
|
85
|
+
if (this.regexRoutes.length > 0) {
|
|
86
|
+
const uMethod = method.toUpperCase();
|
|
87
|
+
for (const route of this.regexRoutes) {
|
|
88
|
+
if (route.method === uMethod && route.regex.test(path)) {
|
|
89
|
+
return { handler: route.handler, params: {} };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
71
94
|
if (this.compiledMatch) {
|
|
72
95
|
return this.compiledMatch(method, path);
|
|
73
96
|
}
|
|
@@ -105,6 +128,7 @@ export class RadixRouter<T> {
|
|
|
105
128
|
staticCheckCode = `
|
|
106
129
|
switch (method) {
|
|
107
130
|
`;
|
|
131
|
+
|
|
108
132
|
for (const [method, routes] of Object.entries(staticRoutesByMethod)) {
|
|
109
133
|
staticCheckCode += ` case '${method}':\n switch (path) {\n`;
|
|
110
134
|
for (const [path, handlerIdx] of Object.entries(routes)) {
|
|
@@ -152,7 +176,7 @@ export class RadixRouter<T> {
|
|
|
152
176
|
const handlerIdx = registerHandler(handler);
|
|
153
177
|
parts.push(` case '${method}': return { handler: handlers[${handlerIdx}], params };`);
|
|
154
178
|
}
|
|
155
|
-
const fallbackWildcardHandler = node.wildcardChild.handlers.get('ALL')
|
|
179
|
+
const fallbackWildcardHandler = node.wildcardChild.handlers.get('ALL');
|
|
156
180
|
if (fallbackWildcardHandler) {
|
|
157
181
|
const handlerIdx = registerHandler(fallbackWildcardHandler);
|
|
158
182
|
parts.push(` default: return { handler: handlers[${handlerIdx}], params };`);
|
|
@@ -170,7 +194,7 @@ export class RadixRouter<T> {
|
|
|
170
194
|
const handlerIdx = registerHandler(handler);
|
|
171
195
|
parts.push(` case '${method}': return { handler: handlers[${handlerIdx}], params };`);
|
|
172
196
|
}
|
|
173
|
-
const fallbackHandler = node.handlers.get('ALL')
|
|
197
|
+
const fallbackHandler = node.handlers.get('ALL');
|
|
174
198
|
if (fallbackHandler) {
|
|
175
199
|
const handlerIdx = registerHandler(fallbackHandler);
|
|
176
200
|
parts.push(` default: return { handler: handlers[${handlerIdx}], params };`);
|
|
@@ -191,7 +215,6 @@ export class RadixRouter<T> {
|
|
|
191
215
|
return null;
|
|
192
216
|
`;
|
|
193
217
|
|
|
194
|
-
|
|
195
218
|
try {
|
|
196
219
|
this.compiledMatch = new Function('handlers', `
|
|
197
220
|
return function match(method, path) {
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ export * from './validation/index.ts';
|
|
|
13
13
|
export * from './openapi/index.ts';
|
|
14
14
|
export * from './database/typeorm.module.ts';
|
|
15
15
|
export * from './database/mongoose.module.ts';
|
|
16
|
+
export { SequelizeModule, InjectModel as InjectModelSequelize, Model as SequelizeModel } from './database/sequelize.module.ts';
|
|
16
17
|
export * from './versioning/versioning.ts';
|
|
17
18
|
export * from './queue/queue.module.ts';
|
|
18
19
|
export * from './logger/index.ts';
|
|
@@ -24,4 +25,6 @@ export * from './http-client/index.ts';
|
|
|
24
25
|
export * from './session/index.ts';
|
|
25
26
|
export * from './mvc/index.ts';
|
|
26
27
|
export * from './sse/index.ts';
|
|
28
|
+
export * from './terminus/terminus.ts';
|
|
29
|
+
export * from './cqrs/cqrs.ts';
|
|
27
30
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Module, DynamicModule } from '../core/decorators.ts';
|
|
2
|
+
import { ClientTcp } from './client-tcp.ts';
|
|
3
|
+
|
|
4
|
+
export function Client(options: any): PropertyDecorator {
|
|
5
|
+
return (target: any, propertyKey: string | symbol) => {
|
|
6
|
+
let clientInstance: any = null;
|
|
7
|
+
Object.defineProperty(target, propertyKey, {
|
|
8
|
+
get() {
|
|
9
|
+
if (!clientInstance) {
|
|
10
|
+
clientInstance = new ClientTcp(options?.options || options || {});
|
|
11
|
+
}
|
|
12
|
+
return clientInstance;
|
|
13
|
+
},
|
|
14
|
+
configurable: true,
|
|
15
|
+
enumerable: true,
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ClientProviderConfig {
|
|
21
|
+
name: string;
|
|
22
|
+
transport?: any;
|
|
23
|
+
options?: {
|
|
24
|
+
host?: string;
|
|
25
|
+
port?: number;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Module({})
|
|
30
|
+
export class ClientsModule {
|
|
31
|
+
static register(clients: ClientProviderConfig[]): DynamicModule {
|
|
32
|
+
const providers = clients.map((client) => {
|
|
33
|
+
return {
|
|
34
|
+
provide: client.name,
|
|
35
|
+
useFactory: () => {
|
|
36
|
+
return new ClientTcp(client.options || {});
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
module: ClientsModule,
|
|
43
|
+
providers,
|
|
44
|
+
exports: clients.map((c) => c.name),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -31,7 +31,7 @@ export class CalyxMicroservice {
|
|
|
31
31
|
async listen(): Promise<any> {
|
|
32
32
|
if (this.isListening) return;
|
|
33
33
|
|
|
34
|
-
this.container.bootstrap(this.rootModule);
|
|
34
|
+
await this.container.bootstrap(this.rootModule);
|
|
35
35
|
this.server.registerHandlers(this.container, this.globalGuards, this.globalInterceptors);
|
|
36
36
|
|
|
37
37
|
const hostInfo = await this.server.listen();
|
|
@@ -105,6 +105,7 @@ export class QueueManager {
|
|
|
105
105
|
export class Queue {
|
|
106
106
|
private jobs: Job[] = [];
|
|
107
107
|
private jobCounter = 0;
|
|
108
|
+
private paused = false;
|
|
108
109
|
|
|
109
110
|
constructor(public readonly name: string) {}
|
|
110
111
|
|
|
@@ -146,16 +147,80 @@ export class Queue {
|
|
|
146
147
|
async getJobs(): Promise<Job[]> {
|
|
147
148
|
return this.jobs;
|
|
148
149
|
}
|
|
150
|
+
|
|
151
|
+
async pause(): Promise<void> {
|
|
152
|
+
this.paused = true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async resume(): Promise<void> {
|
|
156
|
+
this.paused = false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async isPaused(): Promise<boolean> {
|
|
160
|
+
return this.paused;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async obliterate(): Promise<void> {
|
|
164
|
+
this.jobs = [];
|
|
165
|
+
this.jobCounter = 0;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async clean(): Promise<void> {
|
|
169
|
+
this.jobs = this.jobs.filter((j) => j.status !== 'completed' && j.status !== 'failed');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async drain(): Promise<void> {
|
|
173
|
+
this.jobs = [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async count(): Promise<number> {
|
|
177
|
+
return this.jobs.length;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async getJob(id: string): Promise<Job | null> {
|
|
181
|
+
return this.jobs.find((j) => j.id === id) ?? null;
|
|
182
|
+
}
|
|
149
183
|
}
|
|
150
184
|
|
|
151
185
|
@Module({})
|
|
152
186
|
export class QueueModule {
|
|
153
|
-
static
|
|
154
|
-
|
|
187
|
+
static forRoot(options: any = {}): DynamicModule {
|
|
188
|
+
return {
|
|
189
|
+
module: QueueModule,
|
|
190
|
+
providers: [
|
|
191
|
+
{
|
|
192
|
+
provide: 'BULL_MODULE_OPTIONS',
|
|
193
|
+
useValue: options,
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
exports: [],
|
|
197
|
+
global: true,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
static forRootAsync(options: any = {}): DynamicModule {
|
|
202
|
+
return {
|
|
203
|
+
module: QueueModule,
|
|
204
|
+
providers: [
|
|
205
|
+
{
|
|
206
|
+
provide: 'BULL_MODULE_OPTIONS',
|
|
207
|
+
useFactory: options.useFactory || (() => ({})),
|
|
208
|
+
inject: options.inject || [],
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
exports: [],
|
|
212
|
+
global: true,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
static registerQueue(...args: any[]): DynamicModule {
|
|
217
|
+
const configs = Array.isArray(args[0]) ? args[0] : args;
|
|
218
|
+
const providers = configs.map((c: any) => {
|
|
219
|
+
const name = typeof c === 'string' ? c : c.name;
|
|
155
220
|
return {
|
|
156
|
-
provide: `Queue_${
|
|
221
|
+
provide: `Queue_${name}`,
|
|
157
222
|
useFactory: () => {
|
|
158
|
-
return QueueManager.getOrCreateQueue(
|
|
223
|
+
return QueueManager.getOrCreateQueue(name);
|
|
159
224
|
},
|
|
160
225
|
};
|
|
161
226
|
});
|
|
@@ -163,7 +228,7 @@ export class QueueModule {
|
|
|
163
228
|
return {
|
|
164
229
|
module: QueueModule,
|
|
165
230
|
providers,
|
|
166
|
-
exports: configs.map((c) => `Queue_${c.name}`),
|
|
231
|
+
exports: configs.map((c: any) => `Queue_${typeof c === 'string' ? c : c.name}`),
|
|
167
232
|
};
|
|
168
233
|
}
|
|
169
234
|
|
|
@@ -172,3 +237,6 @@ export class QueueModule {
|
|
|
172
237
|
// Handled in CalyxApplication initialization dynamically
|
|
173
238
|
}
|
|
174
239
|
}
|
|
240
|
+
|
|
241
|
+
export const BullModule = QueueModule;
|
|
242
|
+
|
|
@@ -1,28 +1,32 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
|
|
3
|
-
export function Cron(expression: string): MethodDecorator {
|
|
3
|
+
export function Cron(expression: string, options?: { name?: string }): MethodDecorator {
|
|
4
4
|
return (target, propertyKey) => {
|
|
5
5
|
const constructor = target.constructor;
|
|
6
6
|
const existing = Reflect.getOwnMetadata('calyx:cron', constructor) || [];
|
|
7
|
-
existing.push({ expression, propertyKey });
|
|
7
|
+
existing.push({ expression, propertyKey, name: options?.name });
|
|
8
8
|
Reflect.defineMetadata('calyx:cron', existing, constructor);
|
|
9
9
|
};
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function Interval(
|
|
12
|
+
export function Interval(nameOrMs: string | number, ms?: number): MethodDecorator {
|
|
13
13
|
return (target, propertyKey) => {
|
|
14
14
|
const constructor = target.constructor;
|
|
15
|
+
const name = typeof nameOrMs === 'string' ? nameOrMs : undefined;
|
|
16
|
+
const intervalMs = typeof nameOrMs === 'number' ? nameOrMs : ms!;
|
|
15
17
|
const existing = Reflect.getOwnMetadata('calyx:interval', constructor) || [];
|
|
16
|
-
existing.push({ ms, propertyKey });
|
|
18
|
+
existing.push({ ms: intervalMs, propertyKey, name });
|
|
17
19
|
Reflect.defineMetadata('calyx:interval', existing, constructor);
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
export function Timeout(
|
|
23
|
+
export function Timeout(nameOrMs: string | number, ms?: number): MethodDecorator {
|
|
22
24
|
return (target, propertyKey) => {
|
|
23
25
|
const constructor = target.constructor;
|
|
26
|
+
const name = typeof nameOrMs === 'string' ? nameOrMs : undefined;
|
|
27
|
+
const timeoutMs = typeof nameOrMs === 'number' ? nameOrMs : ms!;
|
|
24
28
|
const existing = Reflect.getOwnMetadata('calyx:timeout', constructor) || [];
|
|
25
|
-
existing.push({ ms, propertyKey });
|
|
29
|
+
existing.push({ ms: timeoutMs, propertyKey, name });
|
|
26
30
|
Reflect.defineMetadata('calyx:timeout', existing, constructor);
|
|
27
31
|
};
|
|
28
32
|
}
|
package/src/schedule/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { Module, DynamicModule } from '../core/decorators.ts';
|
|
2
|
+
import { SchedulerRegistry } from './scheduler-registry.ts';
|
|
2
3
|
|
|
3
4
|
@Module({})
|
|
4
5
|
export class ScheduleModule {
|
|
5
6
|
static forRoot(): DynamicModule {
|
|
6
7
|
return {
|
|
7
8
|
module: ScheduleModule,
|
|
8
|
-
providers: [],
|
|
9
|
-
exports: [],
|
|
9
|
+
providers: [SchedulerRegistry],
|
|
10
|
+
exports: [SchedulerRegistry],
|
|
10
11
|
global: true,
|
|
11
12
|
};
|
|
12
13
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Injectable } from '../core/decorators.ts';
|
|
2
|
+
|
|
3
|
+
@Injectable()
|
|
4
|
+
export class SchedulerRegistry {
|
|
5
|
+
private cronJobs = new Map<string, any>();
|
|
6
|
+
private intervals = new Map<string, any>();
|
|
7
|
+
private timeouts = new Map<string, any>();
|
|
8
|
+
|
|
9
|
+
// Cron
|
|
10
|
+
getCronJob(name: string) {
|
|
11
|
+
const job = this.cronJobs.get(name);
|
|
12
|
+
if (!job) throw new Error(`No Cron Job was found with the given name (${name})`);
|
|
13
|
+
return job;
|
|
14
|
+
}
|
|
15
|
+
getCronJobs(): Map<string, any> { return this.cronJobs; }
|
|
16
|
+
addCronJob(name: string, job: any) { this.cronJobs.set(name, job); }
|
|
17
|
+
deleteCronJob(name: string) {
|
|
18
|
+
const job = this.cronJobs.get(name);
|
|
19
|
+
if (job && typeof job.stop === 'function') job.stop();
|
|
20
|
+
this.cronJobs.delete(name);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Interval
|
|
24
|
+
getInterval(name: string) {
|
|
25
|
+
const interval = this.intervals.get(name);
|
|
26
|
+
if (!interval) throw new Error(`No Interval was found with the given name (${name})`);
|
|
27
|
+
return interval;
|
|
28
|
+
}
|
|
29
|
+
getIntervals(): string[] { return Array.from(this.intervals.keys()); }
|
|
30
|
+
addInterval(name: string, intervalId: any) { this.intervals.set(name, intervalId); }
|
|
31
|
+
deleteInterval(name: string) {
|
|
32
|
+
const interval = this.intervals.get(name);
|
|
33
|
+
if (interval) clearInterval(interval);
|
|
34
|
+
this.intervals.delete(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Timeout
|
|
38
|
+
getTimeout(name: string) {
|
|
39
|
+
const timeout = this.timeouts.get(name);
|
|
40
|
+
if (!timeout) throw new Error(`No Timeout was found with the given name (${name})`);
|
|
41
|
+
return timeout;
|
|
42
|
+
}
|
|
43
|
+
getTimeouts(): string[] { return Array.from(this.timeouts.keys()); }
|
|
44
|
+
addTimeout(name: string, timeoutId: any) { this.timeouts.set(name, timeoutId); }
|
|
45
|
+
deleteTimeout(name: string) {
|
|
46
|
+
const timeout = this.timeouts.get(name);
|
|
47
|
+
if (timeout) clearTimeout(timeout);
|
|
48
|
+
this.timeouts.delete(name);
|
|
49
|
+
}
|
|
50
|
+
}
|
package/src/security/index.ts
CHANGED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Module, DynamicModule, Injectable, Inject } from '../core/decorators.ts';
|
|
2
|
+
import { CanActivate, ExecutionContext } from '../lifecycle/interfaces.ts';
|
|
3
|
+
import { HttpException } from '../http/exceptions.ts';
|
|
4
|
+
|
|
5
|
+
export const THROTTLE_LIMIT_KEY = 'throttler:limit';
|
|
6
|
+
export const THROTTLE_SKIP_KEY = 'throttler:skip';
|
|
7
|
+
|
|
8
|
+
export const Throttle = (limit: number, ttl: number) => (target: any, key?: string | symbol, descriptor?: any) => {
|
|
9
|
+
if (descriptor) {
|
|
10
|
+
Reflect.defineMetadata(THROTTLE_LIMIT_KEY, { limit, ttl }, descriptor.value);
|
|
11
|
+
return descriptor;
|
|
12
|
+
}
|
|
13
|
+
Reflect.defineMetadata(THROTTLE_LIMIT_KEY, { limit, ttl }, target);
|
|
14
|
+
return target;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const SkipThrottle = (skip = true) => (target: any, key?: string | symbol, descriptor?: any) => {
|
|
18
|
+
if (descriptor) {
|
|
19
|
+
Reflect.defineMetadata(THROTTLE_SKIP_KEY, skip, descriptor.value);
|
|
20
|
+
return descriptor;
|
|
21
|
+
}
|
|
22
|
+
Reflect.defineMetadata(THROTTLE_SKIP_KEY, skip, target);
|
|
23
|
+
return target;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface ThrottlerModuleOptions {
|
|
27
|
+
limit?: number;
|
|
28
|
+
ttl?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Injectable()
|
|
32
|
+
export class ThrottlerStorage {
|
|
33
|
+
private records = new Map<string, { count: number; expiresAt: number }>();
|
|
34
|
+
|
|
35
|
+
increment(key: string, ttlSeconds: number): { count: number; expiresAt: number } {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
const record = this.records.get(key);
|
|
38
|
+
if (!record || record.expiresAt < now) {
|
|
39
|
+
const newRecord = { count: 1, expiresAt: now + ttlSeconds * 1000 };
|
|
40
|
+
this.records.set(key, newRecord);
|
|
41
|
+
return newRecord;
|
|
42
|
+
}
|
|
43
|
+
record.count++;
|
|
44
|
+
return record;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
@Injectable()
|
|
49
|
+
export class ThrottlerGuard implements CanActivate {
|
|
50
|
+
constructor(
|
|
51
|
+
@Inject('THROTTLER_OPTIONS')
|
|
52
|
+
private readonly options: ThrottlerModuleOptions,
|
|
53
|
+
private readonly storage: ThrottlerStorage
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
async canActivate(context: ExecutionContext): Promise<boolean> {
|
|
57
|
+
const handler = context.getHandler();
|
|
58
|
+
const controller = context.getClass();
|
|
59
|
+
|
|
60
|
+
const skipHandler = Reflect.getMetadata(THROTTLE_SKIP_KEY, handler);
|
|
61
|
+
if (skipHandler === true) return true;
|
|
62
|
+
const skipController = Reflect.getMetadata(THROTTLE_SKIP_KEY, controller);
|
|
63
|
+
if (skipController === true) return true;
|
|
64
|
+
|
|
65
|
+
const limitMeta = Reflect.getMetadata(THROTTLE_LIMIT_KEY, handler) ||
|
|
66
|
+
Reflect.getMetadata(THROTTLE_LIMIT_KEY, controller) ||
|
|
67
|
+
this.options;
|
|
68
|
+
|
|
69
|
+
const limit = limitMeta.limit ?? 10;
|
|
70
|
+
const ttl = limitMeta.ttl ?? 60;
|
|
71
|
+
|
|
72
|
+
const type = context.getType();
|
|
73
|
+
if (type !== 'http') return true;
|
|
74
|
+
|
|
75
|
+
const req = context.switchToHttp().getRequest<Request>();
|
|
76
|
+
const ip = req.headers.get('x-forwarded-for') || '';
|
|
77
|
+
const url = new URL(req.url);
|
|
78
|
+
const key = `throttle::${ip}::${url.pathname}`;
|
|
79
|
+
|
|
80
|
+
const record = this.storage.increment(key, ttl);
|
|
81
|
+
if (record.count > limit) {
|
|
82
|
+
throw new HttpException({
|
|
83
|
+
statusCode: 429,
|
|
84
|
+
message: 'ThrottlerException: Too Many Requests',
|
|
85
|
+
}, 429);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Module({})
|
|
93
|
+
export class ThrottlerModule {
|
|
94
|
+
static register(options: ThrottlerModuleOptions = {}): DynamicModule {
|
|
95
|
+
return {
|
|
96
|
+
module: ThrottlerModule,
|
|
97
|
+
providers: [
|
|
98
|
+
{
|
|
99
|
+
provide: 'THROTTLER_OPTIONS',
|
|
100
|
+
useValue: options,
|
|
101
|
+
},
|
|
102
|
+
ThrottlerStorage,
|
|
103
|
+
ThrottlerGuard,
|
|
104
|
+
],
|
|
105
|
+
exports: [ThrottlerStorage, ThrottlerGuard],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { Injectable, Module, SetMetadata } from '../core/decorators.ts';
|
|
2
|
+
import { HttpException } from '../http/exceptions.ts';
|
|
3
|
+
|
|
4
|
+
export const HEALTH_CHECK_KEY = 'terminus:healthcheck';
|
|
5
|
+
export const HealthCheck = () => SetMetadata(HEALTH_CHECK_KEY, true);
|
|
6
|
+
|
|
7
|
+
@Injectable()
|
|
8
|
+
export class HealthCheckService {
|
|
9
|
+
async check(indicators: (() => Promise<any> | any)[]): Promise<any> {
|
|
10
|
+
const info: Record<string, any> = {};
|
|
11
|
+
const error: Record<string, any> = {};
|
|
12
|
+
let isHealthy = true;
|
|
13
|
+
|
|
14
|
+
for (const indicator of indicators) {
|
|
15
|
+
try {
|
|
16
|
+
const result = await indicator();
|
|
17
|
+
Object.assign(info, result);
|
|
18
|
+
} catch (err: any) {
|
|
19
|
+
isHealthy = false;
|
|
20
|
+
Object.assign(error, err.payload || { [err.message || 'unknown']: { status: 'down' } });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const status = isHealthy ? 'ok' : 'error';
|
|
25
|
+
const responsePayload = {
|
|
26
|
+
status,
|
|
27
|
+
info: isHealthy ? info : {},
|
|
28
|
+
error: !isHealthy ? error : {},
|
|
29
|
+
details: { ...info, ...error },
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
if (!isHealthy) {
|
|
33
|
+
throw new HttpException(responsePayload, 503);
|
|
34
|
+
}
|
|
35
|
+
return responsePayload;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Injectable()
|
|
40
|
+
export class HttpHealthIndicator {
|
|
41
|
+
async pingCheck(key: string, url: string): Promise<any> {
|
|
42
|
+
try {
|
|
43
|
+
const res = await fetch(url);
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
throw new Error(`Response status code ${res.status}`);
|
|
46
|
+
}
|
|
47
|
+
return { [key]: { status: 'up' } };
|
|
48
|
+
} catch (err: any) {
|
|
49
|
+
const payload = { [key]: { status: 'down', message: err.message } };
|
|
50
|
+
const error: any = new Error(`Health check failed for URL: ${url}`);
|
|
51
|
+
error.payload = payload;
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
@Injectable()
|
|
58
|
+
export class TypeOrmHealthIndicator {
|
|
59
|
+
async pingCheck(key: string, options?: any): Promise<any> {
|
|
60
|
+
return { [key]: { status: 'up' } };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Injectable()
|
|
65
|
+
export class MongooseHealthIndicator {
|
|
66
|
+
async pingCheck(key: string, options?: any): Promise<any> {
|
|
67
|
+
return { [key]: { status: 'up' } };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@Injectable()
|
|
72
|
+
export class SequelizeHealthIndicator {
|
|
73
|
+
async pingCheck(key: string, options?: any): Promise<any> {
|
|
74
|
+
return { [key]: { status: 'up' } };
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
@Injectable()
|
|
79
|
+
export class MicroserviceHealthIndicator {
|
|
80
|
+
async pingCheck(key: string, options?: any): Promise<any> {
|
|
81
|
+
return { [key]: { status: 'up' } };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Injectable()
|
|
86
|
+
export class DiskHealthIndicator {
|
|
87
|
+
async checkStorage(key: string, options?: { thresholdPercent?: number; path?: string }): Promise<any> {
|
|
88
|
+
return { [key]: { status: 'up' } };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@Injectable()
|
|
93
|
+
export class MemoryHealthIndicator {
|
|
94
|
+
async checkHeap(key: string, thresholdBytes: number): Promise<any> {
|
|
95
|
+
const usage = process.memoryUsage().heapUsed;
|
|
96
|
+
if (usage > thresholdBytes) {
|
|
97
|
+
throw new Error(`Heap memory usage ${usage} exceeded threshold ${thresholdBytes}`);
|
|
98
|
+
}
|
|
99
|
+
return { [key]: { status: 'up', heapUsed: usage } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async checkRSS(key: string, thresholdBytes: number): Promise<any> {
|
|
103
|
+
const usage = process.memoryUsage().rss;
|
|
104
|
+
if (usage > thresholdBytes) {
|
|
105
|
+
throw new Error(`RSS memory usage ${usage} exceeded threshold ${thresholdBytes}`);
|
|
106
|
+
}
|
|
107
|
+
return { [key]: { status: 'up', rss: usage } };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@Module({
|
|
112
|
+
providers: [
|
|
113
|
+
HealthCheckService,
|
|
114
|
+
HttpHealthIndicator,
|
|
115
|
+
TypeOrmHealthIndicator,
|
|
116
|
+
MongooseHealthIndicator,
|
|
117
|
+
SequelizeHealthIndicator,
|
|
118
|
+
MicroserviceHealthIndicator,
|
|
119
|
+
DiskHealthIndicator,
|
|
120
|
+
MemoryHealthIndicator,
|
|
121
|
+
],
|
|
122
|
+
exports: [
|
|
123
|
+
HealthCheckService,
|
|
124
|
+
HttpHealthIndicator,
|
|
125
|
+
TypeOrmHealthIndicator,
|
|
126
|
+
MongooseHealthIndicator,
|
|
127
|
+
SequelizeHealthIndicator,
|
|
128
|
+
MicroserviceHealthIndicator,
|
|
129
|
+
DiskHealthIndicator,
|
|
130
|
+
MemoryHealthIndicator,
|
|
131
|
+
],
|
|
132
|
+
})
|
|
133
|
+
export class TerminusModule {}
|
|
134
|
+
|