@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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # @alxia/rate-limit
2
2
 
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
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 result = await api.get('/search');
24
- if (result.status === 429) result.data.retryAfter; // seconds
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 plugin adds |
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 plugin: an app that derives `rateLimit` |
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 client, stores, and testing.
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
@@ -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 { definePlugin } from "@alxia/core";
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 definePlugin()((app) => app.derive(async (ctx) => {
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
- 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
- }
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=1917CEB5F68549FD64756E2164756E21
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 { 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",
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": "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",
9
- "debugId": "1917CEB5F68549FD64756E2164756E21",
8
+ "mappings": "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",
9
+ "debugId": "8BE4A8782C7D852264756E2164756E21",
10
10
  "names": []
11
11
  }
@@ -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 plugin adds — and what the app that
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
- * 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.
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 plugin added names it, and the app
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>): 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>;
57
+ export declare function rateLimit<Requires extends object = Empty>(options: RateLimitOptions<Requires>): NoInfer<RateLimit<Requires>>;
55
58
  //# sourceMappingURL=rate-limit.d.ts.map
@@ -1 +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"}
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 client, keeping counts across processes, or testing a limit |
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
- ): Alxia<…> & Requiring<Requires>; // an app, given to `use`, which checks `Requires`
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 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).
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 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
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 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.
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
- .use(auth) // derives user, or answers 401
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: the plugin reads "user", which this app's context does not give: use the plugin that adds it first
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 route hook, so order decides, at runtime and in the types:
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 its
169
- type has no 429;
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 in the group.
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
- 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.
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 hook ends the request before the route runs:
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 client
272
+ ## On the wire
253
273
 
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 }`.
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 result = await api.get('/search');
267
- if (result.status !== 429) return result;
268
- await Bun.sleep(result.data.retryAfter * 1000); // wait, then try again
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 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);
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.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);
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 hooks](https://github.com/softistx/alxia/blob/develop/packages/core/docs/guide/hooks.md):
421
- how route hooks and their order work.
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
- Nothing scheduled yet.
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.** `use(rateLimit({ limit, windowMs }))` counts
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 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.
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`,
@@ -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 plugin
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 route hook
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().use(auth).use(perUser); // auth derives user
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
- [`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).
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; the 429 is in the route's type, so
165
- `data` is typed:
141
+ **Fix:** on the client, wait that long, then try again:
166
142
 
167
143
  ```ts
168
- const result = await api.get('/search');
169
- if (result.status === 429) {
170
- await Bun.sleep(result.data.retryAfter * 1000);
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 `client(app)`, `app.fetch` or `app.request`.
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 has no 429 in its type.)
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 client(app).get('/limited', { init: { headers: { 'x-ip': '1.1.1.1' } } });
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.1.1",
4
- "description": "Rate limiting for alxia, typed: the 429 is part of every route behind it, and the client reads it",
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/client": "^0.2.0",
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.2.0",
49
+ "@alxia/core": "^0.4.0",
51
50
  "typescript": "^6.0.3 || ^7.0.0"
52
51
  }
53
52
  }