@clov-std/elysia-ratelimit 1.2.1 → 3.0.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/README.md +76 -15
- package/dist/index.d.ts +1 -2
- package/dist/index.js +34 -34
- package/dist/rate-limit.d.ts +68 -24
- package/package.json +12 -12
- package/dist/rate-limit.contract.d.ts +0 -5
package/README.md
CHANGED
|
@@ -22,6 +22,7 @@ Storage is handled by `@clov-std/kv-store`, so you start with in-memory and move
|
|
|
22
22
|
- [Features](#-features)
|
|
23
23
|
- [Installation](#-installation)
|
|
24
24
|
- [Usage](#-usage)
|
|
25
|
+
- [Error handling](#-error-handling)
|
|
25
26
|
- [API Reference](#-api-reference)
|
|
26
27
|
- [License](#-license)
|
|
27
28
|
- [Contact](#-contact)
|
|
@@ -33,14 +34,15 @@ Storage is handled by `@clov-std/kv-store`, so you start with in-memory and move
|
|
|
33
34
|
- 🗃️ **KvStore-agnostic** : Works with `MemoryStore` out of the box; swap in `BunRedisStore` or your own adapter.
|
|
34
35
|
- ⚡ **Early rejection** : Runs in `transform`, the first per-route hook, before auth guards and handlers.
|
|
35
36
|
- 📡 **Standard headers** : Automatically sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`.
|
|
37
|
+
- 🧯 **Works with or without an error plugin** : Answers a real `429` on its own, and steps aside when your application handles the error itself.
|
|
36
38
|
|
|
37
39
|
## 🔧 Installation
|
|
38
40
|
|
|
39
41
|
```bash
|
|
40
|
-
bun add @clov-std/elysia-ratelimit
|
|
42
|
+
bun add @clov-std/elysia-ratelimit elysia typebox
|
|
41
43
|
```
|
|
42
44
|
|
|
43
|
-
> **Peer dependencies:** `elysia` must be installed alongside.
|
|
45
|
+
> **Peer dependencies:** `elysia` (v2) and `typebox` must be installed alongside.
|
|
44
46
|
|
|
45
47
|
## ⚙️ Usage
|
|
46
48
|
|
|
@@ -54,9 +56,11 @@ import { Elysia } from 'elysia';
|
|
|
54
56
|
|
|
55
57
|
new Elysia()
|
|
56
58
|
.use(rateLimitPlugin())
|
|
57
|
-
.post(
|
|
58
|
-
|
|
59
|
-
|
|
59
|
+
.post(
|
|
60
|
+
'/auth/login',
|
|
61
|
+
{ rateLimit: { limit: 10, window: 60 } }, // 10 requests per minute per IP
|
|
62
|
+
() => authenticate()
|
|
63
|
+
)
|
|
60
64
|
.listen(3000);
|
|
61
65
|
```
|
|
62
66
|
|
|
@@ -73,9 +77,7 @@ const store = new BunRedisStore('redis://localhost:6379');
|
|
|
73
77
|
|
|
74
78
|
new Elysia()
|
|
75
79
|
.use(rateLimitPlugin(store))
|
|
76
|
-
.post('/auth/login', () => authenticate()
|
|
77
|
-
rateLimit: { limit: 10, window: 60 }
|
|
78
|
-
})
|
|
80
|
+
.post('/auth/login', { rateLimit: { limit: 10, window: 60 } }, () => authenticate())
|
|
79
81
|
.listen(3000);
|
|
80
82
|
```
|
|
81
83
|
|
|
@@ -90,18 +92,77 @@ import { Elysia } from 'elysia';
|
|
|
90
92
|
|
|
91
93
|
new Elysia()
|
|
92
94
|
.use(rateLimitPlugin())
|
|
93
|
-
.get(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
.get(
|
|
96
|
+
'/api/data',
|
|
97
|
+
{
|
|
98
|
+
rateLimit: {
|
|
99
|
+
limit: 100,
|
|
100
|
+
window: 60,
|
|
101
|
+
keyGenerator: ({ ip, request }) =>
|
|
102
|
+
`${ip}:${request.headers.get('authorization') ?? ip}`
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
() => getData()
|
|
106
|
+
)
|
|
100
107
|
.listen(3000);
|
|
101
108
|
```
|
|
102
109
|
|
|
103
110
|
> **Tip:** `extractClientIp` is exported if you need it inside your own `keyGenerator`.
|
|
104
111
|
|
|
112
|
+
## 🧯 Error handling
|
|
113
|
+
|
|
114
|
+
When the allowance is exceeded, the plugin throws a `RateLimitException` carrying:
|
|
115
|
+
|
|
116
|
+
- `key` — always `RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED`, i.e. `'elysia-ratelimit.exceeded'`
|
|
117
|
+
- `cause` — `{ limit, window, reset }`, the counter state at that moment
|
|
118
|
+
- `status` and `response` — the default answer, read by Elysia's error fallback
|
|
119
|
+
|
|
120
|
+
### Without an error plugin
|
|
121
|
+
|
|
122
|
+
The default applies and the route answers a real `429`, which is also the response the macro
|
|
123
|
+
declares for OpenAPI:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{ "type": "elysia-ratelimit.exceeded", "title": "Too Many Requests" }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The `X-RateLimit-*` headers are set before the throw, so they are present on the `429` as well.
|
|
130
|
+
|
|
131
|
+
### With an error plugin
|
|
132
|
+
|
|
133
|
+
That default is only a fallback. Any `.error()` handler your application registers runs first and
|
|
134
|
+
wins, which is how an API answers with its own localized body.
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { RateLimitException, rateLimitPlugin } from '@clov-std/elysia-ratelimit';
|
|
138
|
+
import { Elysia, status } from 'elysia';
|
|
139
|
+
|
|
140
|
+
new Elysia()
|
|
141
|
+
.error(RateLimitException, ({ error }) =>
|
|
142
|
+
status(429, {
|
|
143
|
+
type: 'request.rateLimitExceeded',
|
|
144
|
+
title: 'Too many requests. Please try again later.',
|
|
145
|
+
detail: `Retry in ${error.cause?.reset ?? 0}s.`
|
|
146
|
+
})
|
|
147
|
+
)
|
|
148
|
+
.use(rateLimitPlugin())
|
|
149
|
+
.get('/api/data', { rateLimit: { limit: 100, window: 60 } }, () => getData())
|
|
150
|
+
.listen(3000);
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Matching on the key works too, if you would rather not import the class:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
.error(({ error }) =>
|
|
157
|
+
(error as { key?: string }).key === RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED
|
|
158
|
+
? status(429, { type: 'request.rateLimitExceeded', title: 'Too many requests.' })
|
|
159
|
+
: status(500, { type: 'internal', title: 'Internal Server Error' })
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
> **Note:** Elysia runs the first matching `.error()` handler. If your error plugin registers a
|
|
164
|
+
> catch-all, register it after the specific handlers, otherwise it swallows them.
|
|
165
|
+
|
|
105
166
|
## 📚 API Reference
|
|
106
167
|
|
|
107
168
|
Full docs: [https://clovlabs.github.io/std/](https://clovlabs.github.io/std/)
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export { rateLimitContract } from './rate-limit.contract';
|
|
1
|
+
export { RATE_LIMIT_ERROR_KEYS, RateLimitException, extractClientIp, rateLimitPlugin, type RateLimitExceeded, type RateLimitKeyContext, type RateLimitMacroOptions } from './rate-limit';
|
package/dist/index.js
CHANGED
|
@@ -1,23 +1,36 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/rate-limit.ts
|
|
3
|
-
import {
|
|
3
|
+
import { Exception } from "@clov-std/error";
|
|
4
4
|
import { MemoryStore } from "@clov-std/kv-store";
|
|
5
|
-
import { Elysia } from "elysia";
|
|
6
|
-
|
|
7
|
-
// src/rate-limit.contract.ts
|
|
8
|
-
import { t } from "elysia";
|
|
9
|
-
var rateLimitContract = {
|
|
10
|
-
429: t.Object({
|
|
11
|
-
message: t.String({
|
|
12
|
-
description: "Error message indicating that the rate limit has been exceeded."
|
|
13
|
-
})
|
|
14
|
-
})
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
// src/rate-limit.ts
|
|
5
|
+
import { Elysia, t } from "elysia";
|
|
18
6
|
var RATE_LIMIT_ERROR_KEYS = {
|
|
19
7
|
RATE_LIMIT_EXCEEDED: "elysia-ratelimit.exceeded"
|
|
20
8
|
};
|
|
9
|
+
var rateLimitResponseSchema = t.Object({
|
|
10
|
+
type: t.String({
|
|
11
|
+
description: `Error key. Defaults to \`${RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED}\`.`
|
|
12
|
+
}),
|
|
13
|
+
title: t.String({
|
|
14
|
+
description: "Short, human-readable summary of the error."
|
|
15
|
+
}),
|
|
16
|
+
detail: t.Optional(t.String({
|
|
17
|
+
description: "Explanation specific to this occurrence."
|
|
18
|
+
}))
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
class RateLimitException extends Exception {
|
|
22
|
+
status = 429;
|
|
23
|
+
response = {
|
|
24
|
+
type: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
|
|
25
|
+
title: "Too Many Requests"
|
|
26
|
+
};
|
|
27
|
+
constructor(cause) {
|
|
28
|
+
super("Too Many Requests", {
|
|
29
|
+
key: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
|
|
30
|
+
cause
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
21
34
|
var extractClientIp = (request, server) => {
|
|
22
35
|
const forwarded = request.headers.get("x-forwarded-for");
|
|
23
36
|
if (forwarded) {
|
|
@@ -27,16 +40,9 @@ var extractClientIp = (request, server) => {
|
|
|
27
40
|
}
|
|
28
41
|
return request.headers.get("x-real-ip") ?? server?.requestIP(request)?.address ?? "127.0.0.1";
|
|
29
42
|
};
|
|
30
|
-
var rateLimitPlugin = (store = new MemoryStore) => new Elysia({
|
|
31
|
-
name: "rateLimitPlugin",
|
|
32
|
-
seed: store
|
|
33
|
-
}).model({
|
|
34
|
-
RateLimitError: rateLimitContract[429]
|
|
35
|
-
}).macro({
|
|
43
|
+
var rateLimitPlugin = (store = new MemoryStore) => new Elysia().macro({
|
|
36
44
|
rateLimit: ({ limit, window, keyGenerator }) => ({
|
|
37
|
-
response: {
|
|
38
|
-
429: "RateLimitError"
|
|
39
|
-
},
|
|
45
|
+
response: { 429: rateLimitResponseSchema },
|
|
40
46
|
transform: async ({ set, request, server }) => {
|
|
41
47
|
const route = `${request.method}:${new URL(request.url).pathname}`;
|
|
42
48
|
const ip = extractClientIp(request, server);
|
|
@@ -46,24 +52,18 @@ var rateLimitPlugin = (store = new MemoryStore) => new Elysia({
|
|
|
46
52
|
if (count === 1)
|
|
47
53
|
await store.expire(key, window);
|
|
48
54
|
const remaining = Math.max(0, limit - count);
|
|
49
|
-
const
|
|
55
|
+
const reset = await store.ttl(key);
|
|
50
56
|
set.headers["X-RateLimit-Limit"] = limit.toString();
|
|
51
57
|
set.headers["X-RateLimit-Remaining"] = remaining.toString();
|
|
52
|
-
set.headers["X-RateLimit-Reset"] =
|
|
53
|
-
if (count > limit)
|
|
54
|
-
|
|
55
|
-
throw new HttpException("Too Many Requests", {
|
|
56
|
-
key: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
|
|
57
|
-
cause: { limit, window, remaining: 0, reset: resetTime },
|
|
58
|
-
status: 429
|
|
59
|
-
});
|
|
60
|
-
}
|
|
58
|
+
set.headers["X-RateLimit-Reset"] = reset.toString();
|
|
59
|
+
if (count > limit)
|
|
60
|
+
throw new RateLimitException({ limit, window, reset });
|
|
61
61
|
}
|
|
62
62
|
})
|
|
63
63
|
});
|
|
64
64
|
export {
|
|
65
65
|
rateLimitPlugin,
|
|
66
|
-
rateLimitContract,
|
|
67
66
|
extractClientIp,
|
|
67
|
+
RateLimitException,
|
|
68
68
|
RATE_LIMIT_ERROR_KEYS
|
|
69
69
|
};
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -1,10 +1,55 @@
|
|
|
1
|
+
import { Exception } from '@clov-std/error';
|
|
1
2
|
import { type KvStore } from '@clov-std/kv-store';
|
|
2
3
|
import type { Server } from 'bun';
|
|
3
|
-
import { Elysia, type HTTPHeaders
|
|
4
|
-
import { rateLimitContract } from './rate-limit.contract';
|
|
4
|
+
import { Elysia, type HTTPHeaders } from 'elysia';
|
|
5
5
|
export declare const RATE_LIMIT_ERROR_KEYS: {
|
|
6
|
-
readonly RATE_LIMIT_EXCEEDED:
|
|
6
|
+
readonly RATE_LIMIT_EXCEEDED: 'elysia-ratelimit.exceeded';
|
|
7
7
|
};
|
|
8
|
+
/** Counter state at the moment the limit was exceeded, attached as the exception `cause`. */
|
|
9
|
+
export interface RateLimitExceeded {
|
|
10
|
+
/** Maximum requests allowed within the window. */
|
|
11
|
+
readonly limit: number;
|
|
12
|
+
/** Window length in seconds. */
|
|
13
|
+
readonly window: number;
|
|
14
|
+
/** Seconds until the counter resets. */
|
|
15
|
+
readonly reset: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Shape of the `429` body.
|
|
19
|
+
*
|
|
20
|
+
* `type` is a plain string, not a literal: an application error handler is free to answer
|
|
21
|
+
* with its own key (a localized catalog entry, for instance). `detail` is optional for the
|
|
22
|
+
* same reason, the plugin never sets it.
|
|
23
|
+
*/
|
|
24
|
+
declare const rateLimitResponseSchema: import("typebox").TObject<{
|
|
25
|
+
type: import("typebox").TString;
|
|
26
|
+
title: import("typebox").TString;
|
|
27
|
+
detail: import("typebox").TOptional<import("typebox").TString>;
|
|
28
|
+
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Thrown when a caller exceeds its allowance.
|
|
31
|
+
*
|
|
32
|
+
* `status` and `response` are read by Elysia's error fallback, so an application that
|
|
33
|
+
* installs no error handler still answers a real `429` with the documented body. They are
|
|
34
|
+
* only a default: any `.error()` handler registered by the application runs first and wins,
|
|
35
|
+
* which is what lets an API localize this error from its own catalog. Match on
|
|
36
|
+
* {@link Exception.key}, not on the message.
|
|
37
|
+
*/
|
|
38
|
+
export declare class RateLimitException extends Exception<RateLimitExceeded> {
|
|
39
|
+
/** HTTP status used by Elysia's error fallback. */
|
|
40
|
+
readonly status = 429;
|
|
41
|
+
/** Default body used by Elysia's error fallback. */
|
|
42
|
+
readonly response: {
|
|
43
|
+
readonly type: string;
|
|
44
|
+
readonly title: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Creates a new rate limit exception.
|
|
48
|
+
*
|
|
49
|
+
* @param cause - Counter state at the moment the limit was exceeded.
|
|
50
|
+
*/
|
|
51
|
+
constructor(cause: RateLimitExceeded);
|
|
52
|
+
}
|
|
8
53
|
export interface RateLimitKeyContext {
|
|
9
54
|
request: Request;
|
|
10
55
|
server: Server<unknown> | null;
|
|
@@ -15,38 +60,37 @@ export interface RateLimitMacroOptions {
|
|
|
15
60
|
window: number;
|
|
16
61
|
keyGenerator?: (context: RateLimitKeyContext) => string;
|
|
17
62
|
}
|
|
63
|
+
interface RateLimitTransformContext {
|
|
64
|
+
set: {
|
|
65
|
+
headers: HTTPHeaders;
|
|
66
|
+
};
|
|
67
|
+
request: Request;
|
|
68
|
+
server: Server<unknown> | null;
|
|
69
|
+
}
|
|
70
|
+
type RateLimitMacro = (options: RateLimitMacroOptions) => {
|
|
71
|
+
response: {
|
|
72
|
+
429: typeof rateLimitResponseSchema;
|
|
73
|
+
};
|
|
74
|
+
transform: (context: RateLimitTransformContext) => Promise<void>;
|
|
75
|
+
};
|
|
18
76
|
export declare const extractClientIp: (request: Request, server: Server<unknown> | null) => string;
|
|
19
|
-
export declare const rateLimitPlugin: (store?: KvStore) => Elysia<
|
|
77
|
+
export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'', 'local', {
|
|
20
78
|
decorator: {};
|
|
21
79
|
derive: {};
|
|
22
|
-
resolve: {};
|
|
23
80
|
store: {};
|
|
24
81
|
}, {
|
|
25
|
-
typebox: {
|
|
26
|
-
|
|
27
|
-
};
|
|
28
|
-
error: {};
|
|
82
|
+
typebox: {};
|
|
83
|
+
error: [];
|
|
29
84
|
}, {
|
|
85
|
+
schema: {};
|
|
86
|
+
schemas: {};
|
|
30
87
|
macro: Partial<{
|
|
31
88
|
readonly rateLimit: RateLimitMacroOptions;
|
|
32
89
|
}>;
|
|
33
90
|
macroFn: {
|
|
34
|
-
rateLimit:
|
|
35
|
-
response: {
|
|
36
|
-
429: "RateLimitError";
|
|
37
|
-
};
|
|
38
|
-
transform: ({ set, request, server }: {
|
|
39
|
-
set: {
|
|
40
|
-
headers: HTTPHeaders;
|
|
41
|
-
status?: number | keyof StatusMap;
|
|
42
|
-
};
|
|
43
|
-
request: Request;
|
|
44
|
-
server: Server<unknown> | null;
|
|
45
|
-
}) => Promise<void>;
|
|
46
|
-
};
|
|
91
|
+
rateLimit: RateLimitMacro;
|
|
47
92
|
};
|
|
48
93
|
parser: {};
|
|
49
94
|
response: {};
|
|
50
|
-
schema: {};
|
|
51
|
-
standaloneSchema: {};
|
|
52
95
|
}>;
|
|
96
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clov-std/elysia-ratelimit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Rate limiting plugin for Elysia framework.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -40,20 +40,20 @@
|
|
|
40
40
|
"test": "bun test --pass-with-no-tests --coverage"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@clov-std/error": "
|
|
44
|
-
"@clov-std/kv-store": "
|
|
45
|
-
"elysia": "^1.4.29"
|
|
43
|
+
"@clov-std/error": "^4.0.0",
|
|
44
|
+
"@clov-std/kv-store": "^1.1.2"
|
|
46
45
|
},
|
|
47
46
|
"devDependencies": {
|
|
48
|
-
"@types/bun": "
|
|
49
|
-
"oxfmt": "
|
|
50
|
-
"oxlint": "
|
|
51
|
-
"oxlint-tsgolint": "
|
|
52
|
-
"typescript": "
|
|
47
|
+
"@types/bun": "^1.3.14",
|
|
48
|
+
"oxfmt": "0.61.0",
|
|
49
|
+
"oxlint": "1.76.0",
|
|
50
|
+
"oxlint-tsgolint": "7.0.2001",
|
|
51
|
+
"typescript": "^7.0.2"
|
|
53
52
|
},
|
|
54
53
|
"peerDependencies": {
|
|
55
|
-
"@clov-std/error": "
|
|
56
|
-
"@clov-std/kv-store": "
|
|
57
|
-
"elysia": "^
|
|
54
|
+
"@clov-std/error": "^4.0.0",
|
|
55
|
+
"@clov-std/kv-store": "^1.1.2",
|
|
56
|
+
"elysia": "^2.0.0-beta.1",
|
|
57
|
+
"typebox": "^1.3.10"
|
|
58
58
|
}
|
|
59
59
|
}
|