@clov-std/elysia-ratelimit 4.0.18 → 5.0.0-rc.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 +25 -17
- package/dist/index.js +56 -34
- package/dist/rate-limit.d.ts +36 -13
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="https://cdn.jsdelivr.net/gh/ClovLabs/std@main/packages/elysia-ratelimit/logo-elysia-ratelimit.
|
|
2
|
+
<img src="https://cdn.jsdelivr.net/gh/ClovLabs/std@main/packages/elysia-ratelimit/logo-elysia-ratelimit.svg" alt="Clov Elysia Rate Limit logo" width="200" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# Clov Elysia Rate Limit
|
|
6
6
|
|
|
7
7
|
Rate limiting for Elysia, as a macro. Put `rateLimit: { limit: 10, window: 60 }` on a route, guard or group and you're done.
|
|
8
8
|
|
|
9
|
-
It runs in `transform`, the first per-route hook, so excess requests are rejected before auth guards or handlers do any work. Counters are per client IP by default, which suits login endpoints
|
|
9
|
+
It runs in `transform`, the first per-route hook, so excess requests are rejected before auth guards or handlers do any work. Counters are per route pattern and client IP by default, which suits login endpoints: `/users/1` and `/users/2` share the quota of `/users/:id`. When lots of users share one IP (an office, a corporate proxy), count per authenticated user instead, with the `derive` hook.
|
|
10
10
|
|
|
11
11
|
Storage goes through `@clov-std/kv-store`: memory by default, Redis when you need it, same routes either way.
|
|
12
12
|
|
|
@@ -55,34 +55,42 @@ new Elysia()
|
|
|
55
55
|
|
|
56
56
|
### Per user instead of per IP
|
|
57
57
|
|
|
58
|
-
|
|
58
|
+
A `transform` hook only knows the request. To count per user, run the quota in `derive` (or `beforeHandle`), after the hook that authenticates, and key it on what that hook added. Give the quota its own `policy`, so its counters and headers stay apart from the per-IP one:
|
|
59
59
|
|
|
60
60
|
```ts
|
|
61
|
-
import { rateLimitPlugin } from '@clov-std/elysia-ratelimit';
|
|
62
|
-
import { Elysia } from 'elysia';
|
|
63
|
-
|
|
64
61
|
new Elysia()
|
|
65
62
|
.use(rateLimitPlugin())
|
|
66
|
-
.
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
.macro({
|
|
64
|
+
requireAuth: {
|
|
65
|
+
derive: async ({ headers }) => ({ idUser: await authenticate(headers) }),
|
|
66
|
+
// declared after the derive: derives run in declaration order
|
|
69
67
|
rateLimit: {
|
|
70
|
-
|
|
68
|
+
policy: 'user',
|
|
69
|
+
hook: 'derive',
|
|
70
|
+
limit: 300,
|
|
71
71
|
window: 60,
|
|
72
|
-
keyGenerator: ({
|
|
73
|
-
`${ip}:${request.headers.get('authorization') ?? ip}`
|
|
72
|
+
keyGenerator: ({ idUser }) => idUser
|
|
74
73
|
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
.get('/api/data', { requireAuth: true }, () => getData())
|
|
78
77
|
.listen(3000);
|
|
79
78
|
```
|
|
80
79
|
|
|
80
|
+
Elysia can't type what earlier hooks added, so `keyGenerator` returns `unknown`: anything but a non-empty string throws, so a key read before the hook adding it fails loudly instead of putting every client in one quota. A quota declared at several levels (a guard and its route) counts a request once.
|
|
81
|
+
|
|
81
82
|
`extractClientIp` is exported if you need it in your own `keyGenerator`.
|
|
82
83
|
|
|
83
84
|
## Headers and the 429
|
|
84
85
|
|
|
85
|
-
Every response
|
|
86
|
+
Every limited response lists its quotas in the `RateLimit-Policy` and `RateLimit` fields of [draft-ietf-httpapi-ratelimit-headers](https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/), one item per policy:
|
|
87
|
+
|
|
88
|
+
```http
|
|
89
|
+
RateLimit-Policy: "default";q=10;w=60, "user";q=300;w=60
|
|
90
|
+
RateLimit: "default";r=9;t=60, "user";r=299;t=60
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The `429` adds the standard `Retry-After` (RFC 9110), in seconds.
|
|
86
94
|
|
|
87
95
|
Once the limit is hit, the plugin throws Elysia's `problem()`, so the `429` is a full RFC 9457 document (`application/problem+json`) without any exception class to import:
|
|
88
96
|
|
|
@@ -99,7 +107,7 @@ Once the limit is hit, the plugin throws Elysia's `problem()`, so the `429` is a
|
|
|
99
107
|
- `title` comes from Elysia, based on the status.
|
|
100
108
|
- `detail` reflects the current counter, so it changes from one 429 to the next.
|
|
101
109
|
|
|
102
|
-
The
|
|
110
|
+
The fields are set before the throw, so they're still on the `429`. The macro also declares this response for OpenAPI, so typed clients know about it.
|
|
103
111
|
|
|
104
112
|
## Docs
|
|
105
113
|
|
package/dist/index.js
CHANGED
|
@@ -2,13 +2,12 @@
|
|
|
2
2
|
// src/rate-limit.ts
|
|
3
3
|
import { MemoryStore } from "@clov-std/kv-store";
|
|
4
4
|
import { Elysia, problem, t } from "elysia";
|
|
5
|
+
import { isIP } from "net";
|
|
5
6
|
var RATE_LIMIT_ERROR_CODES = {
|
|
6
7
|
QUOTA_EXCEEDED: "rate-limit.quota.exceeded"
|
|
7
8
|
};
|
|
8
9
|
var rateLimitResponseSchema = t.Object({
|
|
9
|
-
type: t.
|
|
10
|
-
description: `Error code. Always \`${RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED}\`.`
|
|
11
|
-
}),
|
|
10
|
+
type: t.Literal(RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED, { description: "Error code." }),
|
|
12
11
|
title: t.String({
|
|
13
12
|
description: "Short, human-readable summary of the error."
|
|
14
13
|
}),
|
|
@@ -19,39 +18,62 @@ var rateLimitResponseSchema = t.Object({
|
|
|
19
18
|
description: "Explanation specific to this occurrence, including when to retry."
|
|
20
19
|
})
|
|
21
20
|
});
|
|
21
|
+
var isClientIp = (value) => typeof value === "string" && isIP(value) !== 0 && !value.includes("%");
|
|
22
22
|
var extractClientIp = (request, server) => {
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
return request.headers.get("x-real-ip") ?? server?.requestIP(request)?.address ?? "127.0.0.1";
|
|
23
|
+
const forwardedIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
24
|
+
if (isClientIp(forwardedIp))
|
|
25
|
+
return forwardedIp;
|
|
26
|
+
const realIp = request.headers.get("x-real-ip");
|
|
27
|
+
return isClientIp(realIp) ? realIp : server?.requestIP(request)?.address ?? "127.0.0.1";
|
|
30
28
|
};
|
|
31
|
-
var
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
29
|
+
var countedRequests = new WeakMap;
|
|
30
|
+
var appendHeader = (headers, name, item) => {
|
|
31
|
+
const current = headers[name];
|
|
32
|
+
headers[name] = typeof current === "string" ? `${current}, ${item}` : item;
|
|
33
|
+
};
|
|
34
|
+
var consumeRateLimit = async (store, { set, request, path, route }, { policy, key, limit, window }) => {
|
|
35
|
+
const storeKey = `ratelimit:${policy}:${request.method}:${route ?? path}:${key}`;
|
|
36
|
+
const counted = countedRequests.get(request) ?? new Set;
|
|
37
|
+
if (counted.has(storeKey))
|
|
38
|
+
return;
|
|
39
|
+
countedRequests.set(request, counted.add(storeKey));
|
|
40
|
+
const count = await store.increment(storeKey);
|
|
41
|
+
if (count === 1)
|
|
42
|
+
await store.expire(storeKey, window);
|
|
43
|
+
const reset = await store.ttl(storeKey);
|
|
44
|
+
appendHeader(set.headers, "RateLimit-Policy", `"${policy}";q=${limit};w=${window}`);
|
|
45
|
+
appendHeader(set.headers, "RateLimit", `"${policy}";r=${Math.max(0, limit - count)};t=${reset}`);
|
|
46
|
+
if (count <= limit)
|
|
47
|
+
return;
|
|
48
|
+
set.headers["Retry-After"] = reset.toString();
|
|
49
|
+
throw problem({
|
|
50
|
+
type: RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED,
|
|
51
|
+
status: 429,
|
|
52
|
+
detail: `Limit of ${limit} requests per ${window}s exceeded. Retry in ${reset}s.`
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
var rateLimitPlugin = (store = new MemoryStore) => new Elysia({ name: "rateLimitPlugin" }).model({ [RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED]: rateLimitResponseSchema }).macro({
|
|
56
|
+
rateLimit: ({
|
|
57
|
+
limit,
|
|
58
|
+
window,
|
|
59
|
+
policy = "default",
|
|
60
|
+
hook = "transform",
|
|
61
|
+
keyGenerator
|
|
62
|
+
}) => {
|
|
63
|
+
const count = async (ctx) => {
|
|
64
|
+
const ip = extractClientIp(ctx.request, ctx.server);
|
|
65
|
+
const key = keyGenerator ? keyGenerator({ ...ctx, ip }) : ip;
|
|
66
|
+
if (typeof key !== "string" || key === "")
|
|
67
|
+
throw new Error(`rateLimit "${policy}": keyGenerator returned no key, is the hook it reads declared before it?`);
|
|
68
|
+
await consumeRateLimit(store, ctx, { policy, key, limit, window });
|
|
69
|
+
};
|
|
70
|
+
const response = { 429: RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED };
|
|
71
|
+
if (hook === "derive")
|
|
72
|
+
return { response, derive: count };
|
|
73
|
+
if (hook === "beforeHandle")
|
|
74
|
+
return { response, beforeHandle: count };
|
|
75
|
+
return { response, transform: count };
|
|
76
|
+
}
|
|
55
77
|
});
|
|
56
78
|
export {
|
|
57
79
|
RATE_LIMIT_ERROR_CODES,
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -4,14 +4,8 @@ import { Elysia, type HTTPHeaders } from 'elysia';
|
|
|
4
4
|
export declare const RATE_LIMIT_ERROR_CODES: {
|
|
5
5
|
readonly QUOTA_EXCEEDED: 'rate-limit.quota.exceeded';
|
|
6
6
|
};
|
|
7
|
-
/**
|
|
8
|
-
* Shape of the `429` body, an RFC 9457 problem document.
|
|
9
|
-
*
|
|
10
|
-
* `title` is filled by Elysia from the status reason phrase, so the plugin only supplies
|
|
11
|
-
* `type`, `status`, and `detail`.
|
|
12
|
-
*/
|
|
13
7
|
declare const rateLimitResponseSchema: import("typebox").TObject<{
|
|
14
|
-
type: import("typebox").
|
|
8
|
+
type: import("typebox").TLiteral<"rate-limit.quota.exceeded">;
|
|
15
9
|
title: import("typebox").TString;
|
|
16
10
|
status: import("typebox").TLiteral<429>;
|
|
17
11
|
detail: import("typebox").TString;
|
|
@@ -20,32 +14,61 @@ export interface RateLimitKeyContext {
|
|
|
20
14
|
request: Request;
|
|
21
15
|
server: Server<unknown> | null;
|
|
22
16
|
ip: string;
|
|
17
|
+
[key: string]: unknown;
|
|
23
18
|
}
|
|
24
19
|
export interface RateLimitMacroOptions {
|
|
25
20
|
limit: number;
|
|
26
21
|
window: number;
|
|
27
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Names the quota in the RateLimit fields, and separates its counters from other quotas.
|
|
24
|
+
* @default 'default'
|
|
25
|
+
*/
|
|
26
|
+
policy?: string;
|
|
27
|
+
/**
|
|
28
|
+
* `transform` counts first, the key only knows the request.
|
|
29
|
+
* `derive` | `beforeHandle` count later, the key can read what earlier hooks added, like a user.
|
|
30
|
+
* @default 'transform'
|
|
31
|
+
*/
|
|
32
|
+
hook?: 'transform' | 'derive' | 'beforeHandle';
|
|
33
|
+
keyGenerator?: (context: RateLimitKeyContext) => unknown;
|
|
28
34
|
}
|
|
29
|
-
interface
|
|
35
|
+
interface RateLimitContext {
|
|
30
36
|
set: {
|
|
31
37
|
headers: HTTPHeaders;
|
|
32
38
|
};
|
|
33
39
|
request: Request;
|
|
34
40
|
server: Server<unknown> | null;
|
|
41
|
+
path: string;
|
|
42
|
+
route?: string;
|
|
35
43
|
}
|
|
44
|
+
type RateLimitHook = (context: RateLimitContext) => Promise<void>;
|
|
36
45
|
type RateLimitMacro = (options: RateLimitMacroOptions) => {
|
|
37
46
|
response: {
|
|
38
|
-
429: typeof
|
|
47
|
+
429: typeof RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED;
|
|
39
48
|
};
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
} & ({
|
|
50
|
+
transform: RateLimitHook;
|
|
51
|
+
} | {
|
|
52
|
+
derive: RateLimitHook;
|
|
53
|
+
} | {
|
|
54
|
+
beforeHandle: RateLimitHook;
|
|
55
|
+
});
|
|
42
56
|
export declare const extractClientIp: (request: Request, server: Server<unknown> | null) => string;
|
|
57
|
+
/**
|
|
58
|
+
* Creates the rate limit plugin
|
|
59
|
+
*
|
|
60
|
+
* Elysia keeps one definition per plugin name, so every `rateLimitPlugin()` of an app tree
|
|
61
|
+
* resolves to a single plugin instance — the first one. Pass the store here to change it
|
|
62
|
+
* for the whole app, a later `rateLimitPlugin(store)` is ignored.
|
|
63
|
+
*/
|
|
43
64
|
export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'rateLimitPlugin', 'local', {
|
|
44
65
|
decorator: {};
|
|
45
66
|
derive: {};
|
|
46
67
|
store: {};
|
|
47
68
|
}, {
|
|
48
|
-
typebox: {
|
|
69
|
+
typebox: {
|
|
70
|
+
[RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED]: typeof rateLimitResponseSchema;
|
|
71
|
+
};
|
|
49
72
|
error: [];
|
|
50
73
|
}, {
|
|
51
74
|
schema: {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clov-std/elysia-ratelimit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0-rc.1",
|
|
4
4
|
"description": "Rate limiting for Elysia as a route macro, with RFC 9457 429 responses and pluggable storage.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"elysia",
|
|
@@ -36,18 +36,18 @@
|
|
|
36
36
|
"test": "bun test --pass-with-no-tests --coverage"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@clov-std/kv-store": "^2.0.
|
|
39
|
+
"@clov-std/kv-store": "^2.0.5"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@types/bun": "^1.4.2",
|
|
43
|
-
"oxfmt": "0.
|
|
44
|
-
"oxlint": "1.
|
|
43
|
+
"oxfmt": "0.74.0",
|
|
44
|
+
"oxlint": "1.89.0",
|
|
45
45
|
"oxlint-tsgolint": "7.0.2004",
|
|
46
46
|
"typescript": "^7.0.2"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@clov-std/kv-store": "^2.0.
|
|
50
|
-
"elysia": "
|
|
49
|
+
"@clov-std/kv-store": "^2.0.5",
|
|
50
|
+
"elysia": "rc",
|
|
51
51
|
"typebox": "^1.3.36"
|
|
52
52
|
}
|
|
53
53
|
}
|