@alxia/rate-limit 0.1.1 → 0.2.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 +16 -9
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -17
- package/dist/index.js.map +3 -3
- package/dist/rate-limit.d.ts +15 -12
- package/dist/rate-limit.d.ts.map +1 -1
- package/docs/README.md +1 -1
- package/docs/guide.md +63 -51
- package/docs/roadmap.md +9 -4
- package/docs/troubleshooting.md +38 -36
- package/package.json +4 -5
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @alxia/rate-limit
|
|
2
2
|
|
|
3
|
-
Rate limiting for [alxia](https://www.npmjs.com/package/@alxia/core),
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Rate limiting for [alxia](https://www.npmjs.com/package/@alxia/core), as a
|
|
4
|
+
middleware: every request it runs on answers a 429 with `Retry-After` once a key has
|
|
5
|
+
spent its allowance, and reads what is left as a typed `ctx.rateLimit`. No
|
|
6
6
|
dependency.
|
|
7
7
|
|
|
8
8
|
```sh
|
|
@@ -20,8 +20,8 @@ const app = alxia()
|
|
|
20
20
|
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
21
21
|
.get('/search', ({ rateLimit, reply }) => ...); // limited; rateLimit.remaining
|
|
22
22
|
|
|
23
|
-
const
|
|
24
|
-
if (
|
|
23
|
+
const response = await app.request('/search');
|
|
24
|
+
if (response.status === 429) (await response.json()).retryAfter; // seconds
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
Past the limit, a 429 with `Retry-After` and
|
|
@@ -34,11 +34,18 @@ IETF draft's `RateLimit-Limit`, `-Remaining`, `-Reset` and `-Policy`.
|
|
|
34
34
|
| --- | --- | --- |
|
|
35
35
|
| `limit` | required | requests per window: a whole number, 1 or more |
|
|
36
36
|
| `windowMs` | required | the window, in milliseconds: a whole number, 1 or more |
|
|
37
|
-
| `key` | the client's address | what is counted: `(ctx) => string \| undefined`; `undefined` is not counted. `rateLimit<{ user: User }>(…)` lets it read a `user` an earlier
|
|
37
|
+
| `key` | the client's address | what is counted: `(ctx) => string \| undefined`; `undefined` is not counted. `rateLimit<{ user: User }>(…)` lets it read a `user` an earlier middleware adds |
|
|
38
38
|
| `store` | `MemoryStore` | where: `redisStore` from `@alxia/redis`, or your own `RateLimitStore` |
|
|
39
39
|
| `skip` | none | requests not counted |
|
|
40
40
|
| `headers` | `'draft'` | `'legacy'` for `X-RateLimit-*`, or `false` |
|
|
41
41
|
|
|
42
|
+
## Order
|
|
43
|
+
|
|
44
|
+
`rateLimit` counts every request it runs on. Given to `app.use`, that is the
|
|
45
|
+
routes declared after it, and also a request no route matches: it is counted,
|
|
46
|
+
and past the limit answers the 429 before the 404. Put it in a `group` to
|
|
47
|
+
count only some routes: a path-scoped `use('/api', …)` cannot give `rateLimit` to the context.
|
|
48
|
+
|
|
42
49
|
Behind a proxy, give the app an `ip` option that reads the header it sets:
|
|
43
50
|
`alxia({ ip: (request) => request.headers.get('x-real-ip') ?? undefined })`.
|
|
44
51
|
|
|
@@ -63,13 +70,13 @@ nothing.
|
|
|
63
70
|
|
|
64
71
|
| export | |
|
|
65
72
|
| --- | --- |
|
|
66
|
-
| `rateLimit(options)` | the
|
|
73
|
+
| `rateLimit(options)` | the middleware: gives `rateLimit` to what runs after it, or answers the 429 |
|
|
67
74
|
| `MemoryStore` | a fixed window in one process's memory |
|
|
68
75
|
| `RateLimitStore`, `Decision`, `Policy` | a store's contract |
|
|
69
|
-
| `RateLimitedBody`, `RateLimitInfo`, `RateLimitOptions` | its types |
|
|
76
|
+
| `RateLimit`, `RateLimitedBody`, `RateLimitInfo`, `RateLimitOptions` | its types: `RateLimit<Requires>` is the middleware `rateLimit()` returns |
|
|
70
77
|
|
|
71
78
|
## Documentation
|
|
72
79
|
|
|
73
|
-
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/rate-limit/docs): the options and their defaults, which requests are counted, the headers, the 429 on the
|
|
80
|
+
- [Guide](https://github.com/softistx/alxia/tree/develop/packages/rate-limit/docs): the options and their defaults, which requests are counted, the headers, the 429 on the wire, stores, and testing.
|
|
74
81
|
- [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md): an error message, and what to do about it.
|
|
75
82
|
- [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/roadmap.md): what is coming, and what is not planned.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { type RateLimitedBody, type RateLimitInfo, type RateLimitOptions, rateLimit, } from './rate-limit';
|
|
1
|
+
export { type RateLimit, type RateLimitedBody, type RateLimitInfo, type RateLimitOptions, rateLimit, } from './rate-limit';
|
|
2
2
|
export { type Decision, MemoryStore, type Policy, type RateLimitStore, } from './store';
|
|
3
3
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,SAAS,GACT,MAAM,cAAc,CAAC;AACtB,OAAO,EACN,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,MAAM,EACX,KAAK,cAAc,GACnB,MAAM,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,SAAS,EACd,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,gBAAgB,EACrB,SAAS,GACT,MAAM,cAAc,CAAC;AACtB,OAAO,EACN,KAAK,QAAQ,EACb,WAAW,EACX,KAAK,MAAM,EACX,KAAK,cAAc,GACnB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/rate-limit.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
defineMiddleware
|
|
4
|
+
} from "@alxia/core";
|
|
3
5
|
|
|
4
6
|
// src/store.ts
|
|
5
7
|
class MemoryStore {
|
|
@@ -65,27 +67,17 @@ function rateLimit(options) {
|
|
|
65
67
|
const store = options.store ?? new MemoryStore;
|
|
66
68
|
const key = options.key ?? ((ctx) => ctx.ip);
|
|
67
69
|
const style = options.headers ?? "draft";
|
|
68
|
-
return
|
|
70
|
+
return defineMiddleware()(async (ctx, next) => {
|
|
69
71
|
const counted = options.skip?.(ctx) ? undefined : await key(ctx);
|
|
70
72
|
if (counted === undefined) {
|
|
71
73
|
const rateLimit = undefined;
|
|
72
|
-
return { rateLimit };
|
|
74
|
+
return next({ rateLimit });
|
|
73
75
|
}
|
|
74
76
|
const decision = await store.consume(counted, {
|
|
75
77
|
limit: options.limit,
|
|
76
78
|
windowMs: options.windowMs
|
|
77
79
|
});
|
|
78
|
-
|
|
79
|
-
if (style === "draft") {
|
|
80
|
-
ctx.set.headers.set("ratelimit-limit", String(options.limit));
|
|
81
|
-
ctx.set.headers.set("ratelimit-remaining", String(decision.remaining));
|
|
82
|
-
ctx.set.headers.set("ratelimit-reset", String(reset));
|
|
83
|
-
ctx.set.headers.set("ratelimit-policy", `${options.limit};w=${Math.ceil(options.windowMs / 1000)}`);
|
|
84
|
-
} else if (style === "legacy") {
|
|
85
|
-
ctx.set.headers.set("x-ratelimit-limit", String(options.limit));
|
|
86
|
-
ctx.set.headers.set("x-ratelimit-remaining", String(decision.remaining));
|
|
87
|
-
ctx.set.headers.set("x-ratelimit-reset", String(Math.ceil((Date.now() + decision.resetAfter) / 1000)));
|
|
88
|
-
}
|
|
80
|
+
said(ctx.set.headers, style, options, decision);
|
|
89
81
|
if (!decision.allowed) {
|
|
90
82
|
const retryAfter = Math.max(1, Math.ceil(decision.retryAfter / 1000));
|
|
91
83
|
const body = { error: "rate_limited", retryAfter };
|
|
@@ -98,13 +90,25 @@ function rateLimit(options) {
|
|
|
98
90
|
remaining: decision.remaining,
|
|
99
91
|
resetAfter: decision.resetAfter
|
|
100
92
|
};
|
|
101
|
-
return { rateLimit };
|
|
102
|
-
})
|
|
93
|
+
return next({ rateLimit });
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function said(headers, style, options, decision) {
|
|
97
|
+
if (style === "draft") {
|
|
98
|
+
headers.set("ratelimit-limit", String(options.limit));
|
|
99
|
+
headers.set("ratelimit-remaining", String(decision.remaining));
|
|
100
|
+
headers.set("ratelimit-reset", String(Math.ceil(decision.resetAfter / 1000)));
|
|
101
|
+
headers.set("ratelimit-policy", `${options.limit};w=${Math.ceil(options.windowMs / 1000)}`);
|
|
102
|
+
} else if (style === "legacy") {
|
|
103
|
+
headers.set("x-ratelimit-limit", String(options.limit));
|
|
104
|
+
headers.set("x-ratelimit-remaining", String(decision.remaining));
|
|
105
|
+
headers.set("x-ratelimit-reset", String(Math.ceil((Date.now() + decision.resetAfter) / 1000)));
|
|
106
|
+
}
|
|
103
107
|
}
|
|
104
108
|
export {
|
|
105
109
|
MemoryStore,
|
|
106
110
|
rateLimit
|
|
107
111
|
};
|
|
108
112
|
|
|
109
|
-
//# debugId=
|
|
113
|
+
//# debugId=8BE4A8782C7D852264756E2164756E21
|
|
110
114
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/rate-limit.ts", "../src/store.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import {
|
|
5
|
+
"import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\ttype MiddlewareMark,\n\ttype Next,\n\ttype Reply,\n} from '@alxia/core';\nimport { type Decision, MemoryStore, type RateLimitStore } from './store';\n\n/**\n * `Requires` is what `key` and `skip` read from the context beyond\n * `BaseContext` — a `user` an earlier middleware adds — and what the app that\n * uses the limit must then give.\n */\nexport interface RateLimitOptions<Requires extends object = Empty> {\n\t/** How many requests a key may make in a window: a whole number, 1 or more. */\n\treadonly limit: number;\n\t/** The window, in milliseconds: a whole number, 1 or more. */\n\treadonly windowMs: number;\n\t/** What is counted: the client's address by default. `undefined` is not counted. */\n\treadonly key?: (\n\t\tctx: BaseContext & Requires,\n\t) => string | undefined | Promise<string | undefined>;\n\t/** Where it is counted: one process's memory by default. */\n\treadonly store?: RateLimitStore;\n\t/** Requests not counted at all. */\n\treadonly skip?: (ctx: BaseContext & Requires) => boolean;\n\t/**\n\t * The `RateLimit` headers of the IETF draft on every counted response,\n\t * `X-RateLimit-*` with `legacy`, or none. `draft` by default.\n\t */\n\treadonly headers?: 'draft' | 'legacy' | false;\n}\n\n/** The body of the 429. */\nexport interface RateLimitedBody {\n\treadonly error: 'rate_limited';\n\t/** Seconds until the window ends. */\n\treadonly retryAfter: number;\n}\n\n/** What the routes behind the limit read: where the key stands. */\nexport interface RateLimitInfo {\n\treadonly limit: number;\n\treadonly remaining: number;\n\t/** Milliseconds until the allowance is whole again. */\n\treadonly resetAfter: number;\n}\n\n/**\n * What `rateLimit()` makes: a middleware that requires `Requires` of the\n * app, and gives `rateLimit` or answers the 429.\n */\nexport type RateLimit<Requires extends object = Empty> = Middleware<\n\tRequires,\n\tPromise<\n\t\tReply<429, RateLimitedBody> | Next<{ rateLimit: RateLimitInfo | undefined }>\n\t>\n> &\n\tMiddlewareMark;\n\n/**\n * A rate limit, as a middleware: every request it runs on is counted —\n * the routes declared after it, and, given to `app.use`, a request no\n * route matches too — and answered a 429 past the limit.\n *\n * ```ts\n * app.use(rateLimit({ limit: 100, windowMs: 60_000 })).get(...);\n * ```\n *\n * A `key` that reads what an earlier middleware added names it, and the\n * app must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.\n */\nexport function rateLimit<Requires extends object = Empty>(\n\toptions: RateLimitOptions<Requires>,\n): NoInfer<RateLimit<Requires>> {\n\tfor (const name of ['limit', 'windowMs'] as const) {\n\t\tconst value = options[name];\n\t\tif (!Number.isSafeInteger(value) || value < 1) {\n\t\t\t// 0 refuses every request; a window of 0 or less never ends one.\n\t\t\tthrow new TypeError(\n\t\t\t\t`rateLimit: ${name} must be a whole number of 1 or more, not ${String(value)}`,\n\t\t\t);\n\t\t}\n\t}\n\tconst store = options.store ?? new MemoryStore();\n\tconst key = options.key ?? ((ctx: BaseContext & Requires) => ctx.ip);\n\tconst style = options.headers ?? 'draft';\n\treturn defineMiddleware<Requires>()(async (ctx, next) => {\n\t\tconst counted = options.skip?.(ctx) ? undefined : await key(ctx);\n\t\tif (counted === undefined) {\n\t\t\tconst rateLimit: RateLimitInfo | undefined = undefined;\n\t\t\treturn next({ rateLimit });\n\t\t}\n\t\tconst decision = await store.consume(counted, {\n\t\t\tlimit: options.limit,\n\t\t\twindowMs: options.windowMs,\n\t\t});\n\t\tsaid(ctx.set.headers, style, options, decision);\n\t\tif (!decision.allowed) {\n\t\t\tconst retryAfter = Math.max(1, Math.ceil(decision.retryAfter / 1000));\n\t\t\tconst body: RateLimitedBody = { error: 'rate_limited', retryAfter };\n\t\t\treturn ctx.reply(429, body, {\n\t\t\t\theaders: { 'retry-after': String(retryAfter) },\n\t\t\t});\n\t\t}\n\t\tconst rateLimit: RateLimitInfo | undefined = {\n\t\t\tlimit: options.limit,\n\t\t\tremaining: decision.remaining,\n\t\t\tresetAfter: decision.resetAfter,\n\t\t};\n\t\treturn next({ rateLimit });\n\t});\n}\n\n/** The headers that say where the key stands, in the `style` asked for. */\nfunction said(\n\theaders: Headers,\n\tstyle: 'draft' | 'legacy' | false,\n\toptions: { readonly limit: number; readonly windowMs: number },\n\tdecision: Decision,\n): void {\n\tif (style === 'draft') {\n\t\theaders.set('ratelimit-limit', String(options.limit));\n\t\theaders.set('ratelimit-remaining', String(decision.remaining));\n\t\theaders.set(\n\t\t\t'ratelimit-reset',\n\t\t\tString(Math.ceil(decision.resetAfter / 1000)),\n\t\t);\n\t\theaders.set(\n\t\t\t'ratelimit-policy',\n\t\t\t`${options.limit};w=${Math.ceil(options.windowMs / 1000)}`,\n\t\t);\n\t} else if (style === 'legacy') {\n\t\theaders.set('x-ratelimit-limit', String(options.limit));\n\t\theaders.set('x-ratelimit-remaining', String(decision.remaining));\n\t\theaders.set(\n\t\t\t'x-ratelimit-reset',\n\t\t\tString(Math.ceil((Date.now() + decision.resetAfter) / 1000)),\n\t\t);\n\t}\n}\n",
|
|
6
6
|
"/** What a store decides for one request. Every duration is a delay, in milliseconds. */\nexport interface Decision {\n\treadonly allowed: boolean;\n\t/** Requests the key may still make now, after this one. */\n\treadonly remaining: number;\n\t/** Until the key's allowance is whole again. */\n\treadonly resetAfter: number;\n\t/** Until a refused request would be allowed; 0 when this one is. */\n\treadonly retryAfter: number;\n}\n\n/** The policy a store applies: `limit` requests per `windowMs`. */\nexport interface Policy {\n\treadonly limit: number;\n\treadonly windowMs: number;\n}\n\n/**\n * Where requests are counted, and what decides. The memory store counts in\n * one process with a fixed window; `@alxia/redis`'s counts across every\n * process sharing a Redis, with GCRA.\n */\nexport interface RateLimitStore {\n\t/** Counts one request for `key` under `policy`; a refused one counts nothing. */\n\tconsume(key: string, policy: Policy): Decision | Promise<Decision>;\n\t/** Forgets `key`: a user who just logged in. */\n\treset(key: string): void | Promise<void>;\n}\n\n/** A fixed-window counter in memory, swept as windows end. */\nexport class MemoryStore implements RateLimitStore {\n\treadonly #hits = new Map<string, { count: number; resetAt: number }>();\n\t#sweeper: ReturnType<typeof setInterval> | undefined;\n\n\tconsume(key: string, policy: Policy): Decision {\n\t\tconst now = Date.now();\n\t\tlet entry = this.#hits.get(key);\n\t\tif (entry === undefined || entry.resetAt <= now) {\n\t\t\tentry = { count: 0, resetAt: now + policy.windowMs };\n\t\t\tthis.#hits.set(key, entry);\n\t\t}\n\t\tthis.#sweep(policy.windowMs);\n\t\tconst resetAfter = entry.resetAt - now;\n\t\tif (entry.count >= policy.limit) {\n\t\t\treturn {\n\t\t\t\tallowed: false,\n\t\t\t\tremaining: 0,\n\t\t\t\tresetAfter,\n\t\t\t\tretryAfter: resetAfter,\n\t\t\t};\n\t\t}\n\t\tentry.count++;\n\t\treturn {\n\t\t\tallowed: true,\n\t\t\tremaining: policy.limit - entry.count,\n\t\t\tresetAfter,\n\t\t\tretryAfter: 0,\n\t\t};\n\t}\n\n\treset(key: string): void {\n\t\tthis.#hits.delete(key);\n\t}\n\n\t/** How many keys are counted. */\n\tget size(): number {\n\t\treturn this.#hits.size;\n\t}\n\n\t#sweep(windowMs: number): void {\n\t\tif (this.#sweeper !== undefined) return;\n\t\tthis.#sweeper = setInterval(() => {\n\t\t\tconst now = Date.now();\n\t\t\tfor (const [key, entry] of this.#hits) {\n\t\t\t\tif (entry.resetAt <= now) this.#hits.delete(key);\n\t\t\t}\n\t\t\tif (this.#hits.size === 0) {\n\t\t\t\tclearInterval(this.#sweeper);\n\t\t\t\tthis.#sweeper = undefined;\n\t\t\t}\n\t\t}, windowMs);\n\t\tthis.#sweeper.unref?.();\n\t}\n}\n"
|
|
7
7
|
],
|
|
8
|
-
"mappings": ";AAAA;;;AC8BO,MAAM,YAAsC;AAAA,EACzC,QAAQ,IAAI;AAAA,EACrB;AAAA,EAEA,OAAO,CAAC,KAAa,QAA0B;AAAA,IAC9C,MAAM,MAAM,KAAK,IAAI;AAAA,IACrB,IAAI,QAAQ,KAAK,MAAM,IAAI,GAAG;AAAA,IAC9B,IAAI,UAAU,aAAa,MAAM,WAAW,KAAK;AAAA,MAChD,QAAQ,EAAE,OAAO,GAAG,SAAS,MAAM,OAAO,SAAS;AAAA,MACnD,KAAK,MAAM,IAAI,KAAK,KAAK;AAAA,IAC1B;AAAA,IACA,KAAK,OAAO,OAAO,QAAQ;AAAA,IAC3B,MAAM,aAAa,MAAM,UAAU;AAAA,IACnC,IAAI,MAAM,SAAS,OAAO,OAAO;AAAA,MAChC,OAAO;AAAA,QACN,SAAS;AAAA,QACT,WAAW;AAAA,QACX;AAAA,QACA,YAAY;AAAA,MACb;AAAA,IACD;AAAA,IACA,MAAM;AAAA,IACN,OAAO;AAAA,MACN,SAAS;AAAA,MACT,WAAW,OAAO,QAAQ,MAAM;AAAA,MAChC;AAAA,MACA,YAAY;AAAA,IACb;AAAA;AAAA,EAGD,KAAK,CAAC,KAAmB;AAAA,IACxB,KAAK,MAAM,OAAO,GAAG;AAAA;AAAA,MAIlB,IAAI,GAAW;AAAA,IAClB,OAAO,KAAK,MAAM;AAAA;AAAA,EAGnB,MAAM,CAAC,UAAwB;AAAA,IAC9B,IAAI,KAAK,aAAa;AAAA,MAAW;AAAA,IACjC,KAAK,WAAW,YAAY,MAAM;AAAA,MACjC,MAAM,MAAM,KAAK,IAAI;AAAA,MACrB,YAAY,KAAK,UAAU,KAAK,OAAO;AAAA,QACtC,IAAI,MAAM,WAAW;AAAA,UAAK,KAAK,MAAM,OAAO,GAAG;AAAA,MAChD;AAAA,MACA,IAAI,KAAK,MAAM,SAAS,GAAG;AAAA,QAC1B,cAAc,KAAK,QAAQ;AAAA,QAC3B,KAAK,WAAW;AAAA,MACjB;AAAA,OACE,QAAQ;AAAA,IACX,KAAK,SAAS,QAAQ;AAAA;AAExB;;;
|
|
9
|
-
"debugId": "
|
|
8
|
+
"mappings": "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",
|
|
9
|
+
"debugId": "8BE4A8782C7D852264756E2164756E21",
|
|
10
10
|
"names": []
|
|
11
11
|
}
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { type BaseContext, type Empty } from '@alxia/core';
|
|
1
|
+
import { type BaseContext, type Empty, type Middleware, type MiddlewareMark, type Next, type Reply } from '@alxia/core';
|
|
2
2
|
import { type RateLimitStore } from './store';
|
|
3
3
|
/**
|
|
4
4
|
* `Requires` is what `key` and `skip` read from the context beyond
|
|
5
|
-
* `BaseContext` — a `user` an earlier
|
|
5
|
+
* `BaseContext` — a `user` an earlier middleware adds — and what the app that
|
|
6
6
|
* uses the limit must then give.
|
|
7
7
|
*/
|
|
8
8
|
export interface RateLimitOptions<Requires extends object = Empty> {
|
|
@@ -36,20 +36,23 @@ export interface RateLimitInfo {
|
|
|
36
36
|
readonly resetAfter: number;
|
|
37
37
|
}
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
39
|
+
* What `rateLimit()` makes: a middleware that requires `Requires` of the
|
|
40
|
+
* app, and gives `rateLimit` or answers the 429.
|
|
41
|
+
*/
|
|
42
|
+
export type RateLimit<Requires extends object = Empty> = Middleware<Requires, Promise<Reply<429, RateLimitedBody> | Next<{
|
|
43
|
+
rateLimit: RateLimitInfo | undefined;
|
|
44
|
+
}>>> & MiddlewareMark;
|
|
45
|
+
/**
|
|
46
|
+
* A rate limit, as a middleware: every request it runs on is counted —
|
|
47
|
+
* the routes declared after it, and, given to `app.use`, a request no
|
|
48
|
+
* route matches too — and answered a 429 past the limit.
|
|
42
49
|
*
|
|
43
50
|
* ```ts
|
|
44
51
|
* app.use(rateLimit({ limit: 100, windowMs: 60_000 })).get(...);
|
|
45
52
|
* ```
|
|
46
53
|
*
|
|
47
|
-
* A `key` that reads what an earlier
|
|
48
|
-
* must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.
|
|
54
|
+
* A `key` that reads what an earlier middleware added names it, and the
|
|
55
|
+
* app must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.
|
|
49
56
|
*/
|
|
50
|
-
export declare function rateLimit<Requires extends object = Empty>(options: RateLimitOptions<Requires>):
|
|
51
|
-
rateLimit: undefined;
|
|
52
|
-
} | {
|
|
53
|
-
rateLimit: RateLimitInfo;
|
|
54
|
-
}), Empty, "", import("@alxia/core").Reply<429, RateLimitedBody>> & import("@alxia/core").Requiring<Requires>;
|
|
57
|
+
export declare function rateLimit<Requires extends object = Empty>(options: RateLimitOptions<Requires>): NoInfer<RateLimit<Requires>>;
|
|
55
58
|
//# sourceMappingURL=rate-limit.d.ts.map
|
package/dist/rate-limit.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,UAAU,EACf,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,KAAK,KAAK,EACV,MAAM,aAAa,CAAC;AACrB,OAAO,EAA8B,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AAE1E;;;;GAIG;AACH,MAAM,WAAW,gBAAgB,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK;IAChE,+EAA+E;IAC/E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oFAAoF;IACpF,QAAQ,CAAC,GAAG,CAAC,EAAE,CACd,GAAG,EAAE,WAAW,GAAG,QAAQ,KACvB,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAC;IACtD,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,CAAC,EAAE,cAAc,CAAC;IAChC,mCAAmC;IACnC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,WAAW,GAAG,QAAQ,KAAK,OAAO,CAAC;IACzD;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,GAAG,QAAQ,GAAG,KAAK,CAAC;CAC9C;AAED,2BAA2B;AAC3B,MAAM,WAAW,eAAe;IAC/B,QAAQ,CAAC,KAAK,EAAE,cAAc,CAAC;IAC/B,qCAAqC;IACrC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED,mEAAmE;AACnE,MAAM,WAAW,aAAa;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uDAAuD;IACvD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED;;;GAGG;AACH,MAAM,MAAM,SAAS,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,IAAI,UAAU,CAClE,QAAQ,EACR,OAAO,CACN,KAAK,CAAC,GAAG,EAAE,eAAe,CAAC,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,aAAa,GAAG,SAAS,CAAA;CAAE,CAAC,CAC5E,CACD,GACA,cAAc,CAAC;AAEhB;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,EACxD,OAAO,EAAE,gBAAgB,CAAC,QAAQ,CAAC,GACjC,OAAO,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,CAsC9B"}
|
package/docs/README.md
CHANGED
|
@@ -6,6 +6,6 @@ the errors you may meet, and what is coming.
|
|
|
6
6
|
|
|
7
7
|
| Page | Read it when |
|
|
8
8
|
| --- | --- |
|
|
9
|
-
| [Guide](guide.md) | choosing what to count and where, reading the 429 on the
|
|
9
|
+
| [Guide](guide.md) | choosing what to count and where, reading the 429 on the wire, keeping counts across processes, or testing a limit |
|
|
10
10
|
| [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit does not count as you expected |
|
|
11
11
|
| [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
|
package/docs/guide.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This page covers everything `rateLimit` does: which requests it counts, what
|
|
4
4
|
it answers past the limit, the headers it sets, what a route and a client
|
|
5
|
-
read, and where the counts are kept.
|
|
5
|
+
read, and where the counts are kept. It is a middleware: `app.use(rateLimit(…))`.
|
|
6
6
|
|
|
7
7
|
```ts
|
|
8
8
|
import { alxia } from '@alxia/core';
|
|
@@ -25,7 +25,7 @@ app.listen({ port: 3000 });
|
|
|
25
25
|
```ts
|
|
26
26
|
function rateLimit<Requires extends object = Empty>(
|
|
27
27
|
options: RateLimitOptions<Requires>,
|
|
28
|
-
):
|
|
28
|
+
): RateLimit<Requires>; // a middleware, given to `app.use`, which checks `Requires`
|
|
29
29
|
|
|
30
30
|
interface RateLimitOptions<Requires extends object = Empty> {
|
|
31
31
|
readonly limit: number;
|
|
@@ -37,10 +37,19 @@ interface RateLimitOptions<Requires extends object = Empty> {
|
|
|
37
37
|
}
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
`rateLimit` returns
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
and
|
|
40
|
+
`rateLimit` returns a middleware that counts the request. Given to `app.use`,
|
|
41
|
+
it adds `rateLimit` to the context of every route declared after it, and each
|
|
42
|
+
of those routes may answer its 429; it also counts a request no route
|
|
43
|
+
matches, and answers its 429 before the 404 (see [Which requests are
|
|
44
|
+
counted](#which-requests-are-counted)). `Requires` is what `key` and `skip`
|
|
45
|
+
read beyond `BaseContext`; see [Reading the app's context](#reading-the-apps-context).
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
type RateLimit<Requires extends object = Empty> = Middleware<
|
|
49
|
+
Requires,
|
|
50
|
+
Promise<Reply<429, RateLimitedBody> | Next<{ rateLimit: RateLimitInfo | undefined }>>
|
|
51
|
+
>;
|
|
52
|
+
```
|
|
44
53
|
|
|
45
54
|
## Options
|
|
46
55
|
|
|
@@ -89,9 +98,9 @@ app.use(
|
|
|
89
98
|
);
|
|
90
99
|
```
|
|
91
100
|
|
|
92
|
-
`key` is typed with `BaseContext`, what every
|
|
93
|
-
`url`, `ip`, `server`, `route` and `pathParams`, and with `Requires`, empty
|
|
94
|
-
by default. To read what an earlier
|
|
101
|
+
`key` is typed with `BaseContext`, what every middleware reads: the request,
|
|
102
|
+
`url`, `ip`, `server`, `route` (`undefined` on a request no route matches) and `pathParams`, and with `Requires`, empty
|
|
103
|
+
by default. To read what an earlier middleware added, see [Reading the app's
|
|
95
104
|
context](#reading-the-apps-context).
|
|
96
105
|
|
|
97
106
|
### `skip`
|
|
@@ -111,10 +120,10 @@ app.use(
|
|
|
111
120
|
|
|
112
121
|
### Reading the app's context
|
|
113
122
|
|
|
114
|
-
To count by what an earlier
|
|
115
|
-
it as `rateLimit`'s type argument. `key` and `skip` then read it, and
|
|
116
|
-
limit
|
|
117
|
-
|
|
123
|
+
To count by what an earlier middleware added, such as a signed-in `user`,
|
|
124
|
+
name it as `rateLimit`'s type argument. `key` and `skip` then read it, and
|
|
125
|
+
the app that uses the limit must give it first: an app that does not give
|
|
126
|
+
`user` before it cannot use it.
|
|
118
127
|
|
|
119
128
|
```ts
|
|
120
129
|
const perUser = rateLimit<{ user: { id: string; role: string } }>({
|
|
@@ -125,12 +134,12 @@ const perUser = rateLimit<{ user: { id: string; role: string } }>({
|
|
|
125
134
|
});
|
|
126
135
|
|
|
127
136
|
const app = alxia()
|
|
128
|
-
.
|
|
137
|
+
.plugin(auth) // derives user, or answers 401
|
|
129
138
|
.use(perUser)
|
|
130
139
|
.get('/search', handler);
|
|
131
140
|
|
|
132
141
|
alxia().use(perUser);
|
|
133
|
-
// error:
|
|
142
|
+
// error: Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: { id: string; role: string; }; }'
|
|
134
143
|
```
|
|
135
144
|
|
|
136
145
|
### `headers`
|
|
@@ -163,13 +172,25 @@ counts live in that process and are lost when it stops. See
|
|
|
163
172
|
|
|
164
173
|
## Which requests are counted
|
|
165
174
|
|
|
166
|
-
The limit is a
|
|
175
|
+
The limit is a middleware, so order decides, at runtime and in the types:
|
|
167
176
|
|
|
168
|
-
- a route declared **before** `use(rateLimit(…))` is not counted, and
|
|
169
|
-
|
|
177
|
+
- a route declared **before** `use(rateLimit(…))` is not counted, and
|
|
178
|
+
never answers the 429;
|
|
170
179
|
- a route declared **after** it is counted, and may answer the 429;
|
|
180
|
+
- a request **no route matches** (a 404, a 405) is counted too, when the limit
|
|
181
|
+
is on the app: every top-level `use()` runs on it, wherever declared, and
|
|
182
|
+
past the limit the 429 comes before the 404. Spamming missing paths spends
|
|
183
|
+
the allowance;
|
|
171
184
|
- inside a [group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md#groups),
|
|
172
|
-
the limit stays
|
|
185
|
+
the limit stays inside it: its routes, and an unmatched request under the
|
|
186
|
+
group's prefix, never a route declared after the group nor a path outside
|
|
187
|
+
the prefix;
|
|
188
|
+
- a path-scoped `use('/api', rateLimit(…))` does not compile: a middleware
|
|
189
|
+
given a path may add nothing to the context, and the limit adds
|
|
190
|
+
`rateLimit`. A group is the scope.
|
|
191
|
+
|
|
192
|
+
A guard on the app answers an anonymous caller of a missing path with its
|
|
193
|
+
refusal, not the 404: that is the same rule for a rate limit.
|
|
173
194
|
|
|
174
195
|
```ts
|
|
175
196
|
const app = alxia()
|
|
@@ -183,9 +204,8 @@ const app = alxia()
|
|
|
183
204
|
.get('/search', ({ reply }) => reply(200, [])); // the group's limit does not reach it
|
|
184
205
|
```
|
|
185
206
|
|
|
186
|
-
|
|
187
|
-
refuses with a 400 has already been counted.
|
|
188
|
-
(a 404) never reaches the hook, and is not counted.
|
|
207
|
+
The limit runs before the request is validated: a request the route
|
|
208
|
+
refuses with a 400 has already been counted.
|
|
189
209
|
|
|
190
210
|
Two limits on the same routes both count, and either may answer the 429 —
|
|
191
211
|
a burst limit and an hourly one, say. Each sets its own headers on the
|
|
@@ -226,7 +246,7 @@ app
|
|
|
226
246
|
|
|
227
247
|
## The 429
|
|
228
248
|
|
|
229
|
-
Past the limit the
|
|
249
|
+
Past the limit the middleware ends the request before the route runs:
|
|
230
250
|
|
|
231
251
|
```text
|
|
232
252
|
HTTP/1.1 429 Too Many Requests
|
|
@@ -249,30 +269,25 @@ interface RateLimitedBody {
|
|
|
249
269
|
`retryAfter` and `Retry-After` are the same number. A refused request
|
|
250
270
|
counts nothing: retrying after `retryAfter` seconds succeeds.
|
|
251
271
|
|
|
252
|
-
## On the
|
|
272
|
+
## On the wire
|
|
253
273
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
274
|
+
Every route after the limit may answer `429 { error: 'rate_limited',
|
|
275
|
+
retryAfter }`; a route declared before it never does. Declare the 429 in
|
|
276
|
+
your OpenAPI document, and the client you generate from it (with
|
|
277
|
+
`@nxgt/openapi-codegen`, say) reads it typed. A caller waits, then tries
|
|
278
|
+
again:
|
|
257
279
|
|
|
258
280
|
```ts
|
|
259
|
-
import { client } from '@alxia/client';
|
|
260
|
-
import type { App } from './server';
|
|
261
|
-
|
|
262
|
-
const api = client<App>('http://localhost:3000');
|
|
263
|
-
|
|
264
281
|
async function search() {
|
|
265
282
|
for (;;) {
|
|
266
|
-
const
|
|
267
|
-
if (
|
|
268
|
-
await
|
|
283
|
+
const response = await fetch('http://localhost:3000/search');
|
|
284
|
+
if (response.status !== 429) return response;
|
|
285
|
+
const { retryAfter } = await response.json();
|
|
286
|
+
await Bun.sleep(retryAfter * 1000); // wait, then try again
|
|
269
287
|
}
|
|
270
288
|
}
|
|
271
289
|
```
|
|
272
290
|
|
|
273
|
-
A route declared before the limit has no 429 in its type; comparing its
|
|
274
|
-
status to `429` is a compile error.
|
|
275
|
-
|
|
276
291
|
## Stores
|
|
277
292
|
|
|
278
293
|
A store counts and decides. `rateLimit` asks it once per counted request,
|
|
@@ -348,7 +363,7 @@ Keep a reference to the store to forget a key — the failed logins of an
|
|
|
348
363
|
address that has just logged in:
|
|
349
364
|
|
|
350
365
|
```ts
|
|
351
|
-
import { alxia } from '@alxia/core';
|
|
366
|
+
import { alxia, validate } from '@alxia/core';
|
|
352
367
|
import { MemoryStore, rateLimit } from '@alxia/rate-limit';
|
|
353
368
|
import { z } from 'zod';
|
|
354
369
|
|
|
@@ -361,7 +376,7 @@ export const app = alxia()
|
|
|
361
376
|
.use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: attempts }))
|
|
362
377
|
.post(
|
|
363
378
|
'/login',
|
|
364
|
-
{ body: z.object({ name: z.string(), password: z.string() }) },
|
|
379
|
+
validate({ body: z.object({ name: z.string(), password: z.string() }) }),
|
|
365
380
|
async ({ body, ip, reply }) => {
|
|
366
381
|
if (passwords.get(body.name) !== body.password) {
|
|
367
382
|
return reply(401, { error: 'invalid_credentials' as const });
|
|
@@ -390,7 +405,6 @@ nothing. Give the app an `ip` that reads a header, and send it:
|
|
|
390
405
|
|
|
391
406
|
```ts
|
|
392
407
|
import { expect, test } from 'bun:test';
|
|
393
|
-
import { client } from '@alxia/client';
|
|
394
408
|
import { alxia } from '@alxia/core';
|
|
395
409
|
import { rateLimit } from '@alxia/rate-limit';
|
|
396
410
|
|
|
@@ -399,15 +413,13 @@ const app = alxia({ ip: (request) => request.headers.get('x-ip') ?? undefined })
|
|
|
399
413
|
.get('/limited', ({ rateLimit, reply }) => reply(200, rateLimit?.remaining ?? -1));
|
|
400
414
|
|
|
401
415
|
test('answers 429 past the limit, per address', async () => {
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
await
|
|
406
|
-
const third = await api.get('/limited', init);
|
|
416
|
+
const from = (ip: string) => app.request('/limited', { headers: { 'x-ip': ip } });
|
|
417
|
+
expect(await (await from('1.1.1.1')).json()).toBe(1);
|
|
418
|
+
await from('1.1.1.1');
|
|
419
|
+
const third = await from('1.1.1.1');
|
|
407
420
|
expect(third.status).toBe(429);
|
|
408
|
-
expect(third.
|
|
409
|
-
|
|
410
|
-
expect(other.status).toBe(200);
|
|
421
|
+
expect(third.headers.get('retry-after')).toBe('60');
|
|
422
|
+
expect((await from('2.2.2.2')).status).toBe(200);
|
|
411
423
|
});
|
|
412
424
|
```
|
|
413
425
|
|
|
@@ -417,5 +429,5 @@ distinct `x-ip` in each, so one test does not spend another's allowance.
|
|
|
417
429
|
## See also
|
|
418
430
|
|
|
419
431
|
- [Troubleshooting](troubleshooting.md): a message, and what to do about it.
|
|
420
|
-
- [`@alxia/core`'s
|
|
421
|
-
how
|
|
432
|
+
- [`@alxia/core`'s middleware guide](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/middleware.md):
|
|
433
|
+
how `use`, groups and their order work, and which requests a middleware runs on.
|
package/docs/roadmap.md
CHANGED
|
@@ -7,7 +7,10 @@ number on it. Every release, with each change it made, is in
|
|
|
7
7
|
|
|
8
8
|
## Now
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
- **A middleware, not a plugin.** `app.use(rateLimit({ limit, windowMs }))` is
|
|
11
|
+
the form; `app.plugin(rateLimit(…))` keeps working, deprecated. Given to the
|
|
12
|
+
app, the limit also counts a request no route matches, and `RateLimit<Requires>`
|
|
13
|
+
names what `rateLimit()` returns.
|
|
11
14
|
|
|
12
15
|
## Next
|
|
13
16
|
|
|
@@ -30,15 +33,17 @@ Nothing scheduled yet.
|
|
|
30
33
|
|
|
31
34
|
### 0.1.0
|
|
32
35
|
|
|
33
|
-
- **A rate limit as a plugin.** `
|
|
36
|
+
- **A rate limit as a plugin.** `plugin(rateLimit({ limit, windowMs }))` counts
|
|
34
37
|
the requests of every route declared after it, per client address by
|
|
35
38
|
default, and answers a 429 with `Retry-After` and
|
|
36
39
|
`{ error: 'rate_limited', retryAfter }` past the limit.
|
|
37
40
|
- **Options that can work, or a startup error.** A `limit` or `windowMs`
|
|
38
41
|
that is not a whole number of 1 or more throws when `rateLimit()` is
|
|
39
42
|
called.
|
|
40
|
-
- **A
|
|
41
|
-
|
|
43
|
+
- **A 429 only behind the limit.** A route declared before the limit is
|
|
44
|
+
never counted and never answers a 429. (Its place in a typed client left
|
|
45
|
+
with the client: alxia is OpenAPI spec first, so the 429 is declared in
|
|
46
|
+
the document a client is generated from.)
|
|
42
47
|
- **What the route reads.** `ctx.rateLimit` gives the limit, what is left,
|
|
43
48
|
and when the allowance is whole again.
|
|
44
49
|
- **Standard headers.** The IETF draft's `RateLimit-Limit`, `-Remaining`,
|
package/docs/troubleshooting.md
CHANGED
|
@@ -9,7 +9,6 @@ nothing of its own; past the limit it answers a 429.
|
|
|
9
9
|
- [`Property 'user' does not exist on type 'BaseContext & Empty'`](#property-user-does-not-exist-on-type-basecontext--empty)
|
|
10
10
|
- [`Type '() => Promise<boolean>' is not assignable to type '(ctx: BaseContext & Empty) => boolean'`](#type---promiseboolean-is-not-assignable-to-type-ctx-basecontext--empty--boolean)
|
|
11
11
|
- [`'rateLimit' is possibly 'undefined'`](#ratelimit-is-possibly-undefined)
|
|
12
|
-
- [`This comparison appears to be unintentional because the types '200 | 500' and '429' have no overlap`](#this-comparison-appears-to-be-unintentional-because-the-types-200--500-and-429-have-no-overlap)
|
|
13
12
|
|
|
14
13
|
**Startup**
|
|
15
14
|
|
|
@@ -25,19 +24,20 @@ nothing of its own; past the limit it answers a 429.
|
|
|
25
24
|
- [Nothing is limited, and no `RateLimit-*` header is sent](#nothing-is-limited-and-no-ratelimit--header-is-sent)
|
|
26
25
|
- [A client makes more than `limit` requests](#a-client-makes-more-than-limit-requests)
|
|
27
26
|
- [A client is refused before `limit` requests](#a-client-is-refused-before-limit-requests)
|
|
27
|
+
- [A missing path answers 429](#a-missing-path-answers-429)
|
|
28
28
|
|
|
29
29
|
## Types
|
|
30
30
|
|
|
31
31
|
### `Property 'user' does not exist on type 'BaseContext & Empty'`
|
|
32
32
|
|
|
33
|
-
**When:** a `key` (or `skip`) reads something an earlier `derive` or
|
|
33
|
+
**When:** a `key` (or `skip`) reads something an earlier `derive` or middleware
|
|
34
34
|
added to the context, and `rateLimit` is not told about it.
|
|
35
35
|
|
|
36
36
|
```text
|
|
37
37
|
error TS2339: Property 'user' does not exist on type 'BaseContext & Empty'.
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
**Why:** `key` and `skip` are typed with `BaseContext`, what every
|
|
40
|
+
**Why:** `key` and `skip` are typed with `BaseContext`, what every middleware
|
|
41
41
|
reads — `request`, `url`, `ip`, `server`, `route`, `pathParams` — plus what
|
|
42
42
|
you name as `rateLimit`'s type argument, and nothing else. `rateLimit` is
|
|
43
43
|
built before it is used, so it cannot see the app it will be used on.
|
|
@@ -52,11 +52,11 @@ const perUser = rateLimit<{ user: { id: string } }>({
|
|
|
52
52
|
key: ({ user }) => user.id,
|
|
53
53
|
});
|
|
54
54
|
|
|
55
|
-
alxia().
|
|
55
|
+
alxia().plugin(auth).use(perUser); // auth derives user
|
|
56
56
|
```
|
|
57
57
|
|
|
58
58
|
On an app that does not give `user`, `use(perUser)` is a compile error:
|
|
59
|
-
|
|
59
|
+
`Property 'user' is missing in type 'BaseContext & Empty' but required in type '{ user: { id: string; }; }'`.
|
|
60
60
|
|
|
61
61
|
### `Type '() => Promise<boolean>' is not assignable to type '(ctx: BaseContext & Empty) => boolean'`
|
|
62
62
|
|
|
@@ -104,29 +104,6 @@ app
|
|
|
104
104
|
.get('/quota', ({ rateLimit, reply }) => reply(200, { remaining: rateLimit?.remaining ?? null }));
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
-
### `This comparison appears to be unintentional because the types '200 | 500' and '429' have no overlap`
|
|
108
|
-
|
|
109
|
-
**When:** client code checks for a 429 on a route that cannot answer one.
|
|
110
|
-
The left-hand union is that route's statuses.
|
|
111
|
-
|
|
112
|
-
```text
|
|
113
|
-
error TS2367: This comparison appears to be unintentional because the types '200 | 500' and '429' have no overlap.
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
**Why:** the limit applies to the routes declared after
|
|
117
|
-
`use(rateLimit(…))`, in the types as at runtime. This route was declared
|
|
118
|
-
before it, or outside the group that holds the limit, so it is never
|
|
119
|
-
limited and its type has no 429.
|
|
120
|
-
|
|
121
|
-
**Fix:** declare the route after the limit, if it should be limited — or
|
|
122
|
-
drop the check, if it should not:
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
const app = alxia()
|
|
126
|
-
.use(rateLimit({ limit: 100, windowMs: 60_000 }))
|
|
127
|
-
.get('/search', ({ reply }) => reply(200, [])); // now 200 | 429 | 500
|
|
128
|
-
```
|
|
129
|
-
|
|
130
107
|
## Startup
|
|
131
108
|
|
|
132
109
|
### `TypeError: rateLimit: … must be a whole number of 1 or more, not …`
|
|
@@ -161,13 +138,12 @@ app.use(rateLimit({ limit: Number(Bun.env.RATE_LIMIT ?? 100), windowMs: 60_000 }
|
|
|
161
138
|
header are the seconds until a request would be allowed, at least 1. The
|
|
162
139
|
refused request counted nothing.
|
|
163
140
|
|
|
164
|
-
**Fix:** on the client, wait that long
|
|
165
|
-
`data` is typed:
|
|
141
|
+
**Fix:** on the client, wait that long, then try again:
|
|
166
142
|
|
|
167
143
|
```ts
|
|
168
|
-
const
|
|
169
|
-
if (
|
|
170
|
-
await Bun.sleep(
|
|
144
|
+
const response = await fetch('http://localhost:3000/search');
|
|
145
|
+
if (response.status === 429) {
|
|
146
|
+
await Bun.sleep((await response.json()).retryAfter * 1000);
|
|
171
147
|
}
|
|
172
148
|
```
|
|
173
149
|
|
|
@@ -197,13 +173,14 @@ const app = alxia({
|
|
|
197
173
|
|
|
198
174
|
**When:** requests past `limit` still answer 200, without a rate-limit
|
|
199
175
|
header, and `ctx.rateLimit` is `undefined` — typically in a test calling
|
|
200
|
-
the app through `
|
|
176
|
+
the app through `app.fetch` or `app.request`.
|
|
201
177
|
|
|
202
178
|
**Why:** a request whose key is `undefined` is not counted. Without a server
|
|
203
179
|
there is no connection, so the default `ip` is `undefined`, and so is the
|
|
204
180
|
default key. A custom `key` returning `undefined`, or a `skip` returning
|
|
205
181
|
`true`, does the same. (A route declared before the limit is not counted
|
|
206
|
-
either, and
|
|
182
|
+
either, and never answers a 429; nor is a request a group's limit does not
|
|
183
|
+
run on.)
|
|
207
184
|
|
|
208
185
|
**Fix:** in tests, read the address from a header you send:
|
|
209
186
|
|
|
@@ -212,7 +189,7 @@ const app = alxia({ ip: (request) => request.headers.get('x-ip') ?? undefined })
|
|
|
212
189
|
.use(rateLimit({ limit: 2, windowMs: 60_000 }))
|
|
213
190
|
.get('/limited', ({ reply }) => reply(200, 'ok'));
|
|
214
191
|
|
|
215
|
-
await
|
|
192
|
+
await app.request('/limited', { headers: { 'x-ip': '1.1.1.1' } });
|
|
216
193
|
```
|
|
217
194
|
|
|
218
195
|
### A client makes more than `limit` requests
|
|
@@ -245,6 +222,9 @@ app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { nam
|
|
|
245
222
|
show the later one's numbers.
|
|
246
223
|
- **Refused requests are counted.** The limit runs before the route
|
|
247
224
|
validates the request, so a request answered 400 has spent one.
|
|
225
|
+
- **Unmatched requests are counted.** On the app, the limit runs on a
|
|
226
|
+
request no route matches too, so a client probing missing paths spends
|
|
227
|
+
its allowance: see [A missing path answers 429](#a-missing-path-answers-429).
|
|
248
228
|
|
|
249
229
|
**Fix:** give each limit its own `MemoryStore` — or none, and `rateLimit`
|
|
250
230
|
creates one — and give all but one limit `headers: false`:
|
|
@@ -254,3 +234,25 @@ app
|
|
|
254
234
|
.use(rateLimit({ limit: 10, windowMs: 1_000 }))
|
|
255
235
|
.use(rateLimit({ limit: 1_000, windowMs: 60 * 60_000, headers: false }));
|
|
256
236
|
```
|
|
237
|
+
|
|
238
|
+
### A missing path answers 429
|
|
239
|
+
|
|
240
|
+
**When:** a request to a path no route serves answers `429`, not `404`, once
|
|
241
|
+
the client is past the limit.
|
|
242
|
+
|
|
243
|
+
**Why:** a limit given to `app.use` runs on every request, a request no
|
|
244
|
+
route matches included, and answers before the 404. Declared after a route,
|
|
245
|
+
it still runs for unmatched requests: only the routes before it are spared.
|
|
246
|
+
A limit inside a `group` does not.
|
|
247
|
+
|
|
248
|
+
**Fix:** that is the limit working. To count only some routes, scope it:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
const app = alxia()
|
|
252
|
+
.get('/health', ({ reply }) => reply(200, 'ok'))
|
|
253
|
+
.group('/api', (api) =>
|
|
254
|
+
api
|
|
255
|
+
.use(rateLimit({ limit: 100, windowMs: 60_000 })) // /api routes only
|
|
256
|
+
.get('/search', ({ reply }) => reply(200, [])),
|
|
257
|
+
);
|
|
258
|
+
```
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/rate-limit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Rate limiting for alxia
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
7
7
|
"main": "./dist/index.js",
|
|
@@ -41,13 +41,12 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/
|
|
45
|
-
"@alxia/core": "^0.2.0",
|
|
44
|
+
"@alxia/core": "^0.4.0",
|
|
46
45
|
"@types/bun": "^1.4.2",
|
|
47
46
|
"zod": "^4.2.0"
|
|
48
47
|
},
|
|
49
48
|
"peerDependencies": {
|
|
50
|
-
"@alxia/core": "^0.
|
|
49
|
+
"@alxia/core": "^0.4.0",
|
|
51
50
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
52
51
|
}
|
|
53
52
|
}
|