@alxia/rate-limit 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -6
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -13
- package/dist/index.js.map +6 -5
- package/dist/policy.d.ts +16 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/rate-limit.d.ts +25 -12
- package/dist/rate-limit.d.ts.map +1 -1
- package/dist/store.d.ts +11 -0
- package/dist/store.d.ts.map +1 -1
- package/docs/guide.md +29 -8
- package/docs/roadmap.md +10 -0
- package/docs/troubleshooting.md +22 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -32,10 +32,10 @@ IETF draft's `RateLimit-Limit`, `-Remaining`, `-Reset` and `-Policy`.
|
|
|
32
32
|
|
|
33
33
|
| option | default | |
|
|
34
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 |
|
|
35
|
+
| `limit` | required, unless the store has a `policy` | requests per window: a whole number, 1 or more |
|
|
36
|
+
| `windowMs` | required, unless the store has a `policy` | the window, in milliseconds: a whole number, 1 or more |
|
|
37
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
|
-
| `store` | `MemoryStore` | where: `redisStore` from `@alxia/redis`, or your own `RateLimitStore` |
|
|
38
|
+
| `store` | `MemoryStore` | where: `redisStore` from `@alxia/redis`, or your own `RateLimitStore`. A store with a `policy` gives `limit` and `windowMs` |
|
|
39
39
|
| `skip` | none | requests not counted |
|
|
40
40
|
| `headers` | `'draft'` | `'legacy'` for `X-RateLimit-*`, or `false` |
|
|
41
41
|
|
|
@@ -61,8 +61,18 @@ import { redisStore } from '@alxia/redis';
|
|
|
61
61
|
app.use(rateLimit({ limit: 100, windowMs: 60_000, store: redisStore(redis, { name: 'api' }) }));
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
A store of
|
|
65
|
-
windowMs })`
|
|
64
|
+
A store that counts by a rate of its own declares it as `policy: { limit,
|
|
65
|
+
windowMs }`, and `rateLimit({ store })` reads both from it, headers included:
|
|
66
|
+
the numbers are written once. `redisStore(handle.limits.api)` does, from
|
|
67
|
+
the bound limit's definition. A `limit` or `windowMs` given beside it that differs
|
|
68
|
+
throws at declaration.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
app.use(rateLimit({ store: redisStore(handle.limits.api) }));
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
A store of your own implements `RateLimitStore` (and, to count by a rate of
|
|
75
|
+
its own, the optional `policy`): `consume(key, { limit, windowMs })` decides — `allowed`, `remaining`, `resetAfter` and
|
|
66
76
|
`retryAfter`, delays in milliseconds — and a refused request counts
|
|
67
77
|
nothing.
|
|
68
78
|
|
|
@@ -72,7 +82,7 @@ nothing.
|
|
|
72
82
|
| --- | --- |
|
|
73
83
|
| `rateLimit(options)` | the middleware: gives `rateLimit` to what runs after it, or answers the 429 |
|
|
74
84
|
| `MemoryStore` | a fixed window in one process's memory |
|
|
75
|
-
| `RateLimitStore`, `Decision`, `Policy` | a store's contract |
|
|
85
|
+
| `RateLimitStore`, `Decision`, `Policy`, `PolicyStore` | a store's contract; `PolicyStore` is a store with its own `policy`, which `rateLimit` reads `limit` and `windowMs` from |
|
|
76
86
|
| `RateLimit`, `RateLimitedBody`, `RateLimitInfo`, `RateLimitOptions` | its types: `RateLimit<Requires>` is the middleware `rateLimit()` returns |
|
|
77
87
|
|
|
78
88
|
## Documentation
|
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { type RateLimit, type RateLimitedBody, type RateLimitInfo, type RateLimitOptions, rateLimit, } from './rate-limit';
|
|
2
|
-
export { type Decision, MemoryStore, type Policy, type RateLimitStore, } from './store';
|
|
2
|
+
export { type Decision, MemoryStore, type Policy, type PolicyStore, 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,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"}
|
|
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,WAAW,EAChB,KAAK,cAAc,GACnB,MAAM,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,29 @@ import {
|
|
|
4
4
|
markFactory
|
|
5
5
|
} from "@alxia/core";
|
|
6
6
|
|
|
7
|
+
// src/policy.ts
|
|
8
|
+
function resolvePolicy(given) {
|
|
9
|
+
const own = given.store?.policy;
|
|
10
|
+
const policy = {
|
|
11
|
+
limit: given.limit ?? own?.limit ?? Number.NaN,
|
|
12
|
+
windowMs: given.windowMs ?? own?.windowMs ?? Number.NaN
|
|
13
|
+
};
|
|
14
|
+
for (const name of ["limit", "windowMs"]) {
|
|
15
|
+
const value = policy[name];
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
17
|
+
throw new TypeError(`rateLimit: ${name} must be a whole number of 1 or more, not ${String(given[name] ?? own?.[name])}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
if (own !== undefined) {
|
|
21
|
+
for (const name of ["limit", "windowMs"]) {
|
|
22
|
+
if (policy[name] !== own[name]) {
|
|
23
|
+
throw new TypeError(`rateLimit: ${name} ${policy[name]} differs from the store's policy of ${own.limit} per ${own.windowMs}ms. Leave limit and windowMs out to use the store's, or give the same numbers.`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return policy;
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
// src/store.ts
|
|
8
31
|
class MemoryStore {
|
|
9
32
|
#hits = new Map;
|
|
@@ -59,13 +82,8 @@ class MemoryStore {
|
|
|
59
82
|
|
|
60
83
|
// src/rate-limit.ts
|
|
61
84
|
function rateLimit(options) {
|
|
62
|
-
for (const name of ["limit", "windowMs"]) {
|
|
63
|
-
const value = options[name];
|
|
64
|
-
if (!Number.isSafeInteger(value) || value < 1) {
|
|
65
|
-
throw new TypeError(`rateLimit: ${name} must be a whole number of 1 or more, not ${String(value)}`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
85
|
const store = options.store ?? new MemoryStore;
|
|
86
|
+
const policy = resolvePolicy(options);
|
|
69
87
|
const key = options.key ?? ((ctx) => ctx.ip);
|
|
70
88
|
const style = options.headers ?? "draft";
|
|
71
89
|
return defineMiddleware()(async function rateLimit(ctx, next) {
|
|
@@ -74,11 +92,8 @@ function rateLimit(options) {
|
|
|
74
92
|
const rateLimit = undefined;
|
|
75
93
|
return next({ rateLimit });
|
|
76
94
|
}
|
|
77
|
-
const decision = await store.consume(counted,
|
|
78
|
-
|
|
79
|
-
windowMs: options.windowMs
|
|
80
|
-
});
|
|
81
|
-
said(ctx.set.headers, style, options, decision);
|
|
95
|
+
const decision = await store.consume(counted, policy);
|
|
96
|
+
said(ctx.set.headers, style, policy, decision);
|
|
82
97
|
if (!decision.allowed) {
|
|
83
98
|
const retryAfter = Math.max(1, Math.ceil(decision.retryAfter / 1000));
|
|
84
99
|
const body = { error: "rate_limited", retryAfter };
|
|
@@ -87,7 +102,7 @@ function rateLimit(options) {
|
|
|
87
102
|
});
|
|
88
103
|
}
|
|
89
104
|
const rateLimit = {
|
|
90
|
-
limit:
|
|
105
|
+
limit: policy.limit,
|
|
91
106
|
remaining: decision.remaining,
|
|
92
107
|
resetAfter: decision.resetAfter
|
|
93
108
|
};
|
|
@@ -112,5 +127,5 @@ export {
|
|
|
112
127
|
rateLimit
|
|
113
128
|
};
|
|
114
129
|
|
|
115
|
-
//# debugId=
|
|
130
|
+
//# debugId=1BB6B23627280B1764756E2164756E21
|
|
116
131
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
|
-
"sources": ["../src/rate-limit.ts", "../src/store.ts"],
|
|
3
|
+
"sources": ["../src/rate-limit.ts", "../src/policy.ts", "../src/store.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\ttype Reply,\n} from '@alxia/core';\nimport {
|
|
6
|
-
"
|
|
5
|
+
"import {\n\ttype BaseContext,\n\tdefineMiddleware,\n\ttype Empty,\n\ttype Middleware,\n\tmarkFactory,\n\ttype Next,\n\ttype Reply,\n} from '@alxia/core';\nimport { resolvePolicy } from './policy';\nimport {\n\ttype Decision,\n\tMemoryStore,\n\ttype Policy,\n\ttype PolicyStore,\n\ttype RateLimitStore,\n} from './store';\n\n/** What `RateLimitOptions` holds whatever the store: how a request is counted and answered. */\ninterface CountingOptions<Requires extends object> {\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/** 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/**\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 *\n * `limit` and `windowMs` are required, unless the `store` declares its own\n * `policy` (`@alxia/redis`'s `redisStore` of a wired limit): they are then\n * read from it, and one that is given must equal it.\n */\nexport type RateLimitOptions<Requires extends object = Empty> =\n\tCountingOptions<Requires> &\n\t\t(\n\t\t\t| {\n\t\t\t\t\t/** How many requests a key may make in a window: a whole number, 1 or more. */\n\t\t\t\t\treadonly limit: number;\n\t\t\t\t\t/** The window, in milliseconds: a whole number, 1 or more. */\n\t\t\t\t\treadonly windowMs: number;\n\t\t\t\t\t/** Where it is counted: one process's memory by default. */\n\t\t\t\t\treadonly store?: RateLimitStore;\n\t\t\t }\n\t\t\t| {\n\t\t\t\t\treadonly limit?: number;\n\t\t\t\t\treadonly windowMs?: number;\n\t\t\t\t\t/** A store with a policy of its own gives `limit` and `windowMs`. */\n\t\t\t\t\treadonly store: PolicyStore;\n\t\t\t }\n\t\t);\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\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\tconst store = options.store ?? new MemoryStore();\n\tconst policy = resolvePolicy(options);\n\tconst key = options.key ?? ((ctx: BaseContext & Requires) => ctx.ip);\n\tconst style = options.headers ?? 'draft';\n\treturn defineMiddleware<Requires>()(async function rateLimit(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, policy);\n\t\tsaid(ctx.set.headers, style, policy, 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: policy.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: Policy,\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\nmarkFactory(rateLimit);\n",
|
|
6
|
+
"import type { Policy, RateLimitStore } from './store';\n\n/** What `resolvePolicy` reads: the numbers an app gave, and the store they count in. */\ninterface Given {\n\treadonly limit?: number | undefined;\n\treadonly windowMs?: number | undefined;\n\treadonly store?: RateLimitStore | undefined;\n}\n\n/**\n * The policy a limit counts and writes its headers by: the app's `limit`\n * and `windowMs`, or the store's own `policy` where it has one. A number\n * that differs from the store's is an error at declaration, since the\n * headers would say what the store does not do.\n */\nexport function resolvePolicy(given: Given): Policy {\n\tconst own = given.store?.policy;\n\tconst policy: Policy = {\n\t\tlimit: given.limit ?? own?.limit ?? Number.NaN,\n\t\twindowMs: given.windowMs ?? own?.windowMs ?? Number.NaN,\n\t};\n\tfor (const name of ['limit', 'windowMs'] as const) {\n\t\tconst value = policy[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(given[name] ?? own?.[name])}`,\n\t\t\t);\n\t\t}\n\t}\n\tif (own !== undefined) {\n\t\tfor (const name of ['limit', 'windowMs'] as const) {\n\t\t\tif (policy[name] !== own[name]) {\n\t\t\t\tthrow new TypeError(\n\t\t\t\t\t`rateLimit: ${name} ${policy[name]} differs from the store's policy of ${own.limit} per ${own.windowMs}ms. Leave limit and windowMs out to use the store's, or give the same numbers.`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\t}\n\treturn policy;\n}\n",
|
|
7
|
+
"/** 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\t/**\n\t * The policy the store counts by itself, when it has one of its own — a\n\t * rate limit defined elsewhere, as `@alxia/redis`'s `redisStore` of a wired\n\t * limit. `rateLimit({ store })` then reads `limit` and `windowMs` from it,\n\t * and refuses numbers that differ.\n\t */\n\treadonly policy?: Policy;\n}\n\n/** A store that declares its own policy: `rateLimit` needs no `limit` nor `windowMs` with it. */\nexport type PolicyStore = RateLimitStore & { readonly policy: Policy };\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
|
],
|
|
8
|
-
"mappings": ";AAAA;AAAA;AAAA;AAAA;;;
|
|
9
|
-
"debugId": "
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "1BB6B23627280B1764756E2164756E21",
|
|
10
11
|
"names": []
|
|
11
12
|
}
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Policy, RateLimitStore } from './store';
|
|
2
|
+
/** What `resolvePolicy` reads: the numbers an app gave, and the store they count in. */
|
|
3
|
+
interface Given {
|
|
4
|
+
readonly limit?: number | undefined;
|
|
5
|
+
readonly windowMs?: number | undefined;
|
|
6
|
+
readonly store?: RateLimitStore | undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The policy a limit counts and writes its headers by: the app's `limit`
|
|
10
|
+
* and `windowMs`, or the store's own `policy` where it has one. A number
|
|
11
|
+
* that differs from the store's is an error at declaration, since the
|
|
12
|
+
* headers would say what the store does not do.
|
|
13
|
+
*/
|
|
14
|
+
export declare function resolvePolicy(given: Given): Policy;
|
|
15
|
+
export {};
|
|
16
|
+
//# sourceMappingURL=policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../src/policy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAEtD,wFAAwF;AACxF,UAAU,KAAK;IACd,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACpC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,KAAK,CAAC,EAAE,cAAc,GAAG,SAAS,CAAC;CAC5C;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,CAyBlD"}
|
package/dist/rate-limit.d.ts
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
import { type BaseContext, type Empty, type Middleware, type Next, type Reply } from '@alxia/core';
|
|
2
|
-
import { type RateLimitStore } from './store';
|
|
2
|
+
import { type PolicyStore, type RateLimitStore } from './store';
|
|
3
|
+
/** What `RateLimitOptions` holds whatever the store: how a request is counted and answered. */
|
|
4
|
+
interface CountingOptions<Requires extends object> {
|
|
5
|
+
/** What is counted: the client's address by default. `undefined` is not counted. */
|
|
6
|
+
readonly key?: (ctx: BaseContext & Requires) => string | undefined | Promise<string | undefined>;
|
|
7
|
+
/** Requests not counted at all. */
|
|
8
|
+
readonly skip?: (ctx: BaseContext & Requires) => boolean;
|
|
9
|
+
/**
|
|
10
|
+
* The `RateLimit` headers of the IETF draft on every counted response,
|
|
11
|
+
* `X-RateLimit-*` with `legacy`, or none. `draft` by default.
|
|
12
|
+
*/
|
|
13
|
+
readonly headers?: 'draft' | 'legacy' | false;
|
|
14
|
+
}
|
|
3
15
|
/**
|
|
4
16
|
* `Requires` is what `key` and `skip` read from the context beyond
|
|
5
17
|
* `BaseContext` — a `user` an earlier middleware adds — and what the app that
|
|
6
18
|
* uses the limit must then give.
|
|
19
|
+
*
|
|
20
|
+
* `limit` and `windowMs` are required, unless the `store` declares its own
|
|
21
|
+
* `policy` (`@alxia/redis`'s `redisStore` of a wired limit): they are then
|
|
22
|
+
* read from it, and one that is given must equal it.
|
|
7
23
|
*/
|
|
8
|
-
export
|
|
24
|
+
export type RateLimitOptions<Requires extends object = Empty> = CountingOptions<Requires> & ({
|
|
9
25
|
/** How many requests a key may make in a window: a whole number, 1 or more. */
|
|
10
26
|
readonly limit: number;
|
|
11
27
|
/** The window, in milliseconds: a whole number, 1 or more. */
|
|
12
28
|
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
29
|
/** Where it is counted: one process's memory by default. */
|
|
16
30
|
readonly store?: RateLimitStore;
|
|
17
|
-
|
|
18
|
-
readonly
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
readonly headers?: 'draft' | 'legacy' | false;
|
|
24
|
-
}
|
|
31
|
+
} | {
|
|
32
|
+
readonly limit?: number;
|
|
33
|
+
readonly windowMs?: number;
|
|
34
|
+
/** A store with a policy of its own gives `limit` and `windowMs`. */
|
|
35
|
+
readonly store: PolicyStore;
|
|
36
|
+
});
|
|
25
37
|
/** The body of the 429. */
|
|
26
38
|
export interface RateLimitedBody {
|
|
27
39
|
readonly error: 'rate_limited';
|
|
@@ -55,4 +67,5 @@ export type RateLimit<Requires extends object = Empty> = Middleware<Requires, Pr
|
|
|
55
67
|
* app must then give it: `rateLimit<{ user: User }>({ key: ({ user }) => user.id, … })`.
|
|
56
68
|
*/
|
|
57
69
|
export declare function rateLimit<Requires extends object = Empty>(options: RateLimitOptions<Requires>): NoInfer<RateLimit<Requires>>;
|
|
70
|
+
export {};
|
|
58
71
|
//# 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,EACN,KAAK,WAAW,EAEhB,KAAK,KAAK,EACV,KAAK,UAAU,EAEf,KAAK,IAAI,EACT,KAAK,KAAK,EACV,MAAM,aAAa,CAAC;
|
|
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,EAEf,KAAK,IAAI,EACT,KAAK,KAAK,EACV,MAAM,aAAa,CAAC;AAErB,OAAO,EAIN,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,MAAM,SAAS,CAAC;AAEjB,+FAA+F;AAC/F,UAAU,eAAe,CAAC,QAAQ,SAAS,MAAM;IAChD,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,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;;;;;;;;GAQG;AACH,MAAM,MAAM,gBAAgB,CAAC,QAAQ,SAAS,MAAM,GAAG,KAAK,IAC3D,eAAe,CAAC,QAAQ,CAAC,GACxB,CACG;IACA,+EAA+E;IAC/E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,8DAA8D;IAC9D,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,4DAA4D;IAC5D,QAAQ,CAAC,KAAK,CAAC,EAAE,cAAc,CAAC;CAC/B,GACD;IACA,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,qEAAqE;IACrE,QAAQ,CAAC,KAAK,EAAE,WAAW,CAAC;CAC3B,CACH,CAAC;AAEJ,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,CAAC;AAEF;;;;;;;;;;;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,CA2B9B"}
|
package/dist/store.d.ts
CHANGED
|
@@ -23,7 +23,18 @@ export interface RateLimitStore {
|
|
|
23
23
|
consume(key: string, policy: Policy): Decision | Promise<Decision>;
|
|
24
24
|
/** Forgets `key`: a user who just logged in. */
|
|
25
25
|
reset(key: string): void | Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* The policy the store counts by itself, when it has one of its own — a
|
|
28
|
+
* rate limit defined elsewhere, as `@alxia/redis`'s `redisStore` of a wired
|
|
29
|
+
* limit. `rateLimit({ store })` then reads `limit` and `windowMs` from it,
|
|
30
|
+
* and refuses numbers that differ.
|
|
31
|
+
*/
|
|
32
|
+
readonly policy?: Policy;
|
|
26
33
|
}
|
|
34
|
+
/** A store that declares its own policy: `rateLimit` needs no `limit` nor `windowMs` with it. */
|
|
35
|
+
export type PolicyStore = RateLimitStore & {
|
|
36
|
+
readonly policy: Policy;
|
|
37
|
+
};
|
|
27
38
|
/** A fixed-window counter in memory, swept as windows end. */
|
|
28
39
|
export declare class MemoryStore implements RateLimitStore {
|
|
29
40
|
#private;
|
package/dist/store.d.ts.map
CHANGED
|
@@ -1 +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;
|
|
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;IACzC;;;;;OAKG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,iGAAiG;AACjG,MAAM,MAAM,WAAW,GAAG,cAAc,GAAG;IAAE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC;AAEvE,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/guide.md
CHANGED
|
@@ -27,14 +27,16 @@ 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
|
-
|
|
31
|
-
readonly limit: number;
|
|
32
|
-
readonly windowMs: number;
|
|
30
|
+
type RateLimitOptions<Requires extends object = Empty> = {
|
|
33
31
|
readonly key?: (ctx: BaseContext & Requires) => string | undefined | Promise<string | undefined>;
|
|
34
|
-
readonly store?: RateLimitStore;
|
|
35
32
|
readonly skip?: (ctx: BaseContext & Requires) => boolean;
|
|
36
33
|
readonly headers?: 'draft' | 'legacy' | false;
|
|
37
|
-
}
|
|
34
|
+
} & (
|
|
35
|
+
| { readonly limit: number; readonly windowMs: number; readonly store?: RateLimitStore }
|
|
36
|
+
| { readonly limit?: number; readonly windowMs?: number; readonly store: PolicyStore } // a store with a policy
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
type PolicyStore = RateLimitStore & { readonly policy: { limit: number; windowMs: number } };
|
|
38
40
|
```
|
|
39
41
|
|
|
40
42
|
`rateLimit` returns a middleware that counts the request. Given to `app.use`,
|
|
@@ -55,10 +57,10 @@ type RateLimit<Requires extends object = Empty> = Middleware<
|
|
|
55
57
|
|
|
56
58
|
| Option | Type | Default | Effect |
|
|
57
59
|
| --- | --- | --- | --- |
|
|
58
|
-
| `limit` | `number` | required | requests one key may make in a window: a whole number, 1 or more |
|
|
59
|
-
| `windowMs` | `number` | required | the window, in milliseconds: a whole number, 1 or more |
|
|
60
|
+
| `limit` | `number` | required, unless the store has a `policy` | requests one key may make in a window: a whole number, 1 or more |
|
|
61
|
+
| `windowMs` | `number` | required, unless the store has a `policy` | the window, in milliseconds: a whole number, 1 or more |
|
|
60
62
|
| `key` | `(ctx: BaseContext & Requires) => string \| undefined \| Promise<…>` | `ctx.ip` | what is counted; `undefined` is not counted |
|
|
61
|
-
| `store` | `RateLimitStore` | a new `MemoryStore` | where the counts are kept |
|
|
63
|
+
| `store` | `RateLimitStore` | a new `MemoryStore` | where the counts are kept; with a `policy`, it gives `limit` and `windowMs` |
|
|
62
64
|
| `skip` | `(ctx: BaseContext & Requires) => boolean` | none | requests not counted at all |
|
|
63
65
|
| `headers` | `'draft' \| 'legacy' \| false` | `'draft'` | which rate-limit headers each counted response carries |
|
|
64
66
|
|
|
@@ -66,6 +68,25 @@ A `limit` or a `windowMs` that is not a whole number of 1 or more makes
|
|
|
66
68
|
`rateLimit()` throw a `TypeError` when it is called, so the app fails at
|
|
67
69
|
startup ([troubleshooting](troubleshooting.md#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-)).
|
|
68
70
|
|
|
71
|
+
### A store with a policy
|
|
72
|
+
|
|
73
|
+
A store that counts by a rate of its own — a rate limit defined elsewhere —
|
|
74
|
+
declares it as `policy?: { limit: number; windowMs: number }`. With one,
|
|
75
|
+
`limit` and `windowMs` are optional in the type and read from the store, the
|
|
76
|
+
`RateLimit-*` headers and `ctx.rateLimit.limit` included, so the rate is
|
|
77
|
+
written once. Without one they are required, and a type error says so.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { redisStore } from '@alxia/redis';
|
|
81
|
+
|
|
82
|
+
// `api` is the defineRateLimit definition the handle wired.
|
|
83
|
+
app.use(rateLimit({ store: redisStore(handle.limits.api) })); // 100 per 60 s, from `api`
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
A `limit` or a `windowMs` given beside a policy must equal it, or
|
|
87
|
+
`rateLimit()` throws at declaration
|
|
88
|
+
([troubleshooting](troubleshooting.md#typeerror-ratelimit-limit--differs-from-the-stores-policy-of-)).
|
|
89
|
+
|
|
69
90
|
A store may refuse larger values than `rateLimit` does: `redisStore`
|
|
70
91
|
refuses a `limit × windowMs` above 9,007,199,254,740 and a `windowMs`
|
|
71
92
|
above ten 365-day years (315,360,000,000), on the first request it counts rather than at startup
|
package/docs/roadmap.md
CHANGED
|
@@ -32,6 +32,16 @@ Nothing scheduled yet.
|
|
|
32
32
|
|
|
33
33
|
## Shipped
|
|
34
34
|
|
|
35
|
+
### 0.4.0: a store with a policy
|
|
36
|
+
|
|
37
|
+
- **The rate, written once.** A store may declare `policy?: { limit,
|
|
38
|
+
windowMs }` (`PolicyStore` is one that does). `rateLimit({ store })` then
|
|
39
|
+
reads `limit` and `windowMs` from it, both optional in the type, headers
|
|
40
|
+
included; without a policy they stay required. A `limit` or `windowMs` that
|
|
41
|
+
differs from the policy throws at declaration. `@alxia/redis`'s
|
|
42
|
+
`redisStore(handle.limits.api)` is such a store. Every existing form
|
|
43
|
+
works as before.
|
|
44
|
+
|
|
35
45
|
### 0.1.0
|
|
36
46
|
|
|
37
47
|
- **A rate limit as a plugin**, the 0.1 form. `rateLimit({ limit, windowMs })` counts
|
package/docs/troubleshooting.md
CHANGED
|
@@ -13,6 +13,7 @@ nothing of its own; past the limit it answers a 429.
|
|
|
13
13
|
**Startup**
|
|
14
14
|
|
|
15
15
|
- [`TypeError: rateLimit: … must be a whole number of 1 or more, not …`](#typeerror-ratelimit--must-be-a-whole-number-of-1-or-more-not-)
|
|
16
|
+
- [`TypeError: rateLimit: limit … differs from the store's policy of …`](#typeerror-ratelimit-limit--differs-from-the-stores-policy-of-)
|
|
16
17
|
|
|
17
18
|
**Responses**
|
|
18
19
|
|
|
@@ -128,6 +129,27 @@ let an empty or non-numeric value still fail at startup, as it should:
|
|
|
128
129
|
app.use(rateLimit({ limit: Number(Bun.env.RATE_LIMIT ?? 100), windowMs: 60_000 }));
|
|
129
130
|
```
|
|
130
131
|
|
|
132
|
+
### `TypeError: rateLimit: limit … differs from the store's policy of …`
|
|
133
|
+
|
|
134
|
+
**When:** `rateLimit()` is given a `store` that declares its own `policy`
|
|
135
|
+
(`redisStore(handle.limits.api)`) and a `limit` or a `windowMs` that is
|
|
136
|
+
not the store's. It throws at declaration, so the app fails at startup:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
TypeError: rateLimit: limit 50 differs from the store's policy of 100 per 60000ms. Leave limit and windowMs out to use the store's, or give the same numbers.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
**Why:** the store counts by its policy, and the `RateLimit-*` headers are
|
|
143
|
+
written from the numbers `rateLimit` holds: two values would make the headers
|
|
144
|
+
say what the store does not enforce. (`windowMs` reads the same way.)
|
|
145
|
+
|
|
146
|
+
**Fix:** leave `limit` and `windowMs` out; they come from the store. To change
|
|
147
|
+
the rate, change the definition the store reads it from:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
app.use(rateLimit({ store: redisStore(handle.limits.api) }));
|
|
151
|
+
```
|
|
152
|
+
|
|
131
153
|
## Responses
|
|
132
154
|
|
|
133
155
|
### `429 {"error":"rate_limited","retryAfter":…}`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alxia/rate-limit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Rate limiting for alxia: a 429 with Retry-After past the limit, the remaining allowance typed in the context, pluggable stores",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -41,12 +41,12 @@
|
|
|
41
41
|
]
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@alxia/core": "^0.5.
|
|
44
|
+
"@alxia/core": "^0.5.1",
|
|
45
45
|
"@types/bun": "^1.4.2",
|
|
46
46
|
"zod": "^4.2.0"
|
|
47
47
|
},
|
|
48
48
|
"peerDependencies": {
|
|
49
|
-
"@alxia/core": "^0.5.
|
|
49
|
+
"@alxia/core": "^0.5.1",
|
|
50
50
|
"typescript": "^6.0.3 || ^7.0.0"
|
|
51
51
|
}
|
|
52
52
|
}
|