@clov-std/elysia-ratelimit 3.1.1 → 4.0.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 +14 -47
- package/dist/index.d.ts +1 -1
- package/dist/index.js +15 -30
- package/dist/rate-limit.d.ts +9 -37
- package/package.json +5 -7
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Storage is handled by `@clov-std/kv-store`, so you start with in-memory and move
|
|
|
34
34
|
- 🗃️ **KvStore-agnostic** : Works with `MemoryStore` out of the box; swap in `BunRedisStore` or your own adapter.
|
|
35
35
|
- ⚡ **Early rejection** : Runs in `transform`, the first per-route hook, before auth guards and handlers.
|
|
36
36
|
- 📡 **Standard headers** : Automatically sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`.
|
|
37
|
-
- 🧯 **
|
|
37
|
+
- 🧯 **Complete RFC 9457 `429`** : Answers a real problem document on its own, `detail` included, with no exception class to import.
|
|
38
38
|
|
|
39
39
|
## 🔧 Installation
|
|
40
40
|
|
|
@@ -111,57 +111,24 @@ new Elysia()
|
|
|
111
111
|
|
|
112
112
|
## 🧯 Error handling
|
|
113
113
|
|
|
114
|
-
When the allowance is exceeded, the plugin throws
|
|
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:
|
|
114
|
+
When the allowance is exceeded, the plugin throws Elysia's `problem()`, so the `429` is a complete
|
|
115
|
+
RFC 9457 document with no exception class and no error dependency involved:
|
|
124
116
|
|
|
125
117
|
```json
|
|
126
|
-
{
|
|
118
|
+
{
|
|
119
|
+
"type": "rate-limit.quota.exceeded",
|
|
120
|
+
"title": "Too Many Requests",
|
|
121
|
+
"status": 429,
|
|
122
|
+
"detail": "Limit of 100 requests per 60s exceeded. Retry in 42s."
|
|
123
|
+
}
|
|
127
124
|
```
|
|
128
125
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
```
|
|
126
|
+
`type` is always `RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED`. `title` is derived by Elysia from the
|
|
127
|
+
status reason phrase. `detail` carries the live counter state, so it changes per occurrence.
|
|
128
|
+
The content type is `application/problem+json`, and the `X-RateLimit-*` headers are set before
|
|
129
|
+
the throw so they survive on the `429`.
|
|
162
130
|
|
|
163
|
-
|
|
164
|
-
> catch-all, register it after the specific handlers, otherwise it swallows them.
|
|
131
|
+
This is also the response the macro declares for OpenAPI, so a typed client sees it.
|
|
165
132
|
|
|
166
133
|
## 📚 API Reference
|
|
167
134
|
|
package/dist/index.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { RATE_LIMIT_ERROR_CODES, extractClientIp, rateLimitPlugin, type RateLimitKeyContext, type RateLimitMacroOptions } from './rate-limit';
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/rate-limit.ts
|
|
3
|
-
import { Exception } from "@clov-std/error";
|
|
4
3
|
import { MemoryStore } from "@clov-std/kv-store";
|
|
5
|
-
import { Elysia, t } from "elysia";
|
|
6
|
-
var
|
|
7
|
-
|
|
4
|
+
import { Elysia, problem, t } from "elysia";
|
|
5
|
+
var RATE_LIMIT_ERROR_CODES = {
|
|
6
|
+
QUOTA_EXCEEDED: "rate-limit.quota.exceeded"
|
|
8
7
|
};
|
|
9
8
|
var rateLimitResponseSchema = t.Object({
|
|
10
9
|
type: t.String({
|
|
11
|
-
description: `Error
|
|
10
|
+
description: `Error code. Always \`${RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED}\`.`
|
|
12
11
|
}),
|
|
13
12
|
title: t.String({
|
|
14
13
|
description: "Short, human-readable summary of the error."
|
|
@@ -16,25 +15,10 @@ var rateLimitResponseSchema = t.Object({
|
|
|
16
15
|
status: t.Literal(429, {
|
|
17
16
|
description: "HTTP status code, repeated in the body per RFC 9457."
|
|
18
17
|
}),
|
|
19
|
-
detail: t.
|
|
20
|
-
description: "Explanation specific to this occurrence."
|
|
21
|
-
})
|
|
18
|
+
detail: t.String({
|
|
19
|
+
description: "Explanation specific to this occurrence, including when to retry."
|
|
20
|
+
})
|
|
22
21
|
});
|
|
23
|
-
|
|
24
|
-
class RateLimitException extends Exception {
|
|
25
|
-
status = 429;
|
|
26
|
-
response = {
|
|
27
|
-
type: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
|
|
28
|
-
title: "Too Many Requests",
|
|
29
|
-
status: 429
|
|
30
|
-
};
|
|
31
|
-
constructor(cause) {
|
|
32
|
-
super("Too Many Requests", {
|
|
33
|
-
key: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
|
|
34
|
-
cause
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
22
|
var extractClientIp = (request, server) => {
|
|
39
23
|
const forwarded = request.headers.get("x-forwarded-for");
|
|
40
24
|
if (forwarded) {
|
|
@@ -44,7 +28,7 @@ var extractClientIp = (request, server) => {
|
|
|
44
28
|
}
|
|
45
29
|
return request.headers.get("x-real-ip") ?? server?.requestIP(request)?.address ?? "127.0.0.1";
|
|
46
30
|
};
|
|
47
|
-
var rateLimitPlugin = (store = new MemoryStore) => new Elysia().macro({
|
|
31
|
+
var rateLimitPlugin = (store = new MemoryStore) => new Elysia({ name: "rateLimitPlugin" }).macro({
|
|
48
32
|
rateLimit: ({
|
|
49
33
|
limit,
|
|
50
34
|
window,
|
|
@@ -64,16 +48,17 @@ var rateLimitPlugin = (store = new MemoryStore) => new Elysia().macro({
|
|
|
64
48
|
set.headers["X-RateLimit-Limit"] = limit.toString();
|
|
65
49
|
set.headers["X-RateLimit-Remaining"] = remaining.toString();
|
|
66
50
|
set.headers["X-RateLimit-Reset"] = reset.toString();
|
|
67
|
-
if (count > limit)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
51
|
+
if (count > limit)
|
|
52
|
+
throw problem({
|
|
53
|
+
type: RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED,
|
|
54
|
+
status: 429,
|
|
55
|
+
detail: `Limit of ${limit} requests per ${window}s exceeded. Retry in ${reset}s.`
|
|
56
|
+
});
|
|
71
57
|
}
|
|
72
58
|
})
|
|
73
59
|
});
|
|
74
60
|
export {
|
|
75
61
|
rateLimitPlugin,
|
|
76
62
|
extractClientIp,
|
|
77
|
-
|
|
78
|
-
RATE_LIMIT_ERROR_KEYS
|
|
63
|
+
RATE_LIMIT_ERROR_CODES
|
|
79
64
|
};
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -1,50 +1,22 @@
|
|
|
1
|
-
import { Exception } from '@clov-std/error';
|
|
2
1
|
import { type KvStore } from '@clov-std/kv-store';
|
|
3
2
|
import type { Server } from 'bun';
|
|
4
3
|
import { Elysia, type HTTPHeaders } from 'elysia';
|
|
5
|
-
import type { TLiteral, TObject,
|
|
6
|
-
export declare const
|
|
7
|
-
readonly
|
|
4
|
+
import type { TLiteral, TObject, TString } from 'typebox/type';
|
|
5
|
+
export declare const RATE_LIMIT_ERROR_CODES: {
|
|
6
|
+
readonly QUOTA_EXCEEDED: 'rate-limit.quota.exceeded';
|
|
8
7
|
};
|
|
9
|
-
/** Counter state at the moment the limit was exceeded, attached as the exception `cause`. */
|
|
10
|
-
export interface RateLimitExceeded {
|
|
11
|
-
/** Maximum requests allowed within the window. */
|
|
12
|
-
readonly limit: number;
|
|
13
|
-
/** Window length in seconds. */
|
|
14
|
-
readonly window: number;
|
|
15
|
-
/** Seconds until the counter resets. */
|
|
16
|
-
readonly reset: number;
|
|
17
|
-
}
|
|
18
8
|
/**
|
|
19
|
-
* Shape of the `429` body,
|
|
9
|
+
* Shape of the `429` body, an RFC 9457 problem document.
|
|
10
|
+
*
|
|
11
|
+
* `title` is filled by Elysia from the status reason phrase, so the plugin only supplies
|
|
12
|
+
* `type`, `status`, and `detail`.
|
|
20
13
|
*/
|
|
21
14
|
declare const rateLimitResponseSchema: TObject<{
|
|
22
15
|
type: TString;
|
|
23
16
|
title: TString;
|
|
24
17
|
status: TLiteral<429>;
|
|
25
|
-
detail:
|
|
18
|
+
detail: TString;
|
|
26
19
|
}>;
|
|
27
|
-
/**
|
|
28
|
-
* Thrown when a caller exceeds its allowance.
|
|
29
|
-
*
|
|
30
|
-
* @see {@link rateLimitResponseSchema}
|
|
31
|
-
*/
|
|
32
|
-
export declare class RateLimitException extends Exception<RateLimitExceeded> {
|
|
33
|
-
/** HTTP status used by Elysia's error fallback. */
|
|
34
|
-
readonly status = 429;
|
|
35
|
-
/** Default body used by Elysia's error fallback. */
|
|
36
|
-
readonly response: {
|
|
37
|
-
readonly type: string;
|
|
38
|
-
readonly title: string;
|
|
39
|
-
readonly status: 429;
|
|
40
|
-
};
|
|
41
|
-
/**
|
|
42
|
-
* Creates a new rate limit exception.
|
|
43
|
-
*
|
|
44
|
-
* @param cause - Counter state at the moment the limit was exceeded.
|
|
45
|
-
*/
|
|
46
|
-
constructor(cause: RateLimitExceeded);
|
|
47
|
-
}
|
|
48
20
|
export interface RateLimitKeyContext {
|
|
49
21
|
request: Request;
|
|
50
22
|
server: Server<unknown> | null;
|
|
@@ -69,7 +41,7 @@ type RateLimitMacro = (options: RateLimitMacroOptions) => {
|
|
|
69
41
|
transform: (context: RateLimitTransformContext) => Promise<void>;
|
|
70
42
|
};
|
|
71
43
|
export declare const extractClientIp: (request: Request, server: Server<unknown> | null) => string;
|
|
72
|
-
export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'', 'local', {
|
|
44
|
+
export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'rateLimitPlugin', 'local', {
|
|
73
45
|
decorator: {};
|
|
74
46
|
derive: {};
|
|
75
47
|
store: {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clov-std/elysia-ratelimit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.1",
|
|
4
4
|
"description": "Rate limiting plugin for Elysia framework.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"bun",
|
|
@@ -40,19 +40,17 @@
|
|
|
40
40
|
"test": "bun test --pass-with-no-tests --coverage"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@clov-std/
|
|
44
|
-
"@clov-std/kv-store": "^1.1.2"
|
|
43
|
+
"@clov-std/kv-store": "^2.0.0"
|
|
45
44
|
},
|
|
46
45
|
"devDependencies": {
|
|
47
46
|
"@types/bun": "^1.3.14",
|
|
48
|
-
"oxfmt": "0.
|
|
49
|
-
"oxlint": "1.
|
|
47
|
+
"oxfmt": "0.62.0",
|
|
48
|
+
"oxlint": "1.77.0",
|
|
50
49
|
"oxlint-tsgolint": "7.0.2001",
|
|
51
50
|
"typescript": "^7.0.2"
|
|
52
51
|
},
|
|
53
52
|
"peerDependencies": {
|
|
54
|
-
"@clov-std/
|
|
55
|
-
"@clov-std/kv-store": "^1.1.2",
|
|
53
|
+
"@clov-std/kv-store": "^2.0.0",
|
|
56
54
|
"elysia": "^2.0.0-beta.1",
|
|
57
55
|
"typebox": "^1.3.10"
|
|
58
56
|
}
|