From c049df8dbd57f147baeea425eec2c66506efc7ee Mon Sep 17 00:00:00 2001 From: Thomas Cruveilher <38007824+Sorikairox@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:48:02 +0000 Subject: [PATCH 1/5] feat: add rate limiting service --- spec/throttler.test.ts | 52 ++++++++++++++++++++++++++++++++++++++ src/mod.ts | 3 ++- src/throttler/decorator.ts | 20 +++++++++++++++ src/throttler/guard.ts | 44 ++++++++++++++++++++++++++++++++ src/throttler/mod.ts | 3 +++ src/throttler/service.ts | 31 +++++++++++++++++++++++ 6 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 spec/throttler.test.ts create mode 100644 src/throttler/decorator.ts create mode 100644 src/throttler/guard.ts create mode 100644 src/throttler/mod.ts create mode 100644 src/throttler/service.ts diff --git a/spec/throttler.test.ts b/spec/throttler.test.ts new file mode 100644 index 0000000..3120a0c --- /dev/null +++ b/spec/throttler.test.ts @@ -0,0 +1,52 @@ +import { assertEquals } from '../src/deps_test.ts'; +import { DanetApplication } from '../src/app.ts'; +import { Controller, Get } from '../src/router/controller/decorator.ts'; +import { Module } from '../src/module/decorator.ts'; +import { Throttle } from '../src/throttler/decorator.ts'; +import { ThrottlerService, ThrottleGuard } from '../src/throttler/mod.ts'; +import { GLOBAL_GUARD } from '../src/guard/constants.ts'; + +@Controller('throttle') +class ThrottleController { + @Throttle(2, 1) + @Get('/') + simpleGet() { + return { ok: true }; + } +} + +@Module({ + imports: [], + controllers: [ThrottleController], + injectables: [ + ThrottlerService, + { useClass: ThrottleGuard, token: GLOBAL_GUARD }, + ], +}) +class ThrottleModule {} + +Deno.test('throttle guard limits requests', async () => { + const app = new DanetApplication(); + await app.init(ThrottleModule); + const listenEvent = await app.listen(0); + const base = `http://localhost:${listenEvent.port}/throttle`; + + const r1 = await fetch(base, { method: 'GET' }); + assertEquals(r1.status, 200); + await r1?.body?.cancel(); + + const r2 = await fetch(base, { method: 'GET' }); + assertEquals(r2.status, 200); + await r2?.body?.cancel(); + + const r3 = await fetch(base, { method: 'GET' }); + assertEquals(r3.status, 429); + const json = await r3.json(); + assertEquals(json, { + description: 'Too many requests', + message: '429 - Too many requests', + name: 'TooManyRequestsException', + status: 429, + }); + await app.close(); +}); diff --git a/src/mod.ts b/src/mod.ts index f0ec7be..0fb21b2 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -15,4 +15,5 @@ export * from './logger.ts'; export * from './events/mod.ts'; export * from './schedule/mod.ts'; export * from './kv-queue/mod.ts'; -export * from './sse/mod.ts'; \ No newline at end of file +export * from './sse/mod.ts'; +export * from './throttler/mod.ts'; \ No newline at end of file diff --git a/src/throttler/decorator.ts b/src/throttler/decorator.ts new file mode 100644 index 0000000..484c260 --- /dev/null +++ b/src/throttler/decorator.ts @@ -0,0 +1,20 @@ +import { MetadataFunction, SetMetadata } from '../metadata/decorator.ts'; + +export const throttleMetadataKey = 'throttle'; + +/** + * Options for throttle metadata. + */ +export interface ThrottleOptions { + limit: number; + ttl?: number; // seconds +} + +/** + * Apply throttle options to controller or handler. + * @param limit number of requests + * @param ttl seconds window + */ +export function Throttle(limit: number, ttl = 60): MetadataFunction { + return SetMetadata(throttleMetadataKey, { limit, ttl } as ThrottleOptions); +} diff --git a/src/throttler/guard.ts b/src/throttler/guard.ts new file mode 100644 index 0000000..6e44052 --- /dev/null +++ b/src/throttler/guard.ts @@ -0,0 +1,44 @@ +import { AuthGuard } from '../guard/interface.ts'; +import { ExecutionContext } from '../router/router.ts'; +import { MetadataHelper } from '../metadata/helper.ts'; +import { throttleMetadataKey, ThrottleOptions } from './decorator.ts'; +import { ThrottlerService } from './service.ts'; +import { Injectable } from '../injector/injectable/decorator.ts'; +import { TooManyRequestsException } from '../exception/http/exceptions.ts'; + +/** + * A guard that checks throttle metadata (method/controller) and enforces limits. + */ +@Injectable() +export class ThrottleGuard implements AuthGuard { + constructor(private throttler: ThrottlerService) {} + + async canActivate(context: ExecutionContext) { + const handler = context.getHandler(); + const controller = context.getClass(); + + const methodOptions = MetadataHelper.getMetadata( + throttleMetadataKey, + handler, + ); + const controllerOptions = MetadataHelper.getMetadata( + throttleMetadataKey, + controller, + ); + const options = methodOptions || controllerOptions; + if (!options) { + // nothing to do + return true; + } + + // default key: X-Forwarded-For header, fallbacks to remote address or global + const ip = context.req.headers.get('x-forwarded-for') || context.req.headers.get('x-real-ip') || 'global'; + const key = `${context.get('_id') || ''}:${ip}:${context.getHandler().name}`; + + const count = this.throttler.consume(key, options.ttl || 60); + if (count > options.limit) { + throw new TooManyRequestsException(); + } + return true; + } +} diff --git a/src/throttler/mod.ts b/src/throttler/mod.ts new file mode 100644 index 0000000..8b9b897 --- /dev/null +++ b/src/throttler/mod.ts @@ -0,0 +1,3 @@ +export * from './decorator.ts'; +export * from './guard.ts'; +export * from './service.ts'; diff --git a/src/throttler/service.ts b/src/throttler/service.ts new file mode 100644 index 0000000..f9957f5 --- /dev/null +++ b/src/throttler/service.ts @@ -0,0 +1,31 @@ +import { Injectable } from '../injector/injectable/decorator.ts'; + +/** + * Very small in-memory throttler. + * Stores timestamps per key and prunes them on each hit. + */ +@Injectable() +export class ThrottlerService { + private store = new Map(); + + /** + * Returns current count after adding this request timestamp. + * Prunes entries older than ttl seconds. + */ + consume(key: string, ttl: number): number { + const now = Date.now(); + const windowStart = now - ttl * 1000; + const list = this.store.get(key) || []; + const pruned = list.filter((ts) => ts > windowStart); + pruned.push(now); + this.store.set(key, pruned); + return pruned.length; + } + + /** + * For tests and diagnostics: reset store + */ + reset() { + this.store.clear(); + } +} From 0978ecb2799c18dfa4a25e9cf6e8373ddf1f2ee5 Mon Sep 17 00:00:00 2001 From: Thomas Cruveilher <38007824+Sorikairox@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:52:38 +0000 Subject: [PATCH 2/5] lint: remove useless async keyword --- src/throttler/guard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/throttler/guard.ts b/src/throttler/guard.ts index 6e44052..4a75e32 100644 --- a/src/throttler/guard.ts +++ b/src/throttler/guard.ts @@ -13,7 +13,7 @@ import { TooManyRequestsException } from '../exception/http/exceptions.ts'; export class ThrottleGuard implements AuthGuard { constructor(private throttler: ThrottlerService) {} - async canActivate(context: ExecutionContext) { +canActivate(context: ExecutionContext) { const handler = context.getHandler(); const controller = context.getClass(); From 6f7968dbb2339ecef2a889bd137bb6f839d3506d Mon Sep 17 00:00:00 2001 From: Thomas Cruveilher <38007824+Sorikairox@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:54:18 +0000 Subject: [PATCH 3/5] fix: use header instead of headers --- src/throttler/guard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/throttler/guard.ts b/src/throttler/guard.ts index 4a75e32..a5e844e 100644 --- a/src/throttler/guard.ts +++ b/src/throttler/guard.ts @@ -32,7 +32,7 @@ canActivate(context: ExecutionContext) { } // default key: X-Forwarded-For header, fallbacks to remote address or global - const ip = context.req.headers.get('x-forwarded-for') || context.req.headers.get('x-real-ip') || 'global'; + const ip = context.req.header.get('x-forwarded-for') || context.req.header.get('x-real-ip') || 'global'; const key = `${context.get('_id') || ''}:${ip}:${context.getHandler().name}`; const count = this.throttler.consume(key, options.ttl || 60); From f9ae022dd5282f227c9a810f67616d49f883751e Mon Sep 17 00:00:00 2001 From: Thomas Cruveilher <38007824+Sorikairox@users.noreply.github.com> Date: Sun, 16 Nov 2025 03:58:39 +0000 Subject: [PATCH 4/5] fix: get real ip --- src/throttler/guard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/throttler/guard.ts b/src/throttler/guard.ts index a5e844e..b95d471 100644 --- a/src/throttler/guard.ts +++ b/src/throttler/guard.ts @@ -32,8 +32,8 @@ canActivate(context: ExecutionContext) { } // default key: X-Forwarded-For header, fallbacks to remote address or global - const ip = context.req.header.get('x-forwarded-for') || context.req.header.get('x-real-ip') || 'global'; - const key = `${context.get('_id') || ''}:${ip}:${context.getHandler().name}`; + const ip = context.req.header('x-forwarded-for') || context.req.header('x-real-ip') || context.req.connection?.remoteAddress || 'global'; + const key = `${ip}:${context.getHandler().name}`; const count = this.throttler.consume(key, options.ttl || 60); if (count > options.limit) { From 8e161e983e7cf891797cbfb0af60eca4bc1cab76 Mon Sep 17 00:00:00 2001 From: Thomas Cruveilher <38007824+Sorikairox@users.noreply.github.com> Date: Sun, 16 Nov 2025 04:14:05 +0000 Subject: [PATCH 5/5] fix: remove connection remote address --- src/throttler/guard.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/throttler/guard.ts b/src/throttler/guard.ts index b95d471..bc4fa47 100644 --- a/src/throttler/guard.ts +++ b/src/throttler/guard.ts @@ -32,7 +32,7 @@ canActivate(context: ExecutionContext) { } // default key: X-Forwarded-For header, fallbacks to remote address or global - const ip = context.req.header('x-forwarded-for') || context.req.header('x-real-ip') || context.req.connection?.remoteAddress || 'global'; + const ip = context.req.header('x-forwarded-for') || context.req.header('x-real-ip') || 'global'; const key = `${ip}:${context.getHandler().name}`; const count = this.throttler.consume(key, options.ttl || 60);