@alxia/rate-limit 0.4.0 → 0.4.2
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 +17 -5
- package/docs/guide.md +14 -4
- package/docs/roadmap.md +1 -1
- package/docs/troubleshooting.md +24 -8
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -46,8 +46,20 @@ routes declared after it, and also a request no route matches: it is counted,
|
|
|
46
46
|
and past the limit answers the 429 before the 404. Put it in a `group` to
|
|
47
47
|
count only some routes: a path-scoped `use('/api', …)` cannot give `rateLimit` to the context.
|
|
48
48
|
|
|
49
|
-
Behind a proxy, give the app an `ip` option that
|
|
50
|
-
|
|
49
|
+
Behind a proxy, the connection is the proxy: give the app an `ip` option that
|
|
50
|
+
reads the client from the header the proxy appends to, with core's `forwardedIp`:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { alxia, forwardedIp } from '@alxia/core';
|
|
54
|
+
|
|
55
|
+
const app = alxia({ ip: forwardedIp({ trusted: 1 }) }) // one proxy in front
|
|
56
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 }));
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Never key by the first entry of `X-Forwarded-For` (`split(',')[0]`): the client
|
|
60
|
+
writes it, so a new value in each request is a new allowance. `forwardedIp`
|
|
61
|
+
reads the entry your proxy appended
|
|
62
|
+
([Serving](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)).
|
|
51
63
|
|
|
52
64
|
## Across processes
|
|
53
65
|
|
|
@@ -63,12 +75,12 @@ app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { nam
|
|
|
63
75
|
|
|
64
76
|
A store that counts by a rate of its own declares it as `policy: { limit,
|
|
65
77
|
windowMs }`, and `rateLimit({ store })` reads both from it, headers included:
|
|
66
|
-
the numbers are written once. `redisStore(handle.limits.api
|
|
67
|
-
the definition
|
|
78
|
+
the numbers are written once. `redisStore(handle.limits.api)` does, from
|
|
79
|
+
the bound limit's definition. A `limit` or `windowMs` given beside it that differs
|
|
68
80
|
throws at declaration.
|
|
69
81
|
|
|
70
82
|
```ts
|
|
71
|
-
app.use(rateLimit({ store: redisStore(handle.limits.api
|
|
83
|
+
app.use(rateLimit({ store: redisStore(handle.limits.api) }));
|
|
72
84
|
```
|
|
73
85
|
|
|
74
86
|
A store of your own implements `RateLimitStore` (and, to count by a rate of
|
package/docs/guide.md
CHANGED
|
@@ -80,7 +80,7 @@ written once. Without one they are required, and a type error says so.
|
|
|
80
80
|
import { redisStore } from '@alxia/redis';
|
|
81
81
|
|
|
82
82
|
// `api` is the defineRateLimit definition the handle wired.
|
|
83
|
-
app.use(rateLimit({ store: redisStore(handle.limits.api
|
|
83
|
+
app.use(rateLimit({ store: redisStore(handle.limits.api) })); // 100 per 60 s, from `api`
|
|
84
84
|
```
|
|
85
85
|
|
|
86
86
|
A `limit` or a `windowMs` given beside a policy must equal it, or
|
|
@@ -96,15 +96,25 @@ above ten 365-day years (315,360,000,000), on the first request it counts rather
|
|
|
96
96
|
|
|
97
97
|
By default a limit counts per client address, `ctx.ip`, which is what the
|
|
98
98
|
app's [`ip` option](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)
|
|
99
|
-
reads. Behind a proxy, that is the proxy's address unless you set `ip
|
|
99
|
+
reads. Behind a proxy, that is the proxy's address unless you set `ip`, and
|
|
100
|
+
the header to read it from is the client's to write: the first entry of
|
|
101
|
+
`X-Forwarded-For` is whatever the client sent, so a limit keyed by it is
|
|
102
|
+
bypassed with a new value in each request. Use core's `forwardedIp`, which
|
|
103
|
+
reads the entry your own proxies appended, from the right:
|
|
100
104
|
|
|
101
105
|
```ts
|
|
106
|
+
import { alxia, forwardedIp } from '@alxia/core';
|
|
107
|
+
|
|
102
108
|
const app = alxia({
|
|
103
|
-
ip: (
|
|
104
|
-
request.headers.get('x-real-ip') ?? server?.requestIP(request)?.address,
|
|
109
|
+
ip: forwardedIp({ trusted: 1 }), // or ['10.0.0.0/8'], the proxies' own ranges
|
|
105
110
|
}).use(rateLimit({ limit: 100, windowMs: 60_000 }));
|
|
106
111
|
```
|
|
107
112
|
|
|
113
|
+
`trusted` is the number of proxies in front of the app (`2` for a CDN and a
|
|
114
|
+
load balancer), or their CIDR ranges, in which case a client that reaches the
|
|
115
|
+
app directly is counted by its own address, whatever header it sends
|
|
116
|
+
([Serving](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)).
|
|
117
|
+
|
|
108
118
|
Count by something else — an API key, a token — by returning it from `key`.
|
|
109
119
|
It may be async. A request whose key is `undefined` is not counted: it passes,
|
|
110
120
|
gets no rate-limit header, and its route reads `rateLimit` as `undefined`.
|
package/docs/roadmap.md
CHANGED
|
@@ -39,7 +39,7 @@ Nothing scheduled yet.
|
|
|
39
39
|
reads `limit` and `windowMs` from it, both optional in the type, headers
|
|
40
40
|
included; without a policy they stay required. A `limit` or `windowMs` that
|
|
41
41
|
differs from the policy throws at declaration. `@alxia/redis`'s
|
|
42
|
-
`redisStore(handle.limits.api
|
|
42
|
+
`redisStore(handle.limits.api)` is such a store. Every existing form
|
|
43
43
|
works as before.
|
|
44
44
|
|
|
45
45
|
### 0.1.0
|
package/docs/troubleshooting.md
CHANGED
|
@@ -22,6 +22,7 @@ nothing of its own; past the limit it answers a 429.
|
|
|
22
22
|
**Counting**
|
|
23
23
|
|
|
24
24
|
- [Every client is refused at once](#every-client-is-refused-at-once)
|
|
25
|
+
- [One client is never refused, whatever it sends](#one-client-is-never-refused-whatever-it-sends)
|
|
25
26
|
- [Nothing is limited, and no `RateLimit-*` header is sent](#nothing-is-limited-and-no-ratelimit--header-is-sent)
|
|
26
27
|
- [A client makes more than `limit` requests](#a-client-makes-more-than-limit-requests)
|
|
27
28
|
- [A client is refused before `limit` requests](#a-client-is-refused-before-limit-requests)
|
|
@@ -132,7 +133,7 @@ app.use(rateLimit({ limit: Number(Bun.env.RATE_LIMIT ?? 100), windowMs: 60_000 }
|
|
|
132
133
|
### `TypeError: rateLimit: limit … differs from the store's policy of …`
|
|
133
134
|
|
|
134
135
|
**When:** `rateLimit()` is given a `store` that declares its own `policy`
|
|
135
|
-
(`redisStore(handle.limits.api
|
|
136
|
+
(`redisStore(handle.limits.api)`) and a `limit` or a `windowMs` that is
|
|
136
137
|
not the store's. It throws at declaration, so the app fails at startup:
|
|
137
138
|
|
|
138
139
|
```text
|
|
@@ -147,7 +148,7 @@ say what the store does not enforce. (`windowMs` reads the same way.)
|
|
|
147
148
|
the rate, change the definition the store reads it from:
|
|
148
149
|
|
|
149
150
|
```ts
|
|
150
|
-
app.use(rateLimit({ store: redisStore(handle.limits.api
|
|
151
|
+
app.use(rateLimit({ store: redisStore(handle.limits.api) }));
|
|
151
152
|
```
|
|
152
153
|
|
|
153
154
|
## Responses
|
|
@@ -181,16 +182,31 @@ busy client, or a handful of ordinary ones, and everyone gets the 429.
|
|
|
181
182
|
**Why:** the default key is `ctx.ip`, the connection's address — the
|
|
182
183
|
proxy's, the same for every request. All clients share one allowance.
|
|
183
184
|
|
|
184
|
-
**Fix:** give the app an `ip` option that reads the
|
|
185
|
-
|
|
185
|
+
**Fix:** give the app an `ip` option that reads the client from the header
|
|
186
|
+
your proxy appends to, with core's `forwardedIp`:
|
|
186
187
|
|
|
187
188
|
```ts
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
import { alxia, forwardedIp } from '@alxia/core';
|
|
190
|
+
|
|
191
|
+
const app = alxia({ ip: forwardedIp({ trusted: 1 }) }).use(
|
|
192
|
+
rateLimit({ limit: 100, windowMs: 60_000 }),
|
|
193
|
+
);
|
|
192
194
|
```
|
|
193
195
|
|
|
196
|
+
### One client is never refused, whatever it sends
|
|
197
|
+
|
|
198
|
+
**When:** behind a proxy, a client changes its `X-Forwarded-For` in each
|
|
199
|
+
request and is never answered a 429, or one address is refused for a
|
|
200
|
+
header another client wrote.
|
|
201
|
+
|
|
202
|
+
**Why:** the `ip` option reads the header's first entry
|
|
203
|
+
(`split(',')[0]`). That entry is what the client wrote, so each value is a
|
|
204
|
+
new key. Each proxy appends the address it saw to the right.
|
|
205
|
+
|
|
206
|
+
**Fix:** read from the right, as `forwardedIp` does: `trusted: 1` is the
|
|
207
|
+
last entry, `2` the one before it behind two proxies, and a list of CIDR
|
|
208
|
+
ranges the first entry from the right that is not a proxy of yours.
|
|
209
|
+
|
|
194
210
|
### Nothing is limited, and no `RateLimit-*` header is sent
|
|
195
211
|
|
|
196
212
|
**When:** requests past `limit` still answer 200, without a rate-limit
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/rate-limit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Rate limiting for alxia: a 429 with Retry-After past the limit, the remaining allowance typed in the context, pluggable stores",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/core": "^0.
|
|
44
|
+
"@alxia/core": "^0.6.0",
|
|
45
45
|
"@types/bun": "^1.4.2",
|
|
46
46
|
"zod": "^4.2.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@alxia/core": "^0.
|
|
49
|
+
"@alxia/core": "^0.6.0",
|
|
50
50
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
51
51
|
}
|
|
52
52
|
}
|