@kanjijs/throttler 0.2.0-beta.1
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/README.md +59 -0
- package/package.json +14 -0
- package/src/index.ts +6 -0
- package/src/throttler.decorator.ts +27 -0
- package/src/throttler.guard.ts +85 -0
- package/src/throttler.module.ts +36 -0
- package/src/throttler.service.ts +50 -0
- package/src/throttler.storage.ts +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @kanjijs/throttler
|
|
2
|
+
|
|
3
|
+
Rate limiting module for the Kanjijs Framework. Protects your application from brute-force attacks and abuse.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @kanjijs/throttler
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### 1. Global Configuration
|
|
14
|
+
|
|
15
|
+
Register the module in your root `AppModule`. This will apply the limit to **ALL** routes by default.
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { Module } from "@kanjijs/core";
|
|
19
|
+
import { ThrottlerModule } from "@kanjijs/throttler";
|
|
20
|
+
|
|
21
|
+
@Module({
|
|
22
|
+
imports: [
|
|
23
|
+
ThrottlerModule.forRoot({
|
|
24
|
+
limit: 10, // Max requests
|
|
25
|
+
ttl: 60 // Time window in seconds
|
|
26
|
+
})
|
|
27
|
+
]
|
|
28
|
+
})
|
|
29
|
+
export class AppModule {}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
### 2. Customizing Specific Routes
|
|
33
|
+
|
|
34
|
+
Use the `@Throttle` decorator to override the global settings for specific methods or controllers.
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import { Controller, Get } from "@kanjijs/core";
|
|
38
|
+
import { Throttle } from "@kanjijs/throttler";
|
|
39
|
+
|
|
40
|
+
@Controller("/files")
|
|
41
|
+
export class FileController {
|
|
42
|
+
|
|
43
|
+
@Get("/download")
|
|
44
|
+
@Throttle({ limit: 3, ttl: 3600 }) // Stricter limit: 3 downloads per hour
|
|
45
|
+
downloadFile() {
|
|
46
|
+
return "File content...";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- **Global Protection**: Acts as a Global Middleware automatically registered via `KanjijsAdapter`.
|
|
54
|
+
- **Headers**: Automatically adds standard rate limit headers:
|
|
55
|
+
- `X-RateLimit-Limit`
|
|
56
|
+
- `X-RateLimit-Remaining`
|
|
57
|
+
- `X-RateLimit-Reset`
|
|
58
|
+
- `Retry-After` (on 429)
|
|
59
|
+
- **Storage**: Currently supports In-Memory storage. Redis support coming soon.
|
package/package.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kanjijs/throttler",
|
|
3
|
+
"version": "0.2.0-beta.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@kanjijs/core": "workspace:*",
|
|
8
|
+
"hono": "^4.0.0",
|
|
9
|
+
"reflect-metadata": "^0.2.0"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun"
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Use, KanjijsIoC } from "@kanjijs/core";
|
|
2
|
+
import { type Context, type Next } from "hono";
|
|
3
|
+
import { ThrottlerService } from "./throttler.service";
|
|
4
|
+
|
|
5
|
+
export interface ThrottlerOptions {
|
|
6
|
+
limit: number;
|
|
7
|
+
ttl: number; // seconds
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Throttle(options: ThrottlerOptions) {
|
|
11
|
+
return Use(async (c: Context, next: Next) => {
|
|
12
|
+
// Resolve singleton
|
|
13
|
+
const throttler = KanjijsIoC.resolve(ThrottlerService);
|
|
14
|
+
|
|
15
|
+
if (throttler) {
|
|
16
|
+
// Use specific options from decorator
|
|
17
|
+
const allowed = await throttler.handleRequest(c, options.limit, options.ttl);
|
|
18
|
+
if (!allowed) return; // Blocked (handleRequest sets status/body)
|
|
19
|
+
} else {
|
|
20
|
+
// Fallback or warning? If module is not imported, throttler service won't exist.
|
|
21
|
+
// It's safe to just next() or warn.
|
|
22
|
+
console.warn("@Throttle used but ThrottlerModule not imported/provided.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
await next();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Inject, Injectable, GLOBAL_MIDDLEWARE_TOKEN } from "@kanjijs/core";
|
|
2
|
+
import { type Context, type Next } from "hono";
|
|
3
|
+
import { ThrottlerInMemoryStorage, type ThrottlerStorage } from "./throttler.storage";
|
|
4
|
+
import { THROTTLER_LIMIT, THROTTLER_TTL } from "./throttler.decorator";
|
|
5
|
+
|
|
6
|
+
export interface ThrottlerModuleOptions {
|
|
7
|
+
limit?: number;
|
|
8
|
+
ttl?: number;
|
|
9
|
+
storage?: ThrottlerStorage;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const THROTTLER_OPTIONS = "THROTTLER_OPTIONS";
|
|
13
|
+
|
|
14
|
+
@Injectable()
|
|
15
|
+
export class ThrottlerGuard {
|
|
16
|
+
private storage: ThrottlerStorage;
|
|
17
|
+
private defaultLimit: number;
|
|
18
|
+
private defaultTtl: number;
|
|
19
|
+
|
|
20
|
+
constructor(@Inject(THROTTLER_OPTIONS) private options: ThrottlerModuleOptions) {
|
|
21
|
+
this.storage = options.storage || new ThrottlerInMemoryStorage();
|
|
22
|
+
this.defaultLimit = options.limit || 10;
|
|
23
|
+
this.defaultTtl = options.ttl || 60;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// This will be registered as a Global Middleware in KanjijsAdapter if provided
|
|
27
|
+
async handle(c: Context, next: Next) {
|
|
28
|
+
// 1. Identify Client (IP)
|
|
29
|
+
// Hono "conn" info or header
|
|
30
|
+
/*
|
|
31
|
+
NOTE: In Bun/Hono, getting IP can be platform specific.
|
|
32
|
+
We will trust X-Forwarded-For or use a fallback.
|
|
33
|
+
*/
|
|
34
|
+
const ip = c.req.header("x-forwarded-for") || "unknown";
|
|
35
|
+
const key = `throttler:${ip}:${c.req.path}`; // Limit by IP + Path (or just IP?) -> Usually just IP for global
|
|
36
|
+
|
|
37
|
+
// 2. Resolve Metadata (Limit/TTL)
|
|
38
|
+
// KanjijsAdapter doesn't expose the "Current Handler" to global middleware easily yet in the context.
|
|
39
|
+
// LIMITATION: Global Middleware runs BEFORE routing in Hono standard app.use('*').
|
|
40
|
+
// To support @Throttle decorators, we need to run THIS check inside the route handler wrapper OR
|
|
41
|
+
// we need to access the route handler metadata here.
|
|
42
|
+
|
|
43
|
+
// CURRENT STRATEGY:
|
|
44
|
+
// Global Config applies to ALL routes globally (Middleware style).
|
|
45
|
+
// @Throttle decorator support requires Per-Route logic.
|
|
46
|
+
// Since KanjijsAdapter registers routes, we should probably implement this as a specific middleware wrapper there,
|
|
47
|
+
// OR we rely on Hono context to pass handler info? No standard way.
|
|
48
|
+
|
|
49
|
+
// REVISED PLAN:
|
|
50
|
+
// We will implement `ThrottlerGuard` but we cannot easily use it as a simple `app.use('*')`
|
|
51
|
+
// IF we want to support @Throttle decorators overriding it properly *per route* efficiently without finding the route match twice.
|
|
52
|
+
|
|
53
|
+
// HOWEVER, for MVP:
|
|
54
|
+
// 1. Global Throttling: Runs on `*`, uses default limit.
|
|
55
|
+
// 2. Decorator Throttling: The adapter needs to know about it.
|
|
56
|
+
|
|
57
|
+
// BUT the user asked for: "rate limit haz que tambien se pueda configurar desde main y no solo usando el decorador"
|
|
58
|
+
|
|
59
|
+
// Let's stick to the plan:
|
|
60
|
+
// If we use Global Middleware, we enforce global limit.
|
|
61
|
+
// If we want specific limits, we need `KanjijsAdapter` to help us OR we attach `ThrottlerGuard` to the route in the Adapter.
|
|
62
|
+
|
|
63
|
+
// DECISION:
|
|
64
|
+
// This `handle` method will apply the DEFAULT global limit if installed globally.
|
|
65
|
+
// For Decorator support, `KanjijsAdapter` needs to look for `@Throttle` metadata and add THIS middleware (configured differently) to that route.
|
|
66
|
+
|
|
67
|
+
const limit = this.defaultLimit;
|
|
68
|
+
const ttl = this.defaultTtl;
|
|
69
|
+
|
|
70
|
+
const { total, timeToExpire } = await this.storage.increment(key, ttl);
|
|
71
|
+
|
|
72
|
+
if (total > limit) {
|
|
73
|
+
return c.text(`Too Many Requests`, 429, {
|
|
74
|
+
"Retry-After": timeToExpire.toString()
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Headers
|
|
79
|
+
c.header("X-RateLimit-Limit", limit.toString());
|
|
80
|
+
c.header("X-RateLimit-Remaining", Math.max(0, limit - total).toString());
|
|
81
|
+
c.header("X-RateLimit-Reset", timeToExpire.toString());
|
|
82
|
+
|
|
83
|
+
await next();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Module, GLOBAL_MIDDLEWARE_TOKEN, KanjijsIoC } from "@kanjijs/core";
|
|
2
|
+
import type { Context, Next } from "hono";
|
|
3
|
+
import { ThrottlerService, THROTTLER_OPTIONS, type ThrottlerModuleOptions } from "./throttler.service";
|
|
4
|
+
|
|
5
|
+
@Module({})
|
|
6
|
+
export class ThrottlerModule {
|
|
7
|
+
static forRoot(options: ThrottlerModuleOptions) {
|
|
8
|
+
return {
|
|
9
|
+
module: ThrottlerModule,
|
|
10
|
+
providers: [
|
|
11
|
+
{
|
|
12
|
+
provide: THROTTLER_OPTIONS,
|
|
13
|
+
useValue: options,
|
|
14
|
+
},
|
|
15
|
+
ThrottlerService,
|
|
16
|
+
{
|
|
17
|
+
provide: GLOBAL_MIDDLEWARE_TOKEN,
|
|
18
|
+
useValue: async (c: Context, next: Next) => {
|
|
19
|
+
// console.log("Throttler Middleware Executing via Global Token");
|
|
20
|
+
const throttler = KanjijsIoC.resolve(ThrottlerService);
|
|
21
|
+
if (throttler) {
|
|
22
|
+
const { limit, ttl } = throttler.getGlobalOptions();
|
|
23
|
+
const allowed = await throttler.handleRequest(c, limit, ttl);
|
|
24
|
+
if (!allowed) {
|
|
25
|
+
c.header("Retry-After", ttl.toString());
|
|
26
|
+
return c.text("Too Many Requests", 429);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
await next();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
exports: [ThrottlerService],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Inject, Injectable } from "@kanjijs/core";
|
|
2
|
+
import { type Context } from "hono";
|
|
3
|
+
import { ThrottlerInMemoryStorage, type ThrottlerStorage } from "./throttler.storage";
|
|
4
|
+
|
|
5
|
+
export interface ThrottlerModuleOptions {
|
|
6
|
+
limit?: number;
|
|
7
|
+
ttl?: number;
|
|
8
|
+
storage?: ThrottlerStorage;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const THROTTLER_OPTIONS = "THROTTLER_OPTIONS";
|
|
12
|
+
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class ThrottlerService {
|
|
15
|
+
private storage: ThrottlerStorage;
|
|
16
|
+
private defaultLimit: number;
|
|
17
|
+
private defaultTtl: number;
|
|
18
|
+
|
|
19
|
+
constructor(@Inject(THROTTLER_OPTIONS) private options: ThrottlerModuleOptions) {
|
|
20
|
+
this.storage = options.storage || new ThrottlerInMemoryStorage();
|
|
21
|
+
this.defaultLimit = options.limit || 10;
|
|
22
|
+
this.defaultTtl = options.ttl || 60;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
getGlobalOptions() {
|
|
26
|
+
return { limit: this.defaultLimit, ttl: this.defaultTtl };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async handleRequest(c: Context, limit: number, ttl: number): Promise<boolean> {
|
|
30
|
+
const ip = c.req.header("x-forwarded-for") || "unknown";
|
|
31
|
+
const path = c.req.path;
|
|
32
|
+
const key = `throttler:${ip}:${path}`;
|
|
33
|
+
|
|
34
|
+
const { total, timeToExpire } = await this.storage.increment(key, ttl);
|
|
35
|
+
|
|
36
|
+
// Headers
|
|
37
|
+
c.header("X-RateLimit-Limit", limit.toString());
|
|
38
|
+
c.header("X-RateLimit-Remaining", Math.max(0, limit - total).toString());
|
|
39
|
+
c.header("X-RateLimit-Reset", timeToExpire.toString());
|
|
40
|
+
|
|
41
|
+
if (total > limit) {
|
|
42
|
+
c.status(429);
|
|
43
|
+
c.header("Retry-After", timeToExpire.toString());
|
|
44
|
+
c.text(`Too Many Requests`); // Body
|
|
45
|
+
return false; // Blocks request
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return true; // Allowed
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ThrottlerStorage {
|
|
2
|
+
increment(key: string, ttl: number): Promise<{ total: number; timeToExpire: number }>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export class ThrottlerInMemoryStorage implements ThrottlerStorage {
|
|
6
|
+
private storage = new Map<string, { total: number; expiresAt: number }>();
|
|
7
|
+
|
|
8
|
+
async increment(key: string, ttl: number): Promise<{ total: number; timeToExpire: number }> {
|
|
9
|
+
const now = Date.now();
|
|
10
|
+
const record = this.storage.get(key);
|
|
11
|
+
|
|
12
|
+
if (!record || record.expiresAt < now) {
|
|
13
|
+
const expiresAt = now + ttl * 1000;
|
|
14
|
+
this.storage.set(key, { total: 1, expiresAt });
|
|
15
|
+
return { total: 1, timeToExpire: ttl };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
record.total++;
|
|
19
|
+
const timeToExpire = Math.ceil((record.expiresAt - now) / 1000);
|
|
20
|
+
return { total: record.total, timeToExpire: Math.max(0, timeToExpire) };
|
|
21
|
+
}
|
|
22
|
+
}
|