@alxia/rate-limit 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Steve Tsala
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,75 @@
1
- # Temporary Holding Version
1
+ # @alxia/rate-limit
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Rate limiting for [alxia](https://www.npmjs.com/package/@alxia/core), typed:
4
+ the 429 is part of every route behind the limit, so
5
+ [`@alxia/client`](https://www.npmjs.com/package/@alxia/client) reads it. No
6
+ dependency.
7
+
8
+ ```sh
9
+ bun add @alxia/rate-limit @alxia/core
10
+ bun add -d typescript
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ ```ts
16
+ import { rateLimit } from '@alxia/rate-limit';
17
+
18
+ const app = alxia()
19
+ .get('/health', ...) // not limited
20
+ .use(rateLimit({ limit: 100, windowMs: 60_000 }))
21
+ .get('/search', ({ rateLimit, reply }) => ...); // limited; rateLimit.remaining
22
+
23
+ const result = await api.get('/search');
24
+ if (result.status === 429) result.data.retryAfter; // seconds
25
+ ```
26
+
27
+ Past the limit, a 429 with `Retry-After` and
28
+ `{ error: 'rate_limited', retryAfter }`. Every counted response carries the
29
+ IETF draft's `RateLimit-Limit`, `-Remaining`, `-Reset` and `-Policy`.
30
+
31
+ ## Options
32
+
33
+ | option | default | |
34
+ | --- | --- | --- |
35
+ | `limit` | required | requests per window: a whole number, 1 or more |
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 plugin adds |
38
+ | `store` | `MemoryStore` | where: `redisStore` from `@alxia/redis`, or your own `RateLimitStore` |
39
+ | `skip` | none | requests not counted |
40
+ | `headers` | `'draft'` | `'legacy'` for `X-RateLimit-*`, or `false` |
41
+
42
+ Behind a proxy, give the app an `ip` option that reads the header it sets:
43
+ `alxia({ ip: (request) => request.headers.get('x-real-ip') ?? undefined })`.
44
+
45
+ ## Across processes
46
+
47
+ `MemoryStore` counts in one process. Behind a load balancer, give every
48
+ process the same store: [`@alxia/redis`](https://www.npmjs.com/package/@alxia/redis)'s
49
+ `redisStore` counts in Redis, with GCRA timed by the Redis server's clock.
50
+
51
+ ```ts
52
+ import { redisStore } from '@alxia/redis';
53
+
54
+ app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { name: 'api' }) }));
55
+ ```
56
+
57
+ A store of your own implements `RateLimitStore`: `consume(key, { limit,
58
+ windowMs })` decides — `allowed`, `remaining`, `resetAfter` and
59
+ `retryAfter`, delays in milliseconds — and a refused request counts
60
+ nothing.
61
+
62
+ ## API
63
+
64
+ | export | |
65
+ | --- | --- |
66
+ | `rateLimit(options)` | the plugin: an app that derives `rateLimit` |
67
+ | `MemoryStore` | a fixed window in one process's memory |
68
+ | `RateLimitStore`, `Decision`, `Policy` | a store's contract |
69
+ | `RateLimitedBody`, `RateLimitInfo`, `RateLimitOptions` | its types |
70
+
71
+ ## Documentation
72
+
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 client, stores, and testing.
74
+ - [Troubleshooting](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/troubleshooting.md): an error message, and what to do about it.
75
+ - [Roadmap](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/docs/roadmap.md): what is coming, and what is not planned.
@@ -0,0 +1,3 @@
1
+ export { type RateLimitedBody, type RateLimitInfo, type RateLimitOptions, rateLimit, } from './rate-limit';
2
+ export { type Decision, MemoryStore, type Policy, type RateLimitStore, } from './store';
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,110 @@
1
+ // src/rate-limit.ts
2
+ import { definePlugin } from "@alxia/core";
3
+
4
+ // src/store.ts
5
+ class MemoryStore {
6
+ #hits = new Map;
7
+ #sweeper;
8
+ consume(key, policy) {
9
+ const now = Date.now();
10
+ let entry = this.#hits.get(key);
11
+ if (entry === undefined || entry.resetAt <= now) {
12
+ entry = { count: 0, resetAt: now + policy.windowMs };
13
+ this.#hits.set(key, entry);
14
+ }
15
+ this.#sweep(policy.windowMs);
16
+ const resetAfter = entry.resetAt - now;
17
+ if (entry.count >= policy.limit) {
18
+ return {
19
+ allowed: false,
20
+ remaining: 0,
21
+ resetAfter,
22
+ retryAfter: resetAfter
23
+ };
24
+ }
25
+ entry.count++;
26
+ return {
27
+ allowed: true,
28
+ remaining: policy.limit - entry.count,
29
+ resetAfter,
30
+ retryAfter: 0
31
+ };
32
+ }
33
+ reset(key) {
34
+ this.#hits.delete(key);
35
+ }
36
+ get size() {
37
+ return this.#hits.size;
38
+ }
39
+ #sweep(windowMs) {
40
+ if (this.#sweeper !== undefined)
41
+ return;
42
+ this.#sweeper = setInterval(() => {
43
+ const now = Date.now();
44
+ for (const [key, entry] of this.#hits) {
45
+ if (entry.resetAt <= now)
46
+ this.#hits.delete(key);
47
+ }
48
+ if (this.#hits.size === 0) {
49
+ clearInterval(this.#sweeper);
50
+ this.#sweeper = undefined;
51
+ }
52
+ }, windowMs);
53
+ this.#sweeper.unref?.();
54
+ }
55
+ }
56
+
57
+ // src/rate-limit.ts
58
+ function rateLimit(options) {
59
+ for (const name of ["limit", "windowMs"]) {
60
+ const value = options[name];
61
+ if (!Number.isSafeInteger(value) || value < 1) {
62
+ throw new TypeError(`rateLimit: ${name} must be a whole number of 1 or more, not ${String(value)}`);
63
+ }
64
+ }
65
+ const store = options.store ?? new MemoryStore;
66
+ const key = options.key ?? ((ctx) => ctx.ip);
67
+ const style = options.headers ?? "draft";
68
+ return definePlugin()((app) => app.derive(async (ctx) => {
69
+ const counted = options.skip?.(ctx) ? undefined : await key(ctx);
70
+ if (counted === undefined) {
71
+ const rateLimit = undefined;
72
+ return { rateLimit };
73
+ }
74
+ const decision = await store.consume(counted, {
75
+ limit: options.limit,
76
+ windowMs: options.windowMs
77
+ });
78
+ const reset = Math.ceil(decision.resetAfter / 1000);
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
+ }
89
+ if (!decision.allowed) {
90
+ const retryAfter = Math.max(1, Math.ceil(decision.retryAfter / 1000));
91
+ const body = { error: "rate_limited", retryAfter };
92
+ return ctx.reply(429, body, {
93
+ headers: { "retry-after": String(retryAfter) }
94
+ });
95
+ }
96
+ const rateLimit = {
97
+ limit: options.limit,
98
+ remaining: decision.remaining,
99
+ resetAfter: decision.resetAfter
100
+ };
101
+ return { rateLimit };
102
+ }));
103
+ }
104
+ export {
105
+ MemoryStore,
106
+ rateLimit
107
+ };
108
+
109
+ //# debugId=1917CEB5F68549FD64756E2164756E21
110
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,11 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/rate-limit.ts", "../src/store.ts"],
4
+ "sourcesContent": [
5
+ "import { type BaseContext, definePlugin, type Empty } from '@alxia/core';\nimport { 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 plugin 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 * A rate limit, as a plugin: every route declared after it counts its\n * requests, and answers a 429 past the limit. The 429 is part of each such\n * route's type, so the client reads it.\n *\n * ```ts\n * app.use(rateLimit({ limit: 100, windowMs: 60_000 })).get(...);\n * ```\n *\n * A `key` that reads what an earlier plugin added names it, and the app\n * must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.\n */\nexport function rateLimit<Requires extends object = Empty>(\n\toptions: RateLimitOptions<Requires>,\n) {\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 definePlugin<Requires>()((app) =>\n\t\tapp.derive(async (ctx) => {\n\t\t\tconst counted = options.skip?.(ctx) ? undefined : await key(ctx);\n\t\t\tif (counted === undefined) {\n\t\t\t\tconst rateLimit: RateLimitInfo | undefined = undefined;\n\t\t\t\treturn { rateLimit };\n\t\t\t}\n\t\t\tconst decision = await store.consume(counted, {\n\t\t\t\tlimit: options.limit,\n\t\t\t\twindowMs: options.windowMs,\n\t\t\t});\n\t\t\tconst reset = Math.ceil(decision.resetAfter / 1000);\n\t\t\tif (style === 'draft') {\n\t\t\t\tctx.set.headers.set('ratelimit-limit', String(options.limit));\n\t\t\t\tctx.set.headers.set('ratelimit-remaining', String(decision.remaining));\n\t\t\t\tctx.set.headers.set('ratelimit-reset', String(reset));\n\t\t\t\tctx.set.headers.set(\n\t\t\t\t\t'ratelimit-policy',\n\t\t\t\t\t`${options.limit};w=${Math.ceil(options.windowMs / 1000)}`,\n\t\t\t\t);\n\t\t\t} else if (style === 'legacy') {\n\t\t\t\tctx.set.headers.set('x-ratelimit-limit', String(options.limit));\n\t\t\t\tctx.set.headers.set(\n\t\t\t\t\t'x-ratelimit-remaining',\n\t\t\t\t\tString(decision.remaining),\n\t\t\t\t);\n\t\t\t\tctx.set.headers.set(\n\t\t\t\t\t'x-ratelimit-reset',\n\t\t\t\t\tString(Math.ceil((Date.now() + decision.resetAfter) / 1000)),\n\t\t\t\t);\n\t\t\t}\n\t\t\tif (!decision.allowed) {\n\t\t\t\tconst retryAfter = Math.max(1, Math.ceil(decision.retryAfter / 1000));\n\t\t\t\tconst body: RateLimitedBody = { error: 'rate_limited', retryAfter };\n\t\t\t\treturn ctx.reply(429, body, {\n\t\t\t\t\theaders: { 'retry-after': String(retryAfter) },\n\t\t\t\t});\n\t\t\t}\n\t\t\tconst rateLimit: RateLimitInfo | undefined = {\n\t\t\t\tlimit: options.limit,\n\t\t\t\tremaining: decision.remaining,\n\t\t\t\tresetAfter: decision.resetAfter,\n\t\t\t};\n\t\t\treturn { rateLimit };\n\t\t}),\n\t);\n}\n",
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
+ ],
8
+ "mappings": "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",
9
+ "debugId": "1917CEB5F68549FD64756E2164756E21",
10
+ "names": []
11
+ }
@@ -0,0 +1,55 @@
1
+ import { type BaseContext, type Empty } from '@alxia/core';
2
+ import { type RateLimitStore } from './store';
3
+ /**
4
+ * `Requires` is what `key` and `skip` read from the context beyond
5
+ * `BaseContext` — a `user` an earlier plugin adds — and what the app that
6
+ * uses the limit must then give.
7
+ */
8
+ export interface RateLimitOptions<Requires extends object = Empty> {
9
+ /** How many requests a key may make in a window: a whole number, 1 or more. */
10
+ readonly limit: number;
11
+ /** The window, in milliseconds: a whole number, 1 or more. */
12
+ readonly windowMs: number;
13
+ /** What is counted: the client's address by default. `undefined` is not counted. */
14
+ readonly key?: (ctx: BaseContext & Requires) => string | undefined | Promise<string | undefined>;
15
+ /** Where it is counted: one process's memory by default. */
16
+ readonly store?: RateLimitStore;
17
+ /** Requests not counted at all. */
18
+ readonly skip?: (ctx: BaseContext & Requires) => boolean;
19
+ /**
20
+ * The `RateLimit` headers of the IETF draft on every counted response,
21
+ * `X-RateLimit-*` with `legacy`, or none. `draft` by default.
22
+ */
23
+ readonly headers?: 'draft' | 'legacy' | false;
24
+ }
25
+ /** The body of the 429. */
26
+ export interface RateLimitedBody {
27
+ readonly error: 'rate_limited';
28
+ /** Seconds until the window ends. */
29
+ readonly retryAfter: number;
30
+ }
31
+ /** What the routes behind the limit read: where the key stands. */
32
+ export interface RateLimitInfo {
33
+ readonly limit: number;
34
+ readonly remaining: number;
35
+ /** Milliseconds until the allowance is whole again. */
36
+ readonly resetAfter: number;
37
+ }
38
+ /**
39
+ * A rate limit, as a plugin: every route declared after it counts its
40
+ * requests, and answers a 429 past the limit. The 429 is part of each such
41
+ * route's type, so the client reads it.
42
+ *
43
+ * ```ts
44
+ * app.use(rateLimit({ limit: 100, windowMs: 60_000 })).get(...);
45
+ * ```
46
+ *
47
+ * A `key` that reads what an earlier plugin added names it, and the app
48
+ * must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.
49
+ */
50
+ export declare function rateLimit<Requires extends object = Empty>(options: RateLimitOptions<Requires>): import("@alxia/core").Alxia<Requires & ({
51
+ rateLimit: undefined;
52
+ } | {
53
+ rateLimit: RateLimitInfo;
54
+ }), Empty, "", import("@alxia/core").Reply<429, RateLimitedBody>> & import("@alxia/core").Requiring<Requires>;
55
+ //# sourceMappingURL=rate-limit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../src/rate-limit.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,WAAW,EAAgB,KAAK,KAAK,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAe,KAAK,cAAc,EAAE,MAAM,SAAS,CAAC;AAE3D;;;;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;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,EACxD,OAAO,EAAE,gBAAgB,CAAC,QAAQ,CAAC;;;;8GA4DnC"}
@@ -0,0 +1,35 @@
1
+ /** What a store decides for one request. Every duration is a delay, in milliseconds. */
2
+ export interface Decision {
3
+ readonly allowed: boolean;
4
+ /** Requests the key may still make now, after this one. */
5
+ readonly remaining: number;
6
+ /** Until the key's allowance is whole again. */
7
+ readonly resetAfter: number;
8
+ /** Until a refused request would be allowed; 0 when this one is. */
9
+ readonly retryAfter: number;
10
+ }
11
+ /** The policy a store applies: `limit` requests per `windowMs`. */
12
+ export interface Policy {
13
+ readonly limit: number;
14
+ readonly windowMs: number;
15
+ }
16
+ /**
17
+ * Where requests are counted, and what decides. The memory store counts in
18
+ * one process with a fixed window; `@alxia/redis`'s counts across every
19
+ * process sharing a Redis, with GCRA.
20
+ */
21
+ export interface RateLimitStore {
22
+ /** Counts one request for `key` under `policy`; a refused one counts nothing. */
23
+ consume(key: string, policy: Policy): Decision | Promise<Decision>;
24
+ /** Forgets `key`: a user who just logged in. */
25
+ reset(key: string): void | Promise<void>;
26
+ }
27
+ /** A fixed-window counter in memory, swept as windows end. */
28
+ export declare class MemoryStore implements RateLimitStore {
29
+ #private;
30
+ consume(key: string, policy: Policy): Decision;
31
+ reset(key: string): void;
32
+ /** How many keys are counted. */
33
+ get size(): number;
34
+ }
35
+ //# sourceMappingURL=store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,wFAAwF;AACxF,MAAM,WAAW,QAAQ;IACxB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,2DAA2D;IAC3D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,gDAAgD;IAChD,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,oEAAoE;IACpE,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC5B;AAED,mEAAmE;AACnE,MAAM,WAAW,MAAM;IACtB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC1B;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC9B,iFAAiF;IACjF,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;IACnE,gDAAgD;IAChD,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACzC;AAED,8DAA8D;AAC9D,qBAAa,WAAY,YAAW,cAAc;;IAIjD,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,QAAQ;IA0B9C,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIxB,iCAAiC;IACjC,IAAI,IAAI,IAAI,MAAM,CAEjB;CAgBD"}
package/docs/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @alxia/rate-limit documentation
2
+
3
+ The [package README](../README.md) is the short version. This folder is
4
+ the long one: the options, defaults and behaviours with an example each,
5
+ the errors you may meet, and what is coming.
6
+
7
+ | Page | Read it when |
8
+ | --- | --- |
9
+ | [Guide](guide.md) | choosing what to count and where, reading the 429 on the client, keeping counts across processes, or testing a limit |
10
+ | [Troubleshooting](troubleshooting.md) | something went wrong and you have the message, or a limit does not count as you expected |
11
+ | [Roadmap](roadmap.md) | wondering what is coming, and what is not planned |
package/docs/guide.md ADDED
@@ -0,0 +1,421 @@
1
+ # Guide
2
+
3
+ This page covers everything `rateLimit` does: which requests it counts, what
4
+ it answers past the limit, the headers it sets, what a route and a client
5
+ read, and where the counts are kept.
6
+
7
+ ```ts
8
+ import { alxia } from '@alxia/core';
9
+ import { rateLimit } from '@alxia/rate-limit';
10
+
11
+ const app = alxia()
12
+ .get('/health', ({ reply }) => reply(200, 'ok')) // not limited
13
+ .use(rateLimit({ limit: 100, windowMs: 60_000 }))
14
+ .get('/search', ({ rateLimit, reply }) => // limited
15
+ reply(200, { remaining: rateLimit?.remaining }),
16
+ );
17
+
18
+ app.listen({ port: 3000 });
19
+ // the 101st GET /search from one address within a minute
20
+ // → 429 {"error":"rate_limited","retryAfter":…}
21
+ ```
22
+
23
+ ## The signature
24
+
25
+ ```ts
26
+ function rateLimit<Requires extends object = Empty>(
27
+ options: RateLimitOptions<Requires>,
28
+ ): Alxia<…> & Requiring<Requires>; // an app, given to `use`, which checks `Requires`
29
+
30
+ interface RateLimitOptions<Requires extends object = Empty> {
31
+ readonly limit: number;
32
+ readonly windowMs: number;
33
+ readonly key?: (ctx: BaseContext & Requires) => string | undefined | Promise<string | undefined>;
34
+ readonly store?: RateLimitStore;
35
+ readonly skip?: (ctx: BaseContext & Requires) => boolean;
36
+ readonly headers?: 'draft' | 'legacy' | false;
37
+ }
38
+ ```
39
+
40
+ `rateLimit` returns an app whose single route hook counts the request. Given
41
+ to `use`, it adds `rateLimit` to the context of every route declared after
42
+ it, and its 429 to each of those routes' types. `Requires` is what `key`
43
+ and `skip` read beyond `BaseContext`; see [Reading the app's context](#reading-the-apps-context).
44
+
45
+ ## Options
46
+
47
+ | Option | Type | Default | Effect |
48
+ | --- | --- | --- | --- |
49
+ | `limit` | `number` | required | requests one key may make in a window: a whole number, 1 or more |
50
+ | `windowMs` | `number` | required | the window, in milliseconds: a whole number, 1 or more |
51
+ | `key` | `(ctx: BaseContext & Requires) => string \| undefined \| Promise<…>` | `ctx.ip` | what is counted; `undefined` is not counted |
52
+ | `store` | `RateLimitStore` | a new `MemoryStore` | where the counts are kept |
53
+ | `skip` | `(ctx: BaseContext & Requires) => boolean` | none | requests not counted at all |
54
+ | `headers` | `'draft' \| 'legacy' \| false` | `'draft'` | which rate-limit headers each counted response carries |
55
+
56
+ A `limit` or a `windowMs` that is not a whole number of 1 or more makes
57
+ `rateLimit()` throw a `TypeError` when it is called, so the app fails at
58
+ startup ([troubleshooting](troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-)).
59
+
60
+ A store may refuse larger values than `rateLimit` does: `redisStore`
61
+ refuses a `limit × windowMs` above 9,007,199,254,740 and a `windowMs`
62
+ above ten 365-day years (315,360,000,000), on the first request it counts rather than at startup
63
+ ([`@alxia/redis`: Policies Redis refuses](https://github.com/softistx/alxia/blob/develop/packages/redis/docs/guide/rate-limits.md#policies-redis-refuses)).
64
+
65
+ ### `key`
66
+
67
+ By default a limit counts per client address, `ctx.ip`, which is what the
68
+ app's [`ip` option](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/serving.md#the-clients-address-ip)
69
+ reads. Behind a proxy, that is the proxy's address unless you set `ip`:
70
+
71
+ ```ts
72
+ const app = alxia({
73
+ ip: (request, server) =>
74
+ request.headers.get('x-real-ip') ?? server?.requestIP(request)?.address,
75
+ }).use(rateLimit({ limit: 100, windowMs: 60_000 }));
76
+ ```
77
+
78
+ Count by something else — an API key, a token — by returning it from `key`.
79
+ It may be async. A request whose key is `undefined` is not counted: it passes,
80
+ gets no rate-limit header, and its route reads `rateLimit` as `undefined`.
81
+
82
+ ```ts
83
+ app.use(
84
+ rateLimit({
85
+ limit: 1_000,
86
+ windowMs: 60 * 60_000,
87
+ key: ({ request }) => request.headers.get('x-api-key') ?? undefined, // no key: not counted
88
+ }),
89
+ );
90
+ ```
91
+
92
+ `key` is typed with `BaseContext`, what every route hook reads: the request,
93
+ `url`, `ip`, `server`, `route` and `pathParams`, and with `Requires`, empty
94
+ by default. To read what an earlier plugin added, see [Reading the app's
95
+ context](#reading-the-apps-context).
96
+
97
+ ### `skip`
98
+
99
+ A request `skip` returns `true` for is not counted, exactly like one whose
100
+ key is `undefined`. It is synchronous.
101
+
102
+ ```ts
103
+ app.use(
104
+ rateLimit({
105
+ limit: 100,
106
+ windowMs: 60_000,
107
+ skip: ({ ip }) => ip === '127.0.0.1', // the local health checker
108
+ }),
109
+ );
110
+ ```
111
+
112
+ ### Reading the app's context
113
+
114
+ To count by what an earlier plugin added, such as a signed-in `user`, name
115
+ it as `rateLimit`'s type argument. `key` and `skip` then read it, and the
116
+ limit is a [`definePlugin`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/writing-a-plugin.md#a-plugin-that-needs-an-earlier-one)
117
+ plugin: an app that does not give `user` before it cannot use it.
118
+
119
+ ```ts
120
+ const perUser = rateLimit<{ user: { id: string; role: string } }>({
121
+ limit: 100,
122
+ windowMs: 60_000,
123
+ key: ({ user }) => user.id,
124
+ skip: ({ user }) => user.role === 'admin',
125
+ });
126
+
127
+ const app = alxia()
128
+ .use(auth) // derives user, or answers 401
129
+ .use(perUser)
130
+ .get('/search', handler);
131
+
132
+ alxia().use(perUser);
133
+ // error: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
134
+ ```
135
+
136
+ ### `headers`
137
+
138
+ Every counted response — the route's own and the 429 alike — carries the
139
+ headers of the style you choose:
140
+
141
+ | `headers` | Sent | Values |
142
+ | --- | --- | --- |
143
+ | `'draft'` (default) | `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, `RateLimit-Policy` | the limit; what is left; seconds until the allowance is whole again; `<limit>;w=<window in seconds>` |
144
+ | `'legacy'` | `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` | the limit; what is left; the Unix time, in seconds, at which the allowance is whole again |
145
+ | `false` | none | — |
146
+
147
+ With `limit: 1, windowMs: 60_000`, the first request answers:
148
+
149
+ ```text
150
+ ratelimit-limit: 1
151
+ ratelimit-remaining: 0
152
+ ratelimit-reset: 60
153
+ ratelimit-policy: 1;w=60
154
+ ```
155
+
156
+ The 429 carries `Retry-After` whatever `headers` says.
157
+
158
+ ### `store`
159
+
160
+ Each `rateLimit` call without a `store` creates its own `MemoryStore`: the
161
+ counts live in that process and are lost when it stops. See
162
+ [Stores](#stores) for sharing them across processes, and for resetting a key.
163
+
164
+ ## Which requests are counted
165
+
166
+ The limit is a route hook, so order decides, at runtime and in the types:
167
+
168
+ - a route declared **before** `use(rateLimit(…))` is not counted, and its
169
+ type has no 429;
170
+ - a route declared **after** it is counted, and may answer the 429;
171
+ - inside a [group](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/groups-and-plugins.md#groups),
172
+ the limit stays in the group.
173
+
174
+ ```ts
175
+ const app = alxia()
176
+ .get('/health', ({ reply }) => reply(200, 'ok')) // never counted
177
+ .group('/auth', (auth) =>
178
+ auth
179
+ .use(rateLimit({ limit: 5, windowMs: 15 * 60_000 })) // 5 per 15 minutes
180
+ .post('/login', ({ reply }) => reply(200, 'ok')),
181
+ )
182
+ .use(rateLimit({ limit: 100, windowMs: 60_000 })) // 100 per minute
183
+ .get('/search', ({ reply }) => reply(200, [])); // the group's limit does not reach it
184
+ ```
185
+
186
+ Route hooks run before the request is validated: a request the route
187
+ refuses with a 400 has already been counted. A request that matches no route
188
+ (a 404) never reaches the hook, and is not counted.
189
+
190
+ Two limits on the same routes both count, and either may answer the 429 —
191
+ a burst limit and an hourly one, say. Each sets its own headers on the
192
+ response, so with two `'draft'` limits the later one's values win; give the
193
+ other `headers: false`. A route reads the `rateLimit` of the later one.
194
+
195
+ ```ts
196
+ app
197
+ .use(rateLimit({ limit: 10, windowMs: 1_000 })) // a burst
198
+ .use(rateLimit({ limit: 1_000, windowMs: 60 * 60_000, headers: false })) // an hour
199
+ .get('/search', ({ rateLimit, reply }) => reply(200, rateLimit ?? null)); // the hourly limit's info
200
+ ```
201
+
202
+ ## What a route reads
203
+
204
+ Every route after the limit reads `ctx.rateLimit`:
205
+
206
+ ```ts
207
+ interface RateLimitInfo {
208
+ readonly limit: number;
209
+ readonly remaining: number; // after this request
210
+ readonly resetAfter: number; // milliseconds until the allowance is whole again
211
+ }
212
+ ```
213
+
214
+ It is `RateLimitInfo | undefined`: `undefined` when the request was not
215
+ counted, by `skip` or an `undefined` key.
216
+
217
+ ```ts
218
+ app
219
+ .use(rateLimit({ limit: 100, windowMs: 60_000 }))
220
+ .get('/quota', ({ rateLimit, reply }) =>
221
+ rateLimit === undefined
222
+ ? reply(200, { limited: false as const })
223
+ : reply(200, { limited: true as const, remaining: rateLimit.remaining }),
224
+ );
225
+ ```
226
+
227
+ ## The 429
228
+
229
+ Past the limit the hook ends the request before the route runs:
230
+
231
+ ```text
232
+ HTTP/1.1 429 Too Many Requests
233
+ retry-after: 60
234
+ ratelimit-limit: 100
235
+ ratelimit-remaining: 0
236
+ ratelimit-reset: 60
237
+ ratelimit-policy: 100;w=60
238
+
239
+ {"error":"rate_limited","retryAfter":60}
240
+ ```
241
+
242
+ ```ts
243
+ interface RateLimitedBody {
244
+ readonly error: 'rate_limited';
245
+ readonly retryAfter: number; // seconds until a request would be allowed, at least 1
246
+ }
247
+ ```
248
+
249
+ `retryAfter` and `Retry-After` are the same number. A refused request
250
+ counts nothing: retrying after `retryAfter` seconds succeeds.
251
+
252
+ ## On the client
253
+
254
+ [`@alxia/client`](https://www.npmjs.com/package/@alxia/client) reads the
255
+ 429 from the app's type: a route after the limit resolves to a union with
256
+ `429`, whose `data` is `{ error: 'rate_limited'; retryAfter: number }`.
257
+
258
+ ```ts
259
+ import { client } from '@alxia/client';
260
+ import type { App } from './server';
261
+
262
+ const api = client<App>('http://localhost:3000');
263
+
264
+ async function search() {
265
+ for (;;) {
266
+ const result = await api.get('/search');
267
+ if (result.status !== 429) return result;
268
+ await Bun.sleep(result.data.retryAfter * 1000); // wait, then try again
269
+ }
270
+ }
271
+ ```
272
+
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
+ ## Stores
277
+
278
+ A store counts and decides. `rateLimit` asks it once per counted request,
279
+ and never knows which store it was given.
280
+
281
+ ```ts
282
+ interface RateLimitStore {
283
+ /** Counts one request for `key` under `policy`; a refused one counts nothing. */
284
+ consume(key: string, policy: Policy): Decision | Promise<Decision>;
285
+ /** Forgets `key`. */
286
+ reset(key: string): void | Promise<void>;
287
+ }
288
+
289
+ interface Policy {
290
+ readonly limit: number;
291
+ readonly windowMs: number;
292
+ }
293
+
294
+ interface Decision {
295
+ readonly allowed: boolean;
296
+ readonly remaining: number; // requests still allowed now, after this one
297
+ readonly resetAfter: number; // ms until the allowance is whole again
298
+ readonly retryAfter: number; // ms until a refused request would be allowed; 0 when allowed
299
+ }
300
+ ```
301
+
302
+ ### `MemoryStore`
303
+
304
+ A fixed window per key, in one process's memory. The first request of a key
305
+ opens a window of `windowMs`; the key may make `limit` requests in it; the
306
+ next request after it ends opens a new one. Ended windows are swept on a
307
+ timer that does not keep the process alive.
308
+
309
+ ```ts
310
+ import { MemoryStore } from '@alxia/rate-limit';
311
+
312
+ const store = new MemoryStore();
313
+ const policy = { limit: 2, windowMs: 60_000 };
314
+
315
+ store.consume('a', policy); // { allowed: true, remaining: 1, resetAfter: 60000, retryAfter: 0 }
316
+ store.consume('a', policy); // { allowed: true, remaining: 0, … }
317
+ store.consume('a', policy); // { allowed: false, remaining: 0, retryAfter: 60000, … }
318
+ store.reset('a');
319
+ store.consume('a', policy); // { allowed: true, remaining: 1, … }
320
+ store.size; // 1: the keys it counts
321
+ ```
322
+
323
+ A `MemoryStore` keys its counts by `key` alone, not by policy: give each
324
+ limit its own store, as `rateLimit` does when you pass none.
325
+
326
+ ### Across processes
327
+
328
+ Behind a load balancer each process would count on its own, and a client
329
+ would get `limit` requests per process. Give every process the same store:
330
+ [`@alxia/redis`](https://www.npmjs.com/package/@alxia/redis)'s `redisStore`
331
+ counts in Redis, with GCRA timed by the Redis server's clock.
332
+
333
+ ```ts
334
+ import { redisStore } from '@alxia/redis';
335
+
336
+ app.use(
337
+ rateLimit({
338
+ limit: 100,
339
+ windowMs: 60_000,
340
+ store: redisStore(redis, { name: 'api' }), // redis: a Bun RedisClient
341
+ }),
342
+ );
343
+ ```
344
+
345
+ ### Resetting a key
346
+
347
+ Keep a reference to the store to forget a key — the failed logins of an
348
+ address that has just logged in:
349
+
350
+ ```ts
351
+ import { alxia } from '@alxia/core';
352
+ import { MemoryStore, rateLimit } from '@alxia/rate-limit';
353
+ import { z } from 'zod';
354
+
355
+ const attempts = new MemoryStore();
356
+ const passwords = new Map([['ada', 'lovelace']]);
357
+
358
+ export const app = alxia()
359
+ .group('/auth', (auth) =>
360
+ auth
361
+ .use(rateLimit({ limit: 5, windowMs: 15 * 60_000, store: attempts }))
362
+ .post(
363
+ '/login',
364
+ { body: z.object({ name: z.string(), password: z.string() }) },
365
+ async ({ body, ip, reply }) => {
366
+ if (passwords.get(body.name) !== body.password) {
367
+ return reply(401, { error: 'invalid_credentials' as const });
368
+ }
369
+ if (ip !== undefined) await attempts.reset(ip); // the key is ctx.ip by default
370
+ return reply(200, { name: body.name });
371
+ },
372
+ ),
373
+ );
374
+ ```
375
+
376
+ The key to reset is the one `key` returned: `ctx.ip` by default.
377
+
378
+ ### A store of your own
379
+
380
+ Implement `RateLimitStore`. `consume` decides — `allowed`, `remaining`,
381
+ `resetAfter` and `retryAfter`, each delay in milliseconds — and must count
382
+ nothing for a refused request, or a client that retries on time is refused
383
+ again. `rateLimit` rounds the delays up to seconds for the headers and the
384
+ 429 body.
385
+
386
+ ## Testing
387
+
388
+ Without a server, `ctx.ip` is `undefined`, so the default key counts
389
+ nothing. Give the app an `ip` that reads a header, and send it:
390
+
391
+ ```ts
392
+ import { expect, test } from 'bun:test';
393
+ import { client } from '@alxia/client';
394
+ import { alxia } from '@alxia/core';
395
+ import { rateLimit } from '@alxia/rate-limit';
396
+
397
+ const app = alxia({ ip: (request) => request.headers.get('x-ip') ?? undefined })
398
+ .use(rateLimit({ limit: 2, windowMs: 60_000 }))
399
+ .get('/limited', ({ rateLimit, reply }) => reply(200, rateLimit?.remaining ?? -1));
400
+
401
+ test('answers 429 past the limit, per address', async () => {
402
+ const api = client(app);
403
+ const init = { init: { headers: { 'x-ip': '1.1.1.1' } } };
404
+ expect((await api.get('/limited', init)).data).toBe(1);
405
+ await api.get('/limited', init);
406
+ const third = await api.get('/limited', init);
407
+ expect(third.status).toBe(429);
408
+ expect(third.response.headers.get('retry-after')).toBe('60');
409
+ const other = await api.get('/limited', { init: { headers: { 'x-ip': '2.2.2.2' } } });
410
+ expect(other.status).toBe(200);
411
+ });
412
+ ```
413
+
414
+ The counts live as long as the app: build a new app per test, or use a
415
+ distinct `x-ip` in each, so one test does not spend another's allowance.
416
+
417
+ ## See also
418
+
419
+ - [Troubleshooting](troubleshooting.md): a message, and what to do about it.
420
+ - [`@alxia/core`'s hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md):
421
+ how route hooks and their order work.
@@ -0,0 +1,53 @@
1
+ # Roadmap
2
+
3
+ What `@alxia/rate-limit` gives an app, and what is coming. This page is a
4
+ direction, not a commitment: the version something shipped in is the only
5
+ number on it. Every release, with each change it made, is in
6
+ [`CHANGELOG.md`](https://github.com/softistx/alxia/blob/develop/packages/rate-limit/CHANGELOG.md).
7
+
8
+ ## Now
9
+
10
+ Nothing scheduled yet.
11
+
12
+ ## Next
13
+
14
+ Nothing scheduled yet.
15
+
16
+ ## Later
17
+
18
+ Nothing scheduled yet.
19
+
20
+ ## Not planned
21
+
22
+ - **A runtime dependency.** `@alxia/rate-limit` installs nothing beside
23
+ itself; `@alxia/core` is its only peer.
24
+ - **A Redis store in this package.** This package defines the store's
25
+ contract and ships the memory store; counting across processes is
26
+ `@alxia/redis`'s `redisStore`, which answers the same contract, so an app
27
+ that never runs more than one process installs no Redis client.
28
+
29
+ ## Shipped
30
+
31
+ ### 0.1.0
32
+
33
+ - **A rate limit as a plugin.** `use(rateLimit({ limit, windowMs }))` counts
34
+ the requests of every route declared after it, per client address by
35
+ default, and answers a 429 with `Retry-After` and
36
+ `{ error: 'rate_limited', retryAfter }` past the limit.
37
+ - **Options that can work, or a startup error.** A `limit` or `windowMs`
38
+ that is not a whole number of 1 or more throws when `rateLimit()` is
39
+ called.
40
+ - **A typed 429.** The 429 is part of each limited route's type, so
41
+ `@alxia/client` reads it, and a route declared before the limit has none.
42
+ - **What the route reads.** `ctx.rateLimit` gives the limit, what is left,
43
+ and when the allowance is whole again.
44
+ - **Standard headers.** The IETF draft's `RateLimit-Limit`, `-Remaining`,
45
+ `-Reset` and `-Policy` on every counted response, `X-RateLimit-*` with
46
+ `headers: 'legacy'`, or none.
47
+ - **What is counted, your way.** `key` counts by an address, a token or an
48
+ API key, sync or async; `skip` and an `undefined` key leave a request
49
+ uncounted. `rateLimit<{ user: User }>(…)` types them with what an earlier
50
+ plugin adds, and an app that does not give it cannot use the limit.
51
+ - **A store contract.** `RateLimitStore` decides each request; `MemoryStore`
52
+ counts a fixed window in one process, and `@alxia/redis`'s `redisStore`
53
+ counts across processes. `reset(key)` forgets a key.
@@ -0,0 +1,256 @@
1
+ # Troubleshooting
2
+
3
+ Each entry is headed by what you see: a TypeScript error, a response, or a
4
+ limit that does not count the way you expected. `@alxia/rate-limit` throws
5
+ nothing of its own; past the limit it answers a 429.
6
+
7
+ **Types**
8
+
9
+ - [`Property 'user' does not exist on type 'BaseContext & Empty'`](#property-user-does-not-exist-on-type-basecontext--empty)
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
+ - [`'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
+
14
+ **Startup**
15
+
16
+ - [`TypeError: rateLimit: … must be a whole number of 1 or more, not …`](#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-)
17
+
18
+ **Responses**
19
+
20
+ - [`429 {"error":"rate_limited","retryAfter":…}`](#429-errorrate_limitedretryafter)
21
+
22
+ **Counting**
23
+
24
+ - [Every client is refused at once](#every-client-is-refused-at-once)
25
+ - [Nothing is limited, and no `RateLimit-*` header is sent](#nothing-is-limited-and-no-ratelimit--header-is-sent)
26
+ - [A client makes more than `limit` requests](#a-client-makes-more-than-limit-requests)
27
+ - [A client is refused before `limit` requests](#a-client-is-refused-before-limit-requests)
28
+
29
+ ## Types
30
+
31
+ ### `Property 'user' does not exist on type 'BaseContext & Empty'`
32
+
33
+ **When:** a `key` (or `skip`) reads something an earlier `derive` or plugin
34
+ added to the context, and `rateLimit` is not told about it.
35
+
36
+ ```text
37
+ error TS2339: Property 'user' does not exist on type 'BaseContext & Empty'.
38
+ ```
39
+
40
+ **Why:** `key` and `skip` are typed with `BaseContext`, what every route hook
41
+ reads — `request`, `url`, `ip`, `server`, `route`, `pathParams` — plus what
42
+ you name as `rateLimit`'s type argument, and nothing else. `rateLimit` is
43
+ built before it is used, so it cannot see the app it will be used on.
44
+
45
+ **Fix:** name what `key` reads. The app that uses the limit must then give
46
+ it, before the limit:
47
+
48
+ ```ts
49
+ const perUser = rateLimit<{ user: { id: string } }>({
50
+ limit: 100,
51
+ windowMs: 60_000,
52
+ key: ({ user }) => user.id,
53
+ });
54
+
55
+ alxia().use(auth).use(perUser); // auth derives user
56
+ ```
57
+
58
+ On an app that does not give `user`, `use(perUser)` is a compile error:
59
+ [`the plugin reads "user", which this app's context does not give`](https://github.com/softistx/alxia/blob/develop/packages/core/docs/troubleshooting.md#the-plugin-reads--which-this-apps-context-does-not-give-use-the-plugin-that-adds-it-first).
60
+
61
+ ### `Type '() => Promise<boolean>' is not assignable to type '(ctx: BaseContext & Empty) => boolean'`
62
+
63
+ **When:** `skip` is an `async` function.
64
+
65
+ ```text
66
+ error TS2322: Type '() => Promise<boolean>' is not assignable to type '(ctx: BaseContext & Empty) => boolean'.
67
+ Type 'Promise<boolean>' is not assignable to type 'boolean'.
68
+ ```
69
+
70
+ **Why:** `skip` is synchronous: a promise is always truthy, so an async
71
+ `skip` would skip every request. `key` may be async; `skip` may not.
72
+
73
+ **Fix:** decide synchronously, or move the async part into `key` and return
74
+ `undefined` for a request that should not be counted:
75
+
76
+ ```ts
77
+ app.use(
78
+ rateLimit({
79
+ limit: 100,
80
+ windowMs: 60_000,
81
+ key: async ({ ip }) => ((await isTrusted(ip)) ? undefined : ip), // undefined: not counted
82
+ }),
83
+ );
84
+ ```
85
+
86
+ ### `'rateLimit' is possibly 'undefined'`
87
+
88
+ **When:** a route reads `ctx.rateLimit.remaining` directly.
89
+
90
+ ```text
91
+ error TS18048: 'rateLimit' is possibly 'undefined'.
92
+ ```
93
+
94
+ **Why:** `ctx.rateLimit` is `RateLimitInfo | undefined`. It is `undefined`
95
+ for a request that was not counted: `skip` returned `true`, or `key`
96
+ returned `undefined` — which the default key does when `ctx.ip` is
97
+ `undefined`.
98
+
99
+ **Fix:**
100
+
101
+ ```ts
102
+ app
103
+ .use(rateLimit({ limit: 100, windowMs: 60_000 }))
104
+ .get('/quota', ({ rateLimit, reply }) => reply(200, { remaining: rateLimit?.remaining ?? null }));
105
+ ```
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
+ ## Startup
131
+
132
+ ### `TypeError: rateLimit: … must be a whole number of 1 or more, not …`
133
+
134
+ **When:** `rateLimit()` is called with a `limit` or a `windowMs` of 0, a
135
+ negative or fractional number, or `NaN`, often from an environment variable
136
+ that is unset. It throws at once, so the app fails at startup:
137
+
138
+ ```text
139
+ TypeError: rateLimit: limit must be a whole number of 1 or more, not 0
140
+ TypeError: rateLimit: windowMs must be a whole number of 1 or more, not NaN
141
+ ```
142
+
143
+ **Why:** a `limit` of 0 would refuse every request, and a window of 0 or
144
+ less would never end one.
145
+
146
+ **Fix:** pass whole numbers. A value read from the environment is a
147
+ string, or `undefined` when the variable is unset: give it a default, and
148
+ let an empty or non-numeric value still fail at startup, as it should:
149
+
150
+ ```ts
151
+ app.use(rateLimit({ limit: Number(Bun.env.RATE_LIMIT ?? 100), windowMs: 60_000 }));
152
+ ```
153
+
154
+ ## Responses
155
+
156
+ ### `429 {"error":"rate_limited","retryAfter":…}`
157
+
158
+ **When:** a key has made `limit` counted requests within the window.
159
+
160
+ **Why:** that is the limit working. `retryAfter` and the `Retry-After`
161
+ header are the seconds until a request would be allowed, at least 1. The
162
+ refused request counted nothing.
163
+
164
+ **Fix:** on the client, wait that long; the 429 is in the route's type, so
165
+ `data` is typed:
166
+
167
+ ```ts
168
+ const result = await api.get('/search');
169
+ if (result.status === 429) {
170
+ await Bun.sleep(result.data.retryAfter * 1000);
171
+ }
172
+ ```
173
+
174
+ If it arrives sooner than you expect, see the entries below.
175
+
176
+ ## Counting
177
+
178
+ ### Every client is refused at once
179
+
180
+ **When:** in production, behind a reverse proxy or a load balancer: one
181
+ busy client, or a handful of ordinary ones, and everyone gets the 429.
182
+
183
+ **Why:** the default key is `ctx.ip`, the connection's address — the
184
+ proxy's, the same for every request. All clients share one allowance.
185
+
186
+ **Fix:** give the app an `ip` option that reads the header your proxy sets,
187
+ and only from a proxy you trust:
188
+
189
+ ```ts
190
+ const app = alxia({
191
+ ip: (request, server) =>
192
+ request.headers.get('x-real-ip') ?? server?.requestIP(request)?.address,
193
+ }).use(rateLimit({ limit: 100, windowMs: 60_000 }));
194
+ ```
195
+
196
+ ### Nothing is limited, and no `RateLimit-*` header is sent
197
+
198
+ **When:** requests past `limit` still answer 200, without a rate-limit
199
+ header, and `ctx.rateLimit` is `undefined` — typically in a test calling
200
+ the app through `client(app)`, `app.fetch` or `app.request`.
201
+
202
+ **Why:** a request whose key is `undefined` is not counted. Without a server
203
+ there is no connection, so the default `ip` is `undefined`, and so is the
204
+ default key. A custom `key` returning `undefined`, or a `skip` returning
205
+ `true`, does the same. (A route declared before the limit is not counted
206
+ either, and has no 429 in its type.)
207
+
208
+ **Fix:** in tests, read the address from a header you send:
209
+
210
+ ```ts
211
+ const app = alxia({ ip: (request) => request.headers.get('x-ip') ?? undefined })
212
+ .use(rateLimit({ limit: 2, windowMs: 60_000 }))
213
+ .get('/limited', ({ reply }) => reply(200, 'ok'));
214
+
215
+ await client(app).get('/limited', { init: { headers: { 'x-ip': '1.1.1.1' } } });
216
+ ```
217
+
218
+ ### A client makes more than `limit` requests
219
+
220
+ **When:** the app runs as several processes or instances, or restarts.
221
+
222
+ **Why:** the default store is a `MemoryStore`, which counts in one process's
223
+ memory. Each process grants `limit` on its own, and a restart forgets
224
+ every count.
225
+
226
+ **Fix:** give every process the same store, such as `@alxia/redis`'s:
227
+
228
+ ```ts
229
+ import { redisStore } from '@alxia/redis';
230
+
231
+ app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { name: 'api' }) }));
232
+ ```
233
+
234
+ ### A client is refused before `limit` requests
235
+
236
+ **When:** a 429 comes earlier than `limit` suggests.
237
+
238
+ **Why:** one of these:
239
+
240
+ - **Two limits share one `MemoryStore`.** It keys its counts by key alone,
241
+ not by policy, so both limits spend the same count, and the smaller
242
+ `limit` refuses first.
243
+ - **Two limits apply to the route.** A group's limit and an app-wide one
244
+ both count; either may refuse. With two `'draft'` limits, the headers
245
+ show the later one's numbers.
246
+ - **Refused requests are counted.** The limit runs before the route
247
+ validates the request, so a request answered 400 has spent one.
248
+
249
+ **Fix:** give each limit its own `MemoryStore` — or none, and `rateLimit`
250
+ creates one — and give all but one limit `headers: false`:
251
+
252
+ ```ts
253
+ app
254
+ .use(rateLimit({ limit: 10, windowMs: 1_000 }))
255
+ .use(rateLimit({ limit: 1_000, windowMs: 60 * 60_000, headers: false }));
256
+ ```
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
- "name": "@alxia/rate-limit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@alxia/rate-limit",
3
+ "version": "0.1.0",
4
+ "description": "Rate limiting for alxia, typed: the 429 is part of every route behind it, and the client reads it",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "files": [
10
+ "dist",
11
+ "docs",
12
+ "README.md",
13
+ "package.json",
14
+ "LICENSE"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "import": "./dist/index.js",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/softistx/alxia.git",
27
+ "directory": "packages/rate-limit"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org",
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "build": "bun run ../../build.ts",
35
+ "test": "bun test src",
36
+ "typecheck": "tsc --noEmit"
37
+ },
38
+ "alxia": {
39
+ "entrypoints": [
40
+ "src/index.ts"
41
+ ]
42
+ },
43
+ "devDependencies": {
44
+ "@alxia/client": "^0.1.0",
45
+ "@alxia/core": "^0.1.0",
46
+ "@types/bun": "^1.4.2",
47
+ "zod": "^4.2.0"
48
+ },
49
+ "peerDependencies": {
50
+ "@alxia/core": "^0.1.0",
51
+ "typescript": "^6.0.3 || ^7.0.0"
52
+ }
53
+ }