@ayepi/rate 0.1.0 → 0.3.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
@@ -49,6 +49,9 @@ retryAfter }`); on exceeded requests the chain short-circuits with the 429.
49
49
  - `rateLimit.server(def, opts)` (`@ayepi/rate/server`) — binds the policy. These options
50
50
  move here: `key, limit, window, algorithm?, store?, prefix?, countRejected?, status?,
51
51
  message?, headers?, alwaysHeaders?, skip?`. Bind it with `implement(api).middleware(...)`.
52
+ The rule fields — `limit`, `window`, `algorithm`, `countRejected` — each accept a fixed
53
+ value **or** a per-request `(io) => value`, so limits can differ by caller (e.g.
54
+ `limit: (io) => io.ctx.user.plan === 'pro' ? 1000 : 100`).
52
55
 
53
56
  ## Standalone (without middleware)
54
57
 
@@ -144,7 +147,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
144
147
  - [`ayepi-rate-stores-doer.md`](./ayepi-rate-stores-doer.md)
145
148
  - [`ayepi-rate.md`](./ayepi-rate.md)
146
149
 
147
- They live next to the source in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/rate) and are **not** shipped in the npm tarball.
150
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/rate).
148
151
 
149
152
  ## License
150
153
 
@@ -0,0 +1,287 @@
1
+ <!--
2
+ ayepi-rate-stores-doer.md — reference for `@ayepi/rate` (stores, Redis, and the rate-limited doer), written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/rate` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+
9
+ Companion to `ayepi-rate.md` (overview + the `rateLimit` middleware + standalone primitives).
10
+ -->
11
+
12
+ # `@ayepi/rate` — stores, Redis, and the rate-limited doer
13
+
14
+ Companion to **`ayepi-rate.md`** (overview, the `rateLimit` middleware, and the standalone
15
+ `limiter`/`rateLimitResponse` primitives). This file covers the pluggable store interface,
16
+ the bundled `memoryStore`, the distributed `@ayepi/rate/redis` store, the `rateLimitedDoer`,
17
+ and how everything works under the hood.
18
+
19
+ ---
20
+
21
+ ## Stores
22
+
23
+ ```ts
24
+ interface RateLimitRule {
25
+ readonly limit: number;
26
+ readonly window: number;
27
+ readonly algorithm: Algorithm;
28
+ /** Count over-limit (rejected) requests against the limit. Default `false`. */
29
+ readonly countRejected?: boolean;
30
+ }
31
+
32
+ interface RateLimitStore {
33
+ /** Record a hit for `key` under `rule` at time `now` (ms) and return the decision. */
34
+ consume(key: string, rule: RateLimitRule, now: number): MaybePromise<RateLimitResult>;
35
+ /** Clear all state for `key` (optional). */
36
+ reset?(key: string): MaybePromise<void>;
37
+ }
38
+ ```
39
+
40
+ A custom store should honor `rule.countRejected`: when it is falsy (the default), a request
41
+ the store **rejects** must not consume budget (don't persist its increment). The bundled
42
+ `memoryStore` and `redisStore` both do this; `token-bucket` is naturally exempt (it never
43
+ charges a request it can't admit).
44
+
45
+ The **algorithm lives in the store**, not in the limiter — that is what keeps the limit
46
+ correct across instances. Implement `RateLimitStore` for any backend (Postgres, DynamoDB,
47
+ Memcached, …); only `consume` is required.
48
+
49
+ ### `memoryStore` (default, bundled)
50
+
51
+ ```ts
52
+ function memoryStore(): RateLimitStore
53
+ ```
54
+
55
+ An in-process store implementing all three algorithms, zero dependencies. It is the
56
+ default when no `store` is passed. Expired counters and idle token buckets are swept
57
+ lazily (amortized: a sweep runs once per ~1000 `consume` calls; idle buckets are dropped
58
+ after ~10 minutes of inactivity since they refill to full anyway). **Single process only**
59
+ — two server instances each get their own independent budget. Use the Redis store to share
60
+ a limit across pods.
61
+
62
+ ### `redisStore` — `@ayepi/rate/redis`
63
+
64
+ ```ts
65
+ import { redisStore } from '@ayepi/rate/redis'
66
+
67
+ function redisStore(client: RedisEvalLike, opts?: RedisStoreOptions): RateLimitStore
68
+
69
+ interface RedisStoreOptions {
70
+ /** Extra key namespace prepended to every key (default `''`). */
71
+ readonly prefix?: string;
72
+ }
73
+
74
+ interface RedisEvalLike {
75
+ eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
76
+ }
77
+ ```
78
+
79
+ A distributed store backed by Redis (ioredis). Each algorithm runs as a single atomic Lua
80
+ script, mirroring `memoryStore`'s semantics, so a limit is enforced **across all
81
+ instances**. `ioredis` is an **optional peer dependency** — install it only if you use this
82
+ store. An ioredis `Redis` instance satisfies `RedisEvalLike`.
83
+
84
+ ```ts
85
+ // shared.ts
86
+ import { rateLimit } from '@ayepi/rate'
87
+ const limit = rateLimit({ requires: [auth] })
88
+
89
+ // server.ts
90
+ import Redis from 'ioredis'
91
+ import { rateLimit } from '@ayepi/rate/server'
92
+ import { redisStore } from '@ayepi/rate/redis'
93
+
94
+ implement(api).middleware(rateLimit.server(limit, {
95
+ key: (io) => io.ctx.user.id,
96
+ limit: 100,
97
+ window: 60_000,
98
+ store: redisStore(new Redis(process.env.REDIS_URL!), { prefix: 'app:' }), // `store` is a .server option
99
+ }))
100
+ ```
101
+
102
+ Two stores sharing one Redis enforce a **shared** budget — that is the whole point of the
103
+ distributed store (verified by the integration test). `reset(key)` issues a `DEL`.
104
+
105
+ > Note `redisStore`'s `prefix` is a **second** namespace, applied in addition to the
106
+ > limiter's own `prefix` (default `'rl:'`). The effective Redis key is
107
+ > `redisPrefix + limiterPrefix + key` (e.g. `app:rl:user-1`).
108
+
109
+ ---
110
+
111
+ ## `rateLimitedDoer` — gating task start rate
112
+
113
+ ```ts
114
+ function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer
115
+
116
+ interface RateLimitedDoerOptions extends LimiterOptions {
117
+ /** Limit key — a single shared bucket by default (`'doer'`), or derived per task. */
118
+ readonly key?: string | ((opts: DoerTaskOptions) => string);
119
+ /** Floor on the re-check delay for deferred tasks (ms, default 50). */
120
+ readonly retryFloor?: number;
121
+ /** Clock injection (default `Date.now`). */
122
+ readonly now?: () => number;
123
+ /** The doer that actually runs admitted tasks (default `unlimitedDoer`). Compose policies. */
124
+ readonly doer?: Doer;
125
+ }
126
+ ```
127
+
128
+ (`RateLimitedDoerOptions` extends `LimiterOptions` — see `ayepi-rate.md` for
129
+ `limit`/`window`/`algorithm`/`store`/`prefix`.)
130
+
131
+ A `Doer` (from `@ayepi/core/doer`) that caps the **start rate** of tasks using the same
132
+ `limiter()` primitive (and the same pluggable store/algorithm) the middleware uses. It does
133
+ **not** run tasks itself — when the limiter admits a task it hands it to an **inner doer**
134
+ (default `unlimitedDoer()`), so you can compose a rate cap with a concurrency/ordering
135
+ policy. Excess tasks wait, **oldest-first**; with a distributed store this rate-limits
136
+ **across a fleet**.
137
+
138
+ A `Doer` exposes `available()`, `do(task, opts?)`, and `done()` — see the doer section of
139
+ the core docs. `rateLimitedDoer`'s `available()` is `min(limit − pending, inner.available())`.
140
+
141
+ ### Example — cap an `@ayepi/work` engine
142
+
143
+ ```ts
144
+ import { rateLimitedDoer } from '@ayepi/rate'
145
+ import { createWork } from '@ayepi/work'
146
+
147
+ const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
148
+ const w = createWork({ work: [sendEmail] as const, doer }) // ≤ 100 sends/min
149
+ ```
150
+
151
+ ### Example — compose a rate cap with a concurrency cap
152
+
153
+ ```ts
154
+ import { rateLimitedDoer } from '@ayepi/rate'
155
+ import { priorityDoer } from '@ayepi/core/doer'
156
+
157
+ // ≤ 100 starts/min AND ≤ 4 running concurrently
158
+ const doer = rateLimitedDoer({
159
+ limit: 100,
160
+ window: 60_000,
161
+ doer: priorityDoer({ max: 4 }),
162
+ })
163
+ ```
164
+
165
+ ### Example — per-key buckets
166
+
167
+ ```ts
168
+ // each `group` gets its own bucket of `limit`
169
+ const doer = rateLimitedDoer({
170
+ limit: 10,
171
+ window: 60_000,
172
+ key: (o) => o.group ?? 'default', // o is the task's DoerTaskOptions ({} if none given)
173
+ })
174
+ ```
175
+
176
+ With a static `key` string (or the default `'doer'`) all tasks share one bucket.
177
+
178
+ ---
179
+
180
+ ## How it works under the hood
181
+
182
+ ### The three algorithms
183
+
184
+ All three are implemented inside the store. `memoryStore` and `redisStore` produce the
185
+ same decisions; Redis just runs each as one atomic Lua script.
186
+
187
+ - **`fixed-window`** (default) — a counter per `[key, window]`. The first hit in a window
188
+ sets the reset time to `now + window`; a hit is `allowed` while `count < limit` and then
189
+ increments the counter. Cheap, but allows up to `2·limit` across a window boundary
190
+ (burst at the end of one window + start of the next).
191
+
192
+ - **`sliding-window`** — keeps the current window's counter **and** reads the immediately
193
+ previous window's counter, then weights the previous count by how far into the current
194
+ window you are: `weighted = prevCount · (window − elapsed)/window + curCount`; a hit is
195
+ `allowed` while admitting it keeps `weighted <= limit`, and only then increments the
196
+ current counter. Smooths the boundary burst of fixed-window. (It stores sub-keys as
197
+ `key|windowStart`; `reset` deletes those sub-keys too.)
198
+
199
+ - **`token-bucket`** — a bucket of capacity `limit` refilling at `limit/window` tokens per
200
+ ms. Each request costs 1 token; `allowed` if at least 1 token is available, otherwise
201
+ `retryAfter` is the time to refill the missing fraction. Permits bursts up to `limit`
202
+ while enforcing the long-run rate.
203
+
204
+ By default a **rejected** request does not consume budget (the counter isn't incremented;
205
+ token-bucket never had the token to spend). This keeps a client from extending its own
206
+ block by hammering — most visible in `sliding-window`, where a counted rejection would
207
+ weigh into the next window. Set `countRejected: true` on the rule/options for the stricter
208
+ "every attempt counts" behavior.
209
+
210
+ `reset`, `remaining`, and `retryAfter` are reported in **milliseconds** in
211
+ `RateLimitInfo`; `rateLimitResponse` converts `reset`/`retryAfter` to **seconds** for the
212
+ `ratelimit-reset` / `retry-after` headers (and emits `retry-after` only when the request
213
+ was rejected).
214
+
215
+ ### Store consultation
216
+
217
+ `limiter()` builds a `RateLimitRule` from `{ limit, window, algorithm }`, prepends
218
+ `prefix` (default `'rl:'`) to the key, and calls `store.consume(prefixedKey, rule, now)`.
219
+ The store records the hit and returns `{ allowed, limit, remaining, reset, retryAfter }`.
220
+ The limiter is stateless beyond its rule + store reference — all per-key state lives in the
221
+ store, which is why a distributed store gives a distributed limit.
222
+
223
+ ### Composition with the core middleware chain
224
+
225
+ `rateLimit.server(def, opts)` binds a standard `@ayepi/core` middleware whose `run`:
226
+
227
+ 1. builds `kio = { req, ctx }`;
228
+ 2. if `skip(kio)` → calls `io.next({ ratelimit: <full budget> })` (admit, no store hit);
229
+ 3. otherwise `await limiter.check(key(kio))`;
230
+ 4. if **not** allowed → returns `rateLimitResponse(info, { status, headers, message })` —
231
+ a `Response`, which `@ayepi/core` treats as a **short-circuit**: the rest of the chain
232
+ and the handler are skipped (HTTP sends the `Response`; ws turns it into an error
233
+ frame);
234
+ 5. if allowed → `io.next({ ratelimit: info })`, merging `ratelimit` into the handler
235
+ context.
236
+
237
+ Because `requires` is declared on the **def** and forwarded into the bound middleware, the
238
+ limiter's dependency middleware run first and their context is available — see
239
+ `ayepi-core-middleware.md` for how `requires` are auto-included and topologically ordered.
240
+
241
+ ### The doer abstraction
242
+
243
+ `rateLimitedDoer` keeps a `pending` queue. On each `drain`:
244
+
245
+ - if the inner doer has no capacity (`inner.available() <= 0`), it arms a short re-check
246
+ timer and stops;
247
+ - otherwise it picks the **oldest** pending task (by `createdAt`, then submission `seq`),
248
+ calls `limiter.check(keyOf(task), now())`;
249
+ - if denied, it arms a timer for `max(retryFloor, retryAfter)` and stops (drains again when
250
+ the limiter would allow);
251
+ - if admitted, it removes the task and calls `inner.do(task.run, task.opts)`.
252
+
253
+ `done()` resolves once `pending` is empty **and** the inner doer's `done()` resolves. The
254
+ re-check timer is `unref`'d, so it won't keep a process alive on its own.
255
+
256
+ ---
257
+
258
+ ## Gotchas / constraints
259
+
260
+ - **`memoryStore` is per-process.** Multiple instances each get an independent budget. For
261
+ a shared limit across pods, use `@ayepi/rate/redis` (or another distributed
262
+ `RateLimitStore`).
263
+ - **`fixed-window` allows boundary bursts** (up to ~2·limit across a window edge). Use
264
+ `sliding-window` or `token-bucket` if that matters.
265
+ - **Header time units differ from `RateLimitInfo`.** `RateLimitInfo.reset`/`retryAfter` are
266
+ **ms**; the `ratelimit-reset`/`retry-after` headers are **seconds** (`Math.ceil`'d).
267
+ - **Custom `headers` replace, not merge.** A `headers` function returns the full header map;
268
+ the default `ratelimit-*` headers are not added alongside it.
269
+ - **`message` arity differs** between `rateLimit` (`(info, io)`) and `rateLimitResponse`
270
+ (`(info)`).
271
+ - **`skip` short-circuits the store** — skipped requests still get a `ctx.ratelimit` (full
272
+ budget, `reset`/`retryAfter` = 0) but record no hit.
273
+ - **`redisStore` needs `ioredis`** (optional peer dep) and a working `eval` (Lua). Its
274
+ `prefix` stacks on top of the limiter's own `prefix`.
275
+ - **`rateLimitedDoer` does not execute tasks** — it admits and hands off to an inner doer.
276
+ Without an inner concurrency cap (`unlimitedDoer` default), admitted tasks run with no
277
+ concurrency limit; the rate cap only governs *start rate*.
278
+ - **Import paths:** `@ayepi/rate`, `@ayepi/rate/server`, and `@ayepi/rate/redis` exist.
279
+ `rateLimitedDoer`, `limiter`, `memoryStore`, and `rateLimitResponse` are exported from the
280
+ main `@ayepi/rate` entry; `rateLimit`'s `.server` binder is on `@ayepi/rate/server`.
281
+
282
+ ---
283
+
284
+ See also: **`ayepi-rate.md`** (overview, the `rateLimit` middleware, standalone primitives),
285
+ **`ayepi-core-middleware.md`** (middleware composition, `requires`, `StackCtx`,
286
+ `.group()`/`.endpoint()`, short-circuit `Response` semantics), and `@ayepi/core/doer` (the
287
+ `Doer` interface and bundled policies `unlimitedDoer`/`priorityDoer`/`ageDoer`/`balancedDoer`).
package/ayepi-rate.md ADDED
@@ -0,0 +1,480 @@
1
+ <!--
2
+ ayepi-rate.md — reference for `@ayepi/rate`, written for coding agents.
3
+
4
+ Copy this file into any project that depends on `@ayepi/rate` (e.g. into your repo's
5
+ `docs/` or `.claude/` directory) and reference it from your agents and slash commands.
6
+ It documents the public API, the patterns the package expects, and how it works under the
7
+ hood, with copy-pasteable examples. Keep it in sync with the installed package version.
8
+ -->
9
+
10
+ # `@ayepi/rate`
11
+
12
+ Rate-limiting middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core).
13
+ It derives a **key from the request context** (the authenticated user, an IP, an API
14
+ token, …), checks it against a **store + algorithm**, and — when the limit is exceeded —
15
+ **short-circuits the middleware chain with a 429 `Response`** (which `@ayepi/core` maps to
16
+ a websocket error frame for ws transports). On allowed requests the handler receives
17
+ `ctx.ratelimit` info. The same limiting primitive is reusable outside middleware (any
18
+ handler, queue/cron worker, CLI) and powers a `rateLimitedDoer` that caps task **start
19
+ rate** for `@ayepi/work`. Use it whenever you need per-user / per-key throttling on an
20
+ ayepi API, with an in-memory default store and a distributed Redis store for limiting
21
+ across instances.
22
+
23
+ ```sh
24
+ pnpm add @ayepi/rate @ayepi/core
25
+ # optional, only for the Redis store (peer dependency, optional):
26
+ pnpm add ioredis
27
+ ```
28
+
29
+ It ships as a **def / impl split**:
30
+
31
+ - `@ayepi/rate` (frontend-safe) exports `rateLimit(opts?)`, a middleware **def factory**.
32
+ The def declares the contract that goes in the spec and **contributes `{ ratelimit }`** to
33
+ the handler context. A spec importing only this entry is safe to bundle for the frontend.
34
+ The standalone `limiter` / `memoryStore` / `rateLimitResponse` / `rateLimitedDoer`
35
+ primitives also live on this entry, unchanged.
36
+ - `@ayepi/rate/server` augments `rateLimit` with **`.server(def, opts)`**, which binds the
37
+ policy. The policy options (`key`, `limit`, `window`, `algorithm`, `store`, `prefix`,
38
+ `countRejected`, `status`, `message`, `headers`, `alwaysHeaders`, `skip`) live here. Bind
39
+ the pair with `implement(api).middleware(...)`.
40
+
41
+ Cross-reference: middleware composition (def vs impl, `requires`, `StackCtx`,
42
+ `.group()`/`.endpoint()`), the `implement(api)` builder, and short-circuit semantics are
43
+ documented in **`ayepi-core-middleware.md`** — read it alongside this file.
44
+
45
+ ---
46
+
47
+ ## At a glance
48
+
49
+ ```ts
50
+ // shared.ts — frontend-safe
51
+ import { rateLimit } from '@ayepi/rate'
52
+
53
+ const limit = rateLimit({
54
+ requires: [auth], // ctx.user is available + typed inside `key`/`skip`/`message` on the impl
55
+ })
56
+
57
+ const api = spec({ endpoints: { ...limit.group({ getThing: { /* … */ } }) } })
58
+ ```
59
+
60
+ ```ts
61
+ // server.ts — binds the policy, with implement(api)
62
+ import { rateLimit } from '@ayepi/rate/server'
63
+ import { implement } from '@ayepi/core'
64
+
65
+ const app = implement(api)
66
+ // 100 requests / minute per authenticated user, sliding window
67
+ .middleware(rateLimit.server(limit, {
68
+ key: (io) => io.ctx.user.id,
69
+ limit: 100,
70
+ window: 60_000,
71
+ algorithm: 'sliding-window',
72
+ }))
73
+ .server()
74
+ ```
75
+
76
+ On allowed requests the handler reads `ctx.ratelimit` (`{ limit, remaining, reset,
77
+ retryAfter }`); on exceeded requests the chain short-circuits with the 429 before the
78
+ handler runs.
79
+
80
+ > **Every middleware in a chain must be bound.** `implement(api)` is a chainable builder;
81
+ > bind a def → impl pair with `.middleware(def, impl)` or `.middleware(boundPair)` (where
82
+ > `rateLimit.server(def, opts)` returns the bound pair). If any middleware reachable from the
83
+ > spec is left unbound, `.server()` throws.
84
+
85
+ ---
86
+
87
+ ## Public API surface
88
+
89
+ Everything below is exported. `@internal` symbols are intentionally omitted.
90
+
91
+ ### Main entry `@ayepi/rate` (frontend-safe)
92
+
93
+ | Export | Kind | Purpose |
94
+ | --- | --- | --- |
95
+ | `rateLimit` | function | **Def factory** — declares the middleware contract (`{ ratelimit }`). |
96
+ | `limiter` | function | Standalone limiter primitive (`check`/`reset`/`rule`). |
97
+ | `rateLimitResponse` | function | Build a 429 `Response` from limiter info. |
98
+ | `rateLimitHeaders` | function | Compute the `RateLimit-*` header map from limiter info. |
99
+ | `memoryStore` | function | The bundled in-process store (all three algorithms). |
100
+ | `rateLimitedDoer` | function | A `Doer` that caps task **start rate**. |
101
+ | `Algorithm` | type | `'fixed-window' \| 'sliding-window' \| 'token-bucket'`. |
102
+ | `RateLimitInfo` | interface | Limiter state exposed to handlers + headers. |
103
+ | `RateLimitResult` | interface | `RateLimitInfo` + `allowed`. |
104
+ | `RateLimitRule` | interface | `{ limit, window, algorithm, countRejected? }` — what a store evaluates. |
105
+ | `RateLimitStore` | interface | Pluggable backend (`consume` + optional `reset`). |
106
+ | `RateKeyIO` | interface | `{ req, ctx }` passed to `key`/`skip`/`message`. |
107
+ | `LimiterOptions` | interface | Base config (`limit`/`window`/`algorithm`/`store`/`prefix`). |
108
+ | `RateLimitResponseOptions` | interface | `status`/`message`/`headers` for `rateLimitResponse`. |
109
+ | `RateLimitDefOptions` | interface | Options for the `rateLimit` def (`name`/`requires`). |
110
+ | `RateLimitedDoerOptions` | interface | Options for `rateLimitedDoer`. |
111
+ | `Limiter` | interface | The object `limiter()` returns. |
112
+
113
+ ### Server subpath `@ayepi/rate/server`
114
+
115
+ | Export | Kind | Purpose |
116
+ | --- | --- | --- |
117
+ | `rateLimit` | function | Same name, **augmented with `.server(def, opts)`** to bind the policy. |
118
+ | `RateLimitServerOptions` | interface | The policy options for `.server` (rule fields accept a static value or a per-request `(io) => T`). |
119
+ | `RateKeyFn` / `Dynamic` | type | Helpers: `RateKeyFn<M, T> = (io) => T`; `Dynamic<M, T> = T \| RateKeyFn<M, T>` — the shape of every per-request option. |
120
+
121
+ ### Redis subpath `@ayepi/rate/redis`
122
+
123
+ | Export | Kind | Purpose |
124
+ | --- | --- | --- |
125
+ | `redisStore` | function | A distributed `RateLimitStore` backed by ioredis. |
126
+ | `RedisStoreOptions` | interface | `{ prefix? }`. |
127
+ | `RedisEvalLike` | interface | Minimal ioredis surface (`eval`) the store needs. |
128
+
129
+ > The package exposes exactly three import specifiers: `@ayepi/rate`, `@ayepi/rate/server`,
130
+ > and `@ayepi/rate/redis` (per `package.json#exports`). `rateLimitedDoer` and the standalone
131
+ > primitives live on the **main entry** — import them from `@ayepi/rate`, not a `/doer`
132
+ > subpath. `rateLimit`'s `.server` binder is the **only** thing on `@ayepi/rate/server`.
133
+
134
+ ---
135
+
136
+ ## `rateLimit` — the def + the `.server` impl
137
+
138
+ ### The def (`@ayepi/rate`)
139
+
140
+ ```ts
141
+ function rateLimit<const R extends readonly AnyMiddleware[] = readonly []>(
142
+ opts?: RateLimitDefOptions<R>,
143
+ ): RateLimitDef<{ ratelimit: RateLimitInfo }, R>
144
+
145
+ interface RateLimitDefOptions<R extends readonly AnyMiddleware[]> {
146
+ /** Middleware this one depends on — their context is available (and typed) in `key`/`skip`/`message` on the impl. */
147
+ readonly requires?: R;
148
+ /** Middleware name for docs/debugging (default `'rateLimit'`). */
149
+ readonly name?: string;
150
+ }
151
+ ```
152
+
153
+ The def declares a `@ayepi/core` middleware that **provides `{ ratelimit: RateLimitInfo }`**
154
+ to the handler context on allowed requests (and short-circuits with a 429 otherwise, once
155
+ bound). It is frontend-safe and carries no policy. Compose the def exactly like any other
156
+ ayepi middleware — attach it with `.endpoint()`, `.group()`, `use(...)` / `.with()`, or list
157
+ it in another middleware's `requires` (see `ayepi-core-middleware.md`).
158
+
159
+ ### The impl (`@ayepi/rate/server`)
160
+
161
+ ```ts
162
+ rateLimit.server: <M extends AnyMiddleware>(
163
+ def: M, // a RateLimitDef — its `requires` types `io.ctx`
164
+ opts: RateLimitServerOptions<M>,
165
+ ) => BoundMiddleware<M> // pass to implement(api).middleware(...)
166
+ ```
167
+
168
+ `.server` binds the policy and returns the bound pair. It composes with the chainable
169
+ `implement(api)` builder: `implement(api).middleware(rateLimit.server(def, opts))`.
170
+
171
+ ### `RateLimitServerOptions`
172
+
173
+ ```ts
174
+ // A rule field is either a fixed value or resolved per request from the key `io`.
175
+ type RateKeyFn<M, T> = (io: RateKeyIO<StackCtx<ReqOf<M>>>) => T;
176
+ type Dynamic<M, T> = T | RateKeyFn<M, T>;
177
+
178
+ interface RateLimitServerOptions<M extends AnyMiddleware> {
179
+ /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
180
+ readonly key: RateKeyFn<M, string>;
181
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
182
+ readonly limit: Dynamic<M, number>;
183
+ /** Window length in ms (also the token refill period) — fixed, or per request. */
184
+ readonly window: Dynamic<M, number>;
185
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
186
+ readonly algorithm?: Dynamic<M, Algorithm>;
187
+ /** Count rejected (over-limit) requests against the limit (default `false`) — fixed, or per request. */
188
+ readonly countRejected?: Dynamic<M, boolean>;
189
+ /** Backend store (default an in-process `memoryStore`). */
190
+ readonly store?: RateLimitStore;
191
+ /** Key prefix/namespace (default `'rl:'`). */
192
+ readonly prefix?: string;
193
+ /** Over-limit status code (default `429`). */
194
+ readonly status?: number;
195
+ /** Over-limit body — string, JSON value, or a function of (info, io). */
196
+ readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
197
+ /** Response headers (see `RateLimitResponseOptions`). */
198
+ readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
199
+ /** Also emit the `RateLimit-*` headers on allowed/skipped responses (default `false`). */
200
+ readonly alwaysHeaders?: boolean;
201
+ /** Bypass the limiter for some requests (e.g. an allow-list). */
202
+ readonly skip?: RateKeyFn<M, boolean>;
203
+ /** Serve through (as allowed) when the **store** errors, instead of failing the request. Default `false` (fail-closed). */
204
+ readonly failOpen?: boolean;
205
+ /** Observe a store error (e.g. Redis down). Fires regardless of `failOpen`. Off by default; must not throw. */
206
+ readonly onError?: (err: unknown) => void;
207
+ }
208
+ ```
209
+
210
+ > **Dynamic policy.** `limit`, `window`, `algorithm`, and `countRejected` each accept a plain
211
+ > value **or** a `(io) => value` resolved per request — so different callers get different
212
+ > limits (e.g. a higher `limit` for a premium plan, a longer `window` for a trusted key). `io`
213
+ > is the same `{ req, ctx }` `key`/`skip` receive, with `io.ctx` typed from the def's `requires`.
214
+ > Resolve `window`/`algorithm` from a **stable** property of the identity (a given key should
215
+ > always map to the same window/algorithm) — the store buckets its per-key state by them, so
216
+ > varying them for one key across requests gives inconsistent accounting. Varying only `limit`
217
+ > is always safe.
218
+
219
+ > **Store errors.** By default the limiter is **fail-closed**: if the store (e.g. a Redis
220
+ > outage) throws, the error propagates and the request is rejected — a store outage doesn't
221
+ > silently lift the limit. Set `failOpen: true` to serve such requests through instead, and/or
222
+ > `onError` to observe the failure (it fires either way). `rateLimitedDoer` takes an `onError`
223
+ > too: a store error there is reported and admission retried — it never strands pending tasks.
224
+
225
+ > The standalone `limiter()` primitive (and `rateLimitedDoer`) still takes a **static**
226
+ > `LimiterOptions` (`limit`/`window`/`algorithm`/`store`/`prefix`/`countRejected`) — dynamic
227
+ > resolution is a middleware feature, since only the request path carries an `io`.
228
+
229
+ Notes grounded in the source:
230
+
231
+ - **`key` is required** (a `.server` option). It runs per request; its return value is the
232
+ limiter key (the configured `prefix` is prepended internally — default `'rl:'`).
233
+ - **`requires`** is declared on the **def** and flows context types into `key`/`skip`/`message`
234
+ on the impl. With `requires: [auth]`, `io.ctx.user` is typed. Without `requires`, `io.ctx`
235
+ is the empty context and you must read from `io.req` (e.g. `io.req.headers.get('x-user')`).
236
+ - **`skip`** runs before the limiter. When it returns `true`, the request is admitted with
237
+ a synthetic `ctx.ratelimit` of `{ limit, remaining: limit, reset: 0, retryAfter: 0 }` —
238
+ no store hit.
239
+ - **`countRejected`** (default `false`) — a request that is **itself rejected** (over the
240
+ limit) does **not** count against the limit, so a client can't extend its own block by
241
+ continuing to hammer the endpoint. This matters most for `sliding-window`, where a
242
+ counted rejection would carry into the next window. Set `true` for the stricter "every
243
+ attempt consumes budget" behavior. (No effect on `token-bucket`, which never charges a
244
+ request it can't admit.) Threads through to the Redis store as well.
245
+ - **`alwaysHeaders`** (default `false`) — also emit the `RateLimit-*` headers on **allowed**
246
+ (and skipped) responses, not just the 429, so every response advertises the caller's
247
+ remaining budget. Uses the same `headers` formatting; `Retry-After` is omitted when the
248
+ request wasn't rate-limited.
249
+ - **`message`** for the middleware takes `(info, io)`; `rateLimitResponse`'s standalone
250
+ `message` takes only `(info)`. `rateLimit.server` adapts between them.
251
+
252
+ ### `RateKeyIO` / `RateLimitInfo`
253
+
254
+ ```ts
255
+ interface RateKeyIO<Ctx extends object> {
256
+ readonly req: Request;
257
+ readonly ctx: Ctx;
258
+ }
259
+
260
+ interface RateLimitInfo {
261
+ readonly limit: number; // the configured request limit for the window
262
+ readonly remaining: number; // requests/tokens left before the limit is hit
263
+ readonly reset: number; // ms until the window/bucket resets
264
+ readonly retryAfter: number; // ms to wait before retrying (0 when allowed)
265
+ }
266
+ ```
267
+
268
+ ### Example — apply to a group of endpoints
269
+
270
+ ```ts
271
+ // shared.ts
272
+ import { rateLimit } from '@ayepi/rate'
273
+ import { spec } from '@ayepi/core'
274
+
275
+ const limit = rateLimit({ requires: [auth] })
276
+
277
+ const api = spec({
278
+ endpoints: {
279
+ ...limit.group({
280
+ listThings: { response: z.array(Thing) },
281
+ createThing: { body: NewThing, response: Thing },
282
+ }),
283
+ },
284
+ })
285
+
286
+ // server.ts
287
+ import { rateLimit } from '@ayepi/rate/server'
288
+
289
+ implement(api).middleware(rateLimit.server(limit, {
290
+ key: (io) => io.ctx.user.id,
291
+ limit: 100,
292
+ window: 60_000,
293
+ algorithm: 'sliding-window',
294
+ }))
295
+ ```
296
+
297
+ ### Example — a single endpoint, reading `ctx.ratelimit`
298
+
299
+ ```ts
300
+ // shared.ts
301
+ const limit = rateLimit({ requires: [auth] })
302
+ const api = spec({
303
+ endpoints: { hit: limit.endpoint({ response: z.object({ ok: z.boolean(), remaining: z.number() }) }) },
304
+ })
305
+
306
+ // server.ts
307
+ import { rateLimit } from '@ayepi/rate/server'
308
+
309
+ const app = implement(api)
310
+ .middleware(rateLimit.server(limit, { key: (io) => io.ctx.user.id, limit: 2, window: 60_000 }))
311
+ .handlers({
312
+ hit: ({ ratelimit }) => ({ ok: true, remaining: ratelimit.remaining }),
313
+ })
314
+ .server()
315
+ ```
316
+
317
+ ### Example — per-caller limits (dynamic `limit`)
318
+
319
+ ```ts
320
+ // shared.ts — auth provides { user: { id, plan } }
321
+ const limit = rateLimit({ requires: [auth] })
322
+ // server.ts
323
+ rateLimit.server(limit, {
324
+ key: (io) => io.ctx.user.id,
325
+ limit: (io) => (io.ctx.user.plan === 'pro' ? 1_000 : 100), // premium callers get more
326
+ window: 60_000, // keep window/algorithm stable per key
327
+ algorithm: 'sliding-window',
328
+ })
329
+ ```
330
+
331
+ Any of `limit` / `window` / `algorithm` / `countRejected` can be a `(io) => value`. Derive
332
+ `window`/`algorithm` from a stable identity attribute (plan, tenant) — not from time or the
333
+ request path — so a given key always maps to the same bucketing.
334
+
335
+ ### Example — per-IP limiting without `requires`
336
+
337
+ ```ts
338
+ // shared.ts
339
+ const limit = rateLimit()
340
+ // server.ts
341
+ rateLimit.server(limit, {
342
+ key: (io) => io.req.headers.get('x-forwarded-for') ?? 'anon',
343
+ limit: 20,
344
+ window: 1_000,
345
+ })
346
+ ```
347
+
348
+ ### Example — choosing an algorithm
349
+
350
+ ```ts
351
+ rateLimit.server(limit, { key, limit: 100, window: 60_000, algorithm: 'fixed-window' }) // default; simple counter per window
352
+ rateLimit.server(limit, { key, limit: 100, window: 60_000, algorithm: 'sliding-window' }) // smoother; weights previous window
353
+ rateLimit.server(limit, { key, limit: 100, window: 60_000, algorithm: 'token-bucket' }) // steady rate, bursts up to `limit`
354
+ ```
355
+
356
+ ### Example — custom 429 (status, JSON body, headers, skip)
357
+
358
+ ```ts
359
+ rateLimit.server(limit, {
360
+ key: (io) => clientIp(io.req),
361
+ limit: 20,
362
+ window: 1_000,
363
+ status: 503, // default 429
364
+ message: (info, io) => ({ error: 'slow down', retryAfter: info.retryAfter }), // string | JSON | fn(info, io)
365
+ headers: (info) => ({ 'x-ratelimit': String(info.limit) }), // custom headers REPLACE the defaults
366
+ skip: (io) => io.req.headers.get('x-admin') === '1', // allow-list bypass
367
+ })
368
+ ```
369
+
370
+ - `headers: true` (default) emits draft `ratelimit-limit` / `ratelimit-remaining` /
371
+ `ratelimit-reset` — plus `retry-after` **only when the request was rejected** (`reset`
372
+ and `retry-after` in **seconds**, `Math.ceil`'d).
373
+ - `headers: false` emits none of those.
374
+ - A `headers` function returns your own map and **replaces** the defaults entirely.
375
+ - `alwaysHeaders: true` applies the same formatting to allowed/skipped responses too (via
376
+ `io.setHeader`), so clients always see their remaining budget.
377
+ - A string `message` is sent as `text/plain; charset=utf-8`; a JSON `message` is
378
+ `JSON.stringify`'d as `application/json`. Default body is `'Too many requests'`.
379
+
380
+ ---
381
+
382
+ ## Standalone primitives (no middleware)
383
+
384
+ The middleware impl is a thin wrapper over `limiter()` + `rateLimitResponse()`. Both stay on
385
+ the **main `@ayepi/rate` entry**, unchanged by the def/impl split — use them directly in a
386
+ plain handler, a worker, a CLI, or another framework.
387
+
388
+ ### `limiter`
389
+
390
+ ```ts
391
+ function limiter(opts: LimiterOptions): Limiter
392
+
393
+ interface Limiter {
394
+ /** Record a hit for `key` (at `now`, default `Date.now()`) and return the decision. */
395
+ check(key: string, now?: number): MaybePromise<RateLimitResult>;
396
+ /** Clear all state for `key`. */
397
+ reset(key: string): MaybePromise<void>;
398
+ /** The rule this limiter enforces. */
399
+ readonly rule: RateLimitRule;
400
+ }
401
+ ```
402
+
403
+ `RateLimitResult` is `RateLimitInfo` plus `readonly allowed: boolean`.
404
+
405
+ ```ts
406
+ import { limiter, reject } from '@ayepi/rate' // reject is from @ayepi/core
407
+
408
+ const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
409
+
410
+ const { allowed, remaining, retryAfter } = await lim.check(userId)
411
+ if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
412
+
413
+ await lim.reset(userId) // clear a key
414
+ ```
415
+
416
+ `check` may return a value or a promise depending on the store (`memoryStore` is sync,
417
+ `redisStore` is async) — `await` it to handle both.
418
+
419
+ ### `rateLimitResponse`
420
+
421
+ ```ts
422
+ function rateLimitResponse(info: RateLimitInfo, opts?: RateLimitResponseOptions): Response
423
+
424
+ interface RateLimitResponseOptions {
425
+ readonly status?: number; // default 429
426
+ readonly message?: string | Json | ((info: RateLimitInfo) => string | Json);
427
+ readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
428
+ }
429
+ ```
430
+
431
+ Builds the same 429 the middleware emits, but as a free-standing `Response` you can return
432
+ from any handler that called `limiter()` directly:
433
+
434
+ ```ts
435
+ const result = await lim.check(userId)
436
+ if (!result.allowed) return rateLimitResponse(result, { message: { error: 'nope' } })
437
+ ```
438
+
439
+ ### `rateLimitHeaders`
440
+
441
+ ```ts
442
+ function rateLimitHeaders(
443
+ info: RateLimitInfo,
444
+ headers?: boolean | ((info: RateLimitInfo) => Record<string, string>), // default true
445
+ ): Record<string, string>
446
+ ```
447
+
448
+ The header map `rateLimitResponse` and the middleware's `alwaysHeaders` both use:
449
+ `true` → draft `ratelimit-limit`/`-remaining`/`-reset` (plus `retry-after` only when
450
+ `info.retryAfter > 0`); `false` → `{}`; a function → your own map. Handy if you call
451
+ `limiter()` directly and want to set the same headers on your own `Response`:
452
+
453
+ ```ts
454
+ const r = await lim.check(userId)
455
+ for (const [k, v] of Object.entries(rateLimitHeaders(r))) res.headers.set(k, v)
456
+ ```
457
+
458
+ ---
459
+
460
+ ## Stores, the Redis store, and the rate-limited doer
461
+
462
+ These topics — the pluggable `RateLimitStore` interface, the bundled `memoryStore`, the
463
+ distributed `@ayepi/rate/redis` store, `rateLimitedDoer`, the algorithm internals, and the
464
+ gotchas — live in the companion file to keep this one focused:
465
+
466
+ - **[`ayepi-rate-stores-doer.md`](./ayepi-rate-stores-doer.md)**
467
+ - **Stores** — `RateLimitStore` interface, `memoryStore` (default, bundled),
468
+ `redisStore` (`@ayepi/rate/redis`) + `RedisStoreOptions` / `RedisEvalLike`.
469
+ - **`rateLimitedDoer`** — capping task start rate, composing with an inner doer,
470
+ per-key buckets.
471
+ - **How it works under the hood** — the three algorithms, store consultation, middleware
472
+ chain composition, the doer drain loop.
473
+ - **Gotchas / constraints.**
474
+
475
+ ---
476
+
477
+ See also: **`ayepi-rate-stores-doer.md`** (stores, Redis, the doer, internals, gotchas),
478
+ **`ayepi-core-middleware.md`** (middleware composition, `requires`, `StackCtx`,
479
+ `.group()`/`.endpoint()`, short-circuit `Response` semantics) and `@ayepi/core/doer` (the
480
+ `Doer` interface and bundled policies `unlimitedDoer`/`priorityDoer`/`ageDoer`/`balancedDoer`).
package/dist/index.cjs CHANGED
@@ -66,10 +66,10 @@ function limiter(opts) {
66
66
  const rule = {
67
67
  limit: opts.limit,
68
68
  window: opts.window,
69
- algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
69
+ algorithm: opts.algorithm ?? "fixed-window",
70
70
  countRejected: opts.countRejected ?? false
71
71
  };
72
- const prefix = opts.prefix ?? DEFAULT_PREFIX;
72
+ const prefix = opts.prefix ?? "rl:";
73
73
  return {
74
74
  rule,
75
75
  check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
@@ -329,6 +329,8 @@ function memoryStore() {
329
329
  };
330
330
  }
331
331
  //#endregion
332
+ exports.DEFAULT_ALGORITHM = DEFAULT_ALGORITHM;
333
+ exports.DEFAULT_PREFIX = DEFAULT_PREFIX;
332
334
  exports.limiter = limiter;
333
335
  exports.memoryStore = memoryStore;
334
336
  exports.rateLimit = rateLimit;
package/dist/index.d.cts CHANGED
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
+ /** Default key namespace. */
7
+ declare const DEFAULT_PREFIX = "rl:";
8
+ /** Default algorithm. */
9
+ declare const DEFAULT_ALGORITHM: Algorithm;
6
10
  /** The rate-limiting algorithm. */
7
11
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
8
12
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
@@ -186,4 +190,4 @@ declare function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer;
186
190
  */
187
191
  declare function memoryStore(): RateLimitStore;
188
192
  //#endregion
189
- export { Algorithm, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
193
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.d.ts CHANGED
@@ -3,6 +3,10 @@ import { Doer, DoerTaskOptions } from "@ayepi/core/doer";
3
3
 
4
4
  //#region src/index.d.ts
5
5
 
6
+ /** Default key namespace. */
7
+ declare const DEFAULT_PREFIX = "rl:";
8
+ /** Default algorithm. */
9
+ declare const DEFAULT_ALGORITHM: Algorithm;
6
10
  /** The rate-limiting algorithm. */
7
11
  type Algorithm = 'fixed-window' | 'sliding-window' | 'token-bucket';
8
12
  /** Limiter state for one key, exposed to handlers (via `ctx.ratelimit`) and response headers. */
@@ -186,4 +190,4 @@ declare function rateLimitedDoer(opts: RateLimitedDoerOptions): Doer;
186
190
  */
187
191
  declare function memoryStore(): RateLimitStore;
188
192
  //#endregion
189
- export { Algorithm, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
193
+ export { Algorithm, DEFAULT_ALGORITHM, DEFAULT_PREFIX, Limiter, LimiterOptions, RateKeyIO, RateLimitDef, RateLimitDefOptions, RateLimitInfo, RateLimitResponseOptions, RateLimitResult, RateLimitRule, RateLimitStore, RateLimitedDoerOptions, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/index.js CHANGED
@@ -65,10 +65,10 @@ function limiter(opts) {
65
65
  const rule = {
66
66
  limit: opts.limit,
67
67
  window: opts.window,
68
- algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
68
+ algorithm: opts.algorithm ?? "fixed-window",
69
69
  countRejected: opts.countRejected ?? false
70
70
  };
71
- const prefix = opts.prefix ?? DEFAULT_PREFIX;
71
+ const prefix = opts.prefix ?? "rl:";
72
72
  return {
73
73
  rule,
74
74
  check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
@@ -328,4 +328,4 @@ function memoryStore() {
328
328
  };
329
329
  }
330
330
  //#endregion
331
- export { limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
331
+ export { DEFAULT_ALGORITHM, DEFAULT_PREFIX, limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/server.cjs CHANGED
@@ -1,22 +1,34 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_index = require("./index.cjs");
3
3
  //#region src/server.ts
4
+ /** Resolve a {@link Dynamic} option for a request (calls it with `io` when it's a function). */
5
+ function resolve(value, io) {
6
+ return typeof value === "function" ? value(io) : value;
7
+ }
4
8
  /** Bind a {@link rateLimit} def to its runtime policy. */
5
9
  function rateLimitServer(def, opts) {
6
- const lim = require_index.limiter(opts);
10
+ const store = opts.store ?? require_index.memoryStore();
11
+ const prefix = opts.prefix ?? "rl:";
7
12
  const message = opts.message;
13
+ const ruleFor = (io) => ({
14
+ limit: resolve(opts.limit, io),
15
+ window: resolve(opts.window, io),
16
+ algorithm: (opts.algorithm !== void 0 ? resolve(opts.algorithm, io) : void 0) ?? "fixed-window",
17
+ countRejected: (opts.countRejected !== void 0 ? resolve(opts.countRejected, io) : void 0) ?? false
18
+ });
8
19
  const run = async (io) => {
9
20
  const kio = {
10
21
  req: io.req,
11
22
  ctx: io.ctx
12
23
  };
24
+ const rule = ruleFor(kio);
13
25
  const advertise = (info) => {
14
26
  if (!opts.alwaysHeaders) return;
15
27
  for (const [name, value] of Object.entries(require_index.rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
16
28
  };
17
29
  const fullBudget = () => ({
18
- limit: lim.rule.limit,
19
- remaining: lim.rule.limit,
30
+ limit: rule.limit,
31
+ remaining: rule.limit,
20
32
  reset: 0,
21
33
  retryAfter: 0
22
34
  });
@@ -27,7 +39,7 @@ function rateLimitServer(def, opts) {
27
39
  }
28
40
  let result;
29
41
  try {
30
- result = await lim.check(opts.key(kio));
42
+ result = await store.consume(prefix + opts.key(kio), rule, Date.now());
31
43
  } catch (err) {
32
44
  try {
33
45
  opts.onError?.(err);
package/dist/server.d.cts CHANGED
@@ -5,22 +5,47 @@ import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
5
5
 
6
6
  /** The `requires` chain of a middleware def. */
7
7
  type ReqOf<M extends AnyMiddleware> = M['__req'];
8
+ /** The `io` (request + accumulated, typed context) passed to every per-request option of a bound {@link rateLimit}. */
9
+ type KeyIO<M extends AnyMiddleware> = RateKeyIO<StackCtx<ReqOf<M>>>;
10
+ /**
11
+ * A per-request option: derive `T` from the rate-limit key {@link RateKeyIO} — the same
12
+ * `(io) => T` shape `key` and `skip` use. `io.ctx` is typed from the def's `requires`.
13
+ */
14
+ type RateKeyFn<M extends AnyMiddleware, T> = (io: KeyIO<M>) => T;
15
+ /**
16
+ * A {@link RateLimitServerOptions} field that is either a fixed `T` or resolved **per request**
17
+ * from the key {@link RateKeyIO}. Use a function to vary the policy by caller — e.g. a larger
18
+ * `limit` for a premium plan, a longer `window` for a trusted client.
19
+ */
20
+ type Dynamic<M extends AnyMiddleware, T> = T | RateKeyFn<M, T>;
8
21
  /**
9
22
  * Server-side options for binding a {@link rateLimit} def — the limiting policy and
10
23
  * response customization, with `key`/`skip`/`message` typed against the def's
11
24
  * `requires` context.
12
25
  *
26
+ * The rule itself — `limit`, `window`, `algorithm`, `countRejected` — can be a fixed value or
27
+ * a `(io) => T` resolved per request, so limits can differ by caller (plan, tenant, API key).
28
+ * **Resolve these from a stable property of the identity** (the same key should always map to
29
+ * the same `window`/`algorithm`): the store buckets its per-key state by them, so varying
30
+ * `window`/`algorithm` for one key across requests gives inconsistent accounting. Varying only
31
+ * `limit` is always safe. See {@link Dynamic}.
32
+ *
13
33
  * @typeParam M - the rate-limit def being bound.
14
34
  */
15
35
  interface RateLimitServerOptions<M extends AnyMiddleware> {
16
36
  /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
17
- readonly key: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => string;
18
- /** Max requests (or token-bucket capacity) per window. */
19
- readonly limit: number;
20
- /** Window length in milliseconds (also the token refill period). */
21
- readonly window: number;
22
- /** Algorithm (default `'fixed-window'`). */
23
- readonly algorithm?: Algorithm;
37
+ readonly key: RateKeyFn<M, string>;
38
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
39
+ readonly limit: Dynamic<M, number>;
40
+ /** Window length in milliseconds (also the token refill period) — fixed, or per request. */
41
+ readonly window: Dynamic<M, number>;
42
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
43
+ readonly algorithm?: Dynamic<M, Algorithm>;
44
+ /**
45
+ * Count requests that are themselves rejected (over-limit) against the limit — fixed, or per
46
+ * request. Default `false`; see {@link RateLimitRule.countRejected}.
47
+ */
48
+ readonly countRejected?: Dynamic<M, boolean>;
24
49
  /** Backend store (default an in-process memory store). */
25
50
  readonly store?: RateLimitStore;
26
51
  /** Key prefix/namespace (default `'rl:'`). */
@@ -28,7 +53,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
28
53
  /** Over-limit status code (default `429`). */
29
54
  readonly status?: number;
30
55
  /** Over-limit body — a string, a JSON value, or a function of the limiter info and request. */
31
- readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
56
+ readonly message?: string | Json | ((info: RateLimitInfo, io: KeyIO<M>) => string | Json);
32
57
  /** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
33
58
  readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
34
59
  /**
@@ -39,7 +64,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
39
64
  */
40
65
  readonly alwaysHeaders?: boolean;
41
66
  /** Bypass the limiter for some requests (e.g. an allow-list). */
42
- readonly skip?: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => boolean;
67
+ readonly skip?: RateKeyFn<M, boolean>;
43
68
  /**
44
69
  * What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
45
70
  * request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
@@ -64,4 +89,4 @@ declare const rateLimit: typeof rateLimit$1 & {
64
89
  server: typeof rateLimitServer;
65
90
  };
66
91
  //#endregion
67
- export { RateLimitServerOptions, rateLimit };
92
+ export { Dynamic, RateKeyFn, RateLimitServerOptions, rateLimit };
package/dist/server.d.ts CHANGED
@@ -5,22 +5,47 @@ import { AnyMiddleware, BoundMiddleware, Json, StackCtx } from "@ayepi/core";
5
5
 
6
6
  /** The `requires` chain of a middleware def. */
7
7
  type ReqOf<M extends AnyMiddleware> = M['__req'];
8
+ /** The `io` (request + accumulated, typed context) passed to every per-request option of a bound {@link rateLimit}. */
9
+ type KeyIO<M extends AnyMiddleware> = RateKeyIO<StackCtx<ReqOf<M>>>;
10
+ /**
11
+ * A per-request option: derive `T` from the rate-limit key {@link RateKeyIO} — the same
12
+ * `(io) => T` shape `key` and `skip` use. `io.ctx` is typed from the def's `requires`.
13
+ */
14
+ type RateKeyFn<M extends AnyMiddleware, T> = (io: KeyIO<M>) => T;
15
+ /**
16
+ * A {@link RateLimitServerOptions} field that is either a fixed `T` or resolved **per request**
17
+ * from the key {@link RateKeyIO}. Use a function to vary the policy by caller — e.g. a larger
18
+ * `limit` for a premium plan, a longer `window` for a trusted client.
19
+ */
20
+ type Dynamic<M extends AnyMiddleware, T> = T | RateKeyFn<M, T>;
8
21
  /**
9
22
  * Server-side options for binding a {@link rateLimit} def — the limiting policy and
10
23
  * response customization, with `key`/`skip`/`message` typed against the def's
11
24
  * `requires` context.
12
25
  *
26
+ * The rule itself — `limit`, `window`, `algorithm`, `countRejected` — can be a fixed value or
27
+ * a `(io) => T` resolved per request, so limits can differ by caller (plan, tenant, API key).
28
+ * **Resolve these from a stable property of the identity** (the same key should always map to
29
+ * the same `window`/`algorithm`): the store buckets its per-key state by them, so varying
30
+ * `window`/`algorithm` for one key across requests gives inconsistent accounting. Varying only
31
+ * `limit` is always safe. See {@link Dynamic}.
32
+ *
13
33
  * @typeParam M - the rate-limit def being bound.
14
34
  */
15
35
  interface RateLimitServerOptions<M extends AnyMiddleware> {
16
36
  /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
17
- readonly key: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => string;
18
- /** Max requests (or token-bucket capacity) per window. */
19
- readonly limit: number;
20
- /** Window length in milliseconds (also the token refill period). */
21
- readonly window: number;
22
- /** Algorithm (default `'fixed-window'`). */
23
- readonly algorithm?: Algorithm;
37
+ readonly key: RateKeyFn<M, string>;
38
+ /** Max requests (or token-bucket capacity) per window — fixed, or per request. */
39
+ readonly limit: Dynamic<M, number>;
40
+ /** Window length in milliseconds (also the token refill period) — fixed, or per request. */
41
+ readonly window: Dynamic<M, number>;
42
+ /** Algorithm (default `'fixed-window'`) — fixed, or per request. */
43
+ readonly algorithm?: Dynamic<M, Algorithm>;
44
+ /**
45
+ * Count requests that are themselves rejected (over-limit) against the limit — fixed, or per
46
+ * request. Default `false`; see {@link RateLimitRule.countRejected}.
47
+ */
48
+ readonly countRejected?: Dynamic<M, boolean>;
24
49
  /** Backend store (default an in-process memory store). */
25
50
  readonly store?: RateLimitStore;
26
51
  /** Key prefix/namespace (default `'rl:'`). */
@@ -28,7 +53,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
28
53
  /** Over-limit status code (default `429`). */
29
54
  readonly status?: number;
30
55
  /** Over-limit body — a string, a JSON value, or a function of the limiter info and request. */
31
- readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<StackCtx<ReqOf<M>>>) => string | Json);
56
+ readonly message?: string | Json | ((info: RateLimitInfo, io: KeyIO<M>) => string | Json);
32
57
  /** Response headers (draft `RateLimit-*` + `Retry-After` by default; `false` for none; a function for your own). */
33
58
  readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
34
59
  /**
@@ -39,7 +64,7 @@ interface RateLimitServerOptions<M extends AnyMiddleware> {
39
64
  */
40
65
  readonly alwaysHeaders?: boolean;
41
66
  /** Bypass the limiter for some requests (e.g. an allow-list). */
42
- readonly skip?: (io: RateKeyIO<StackCtx<ReqOf<M>>>) => boolean;
67
+ readonly skip?: RateKeyFn<M, boolean>;
43
68
  /**
44
69
  * What to do when the **store itself errors** (e.g. Redis is down) — `true` serves the
45
70
  * request through (as if allowed, full budget); `false` (default) is fail-**closed**: the
@@ -64,4 +89,4 @@ declare const rateLimit: typeof rateLimit$1 & {
64
89
  server: typeof rateLimitServer;
65
90
  };
66
91
  //#endregion
67
- export { RateLimitServerOptions, rateLimit };
92
+ export { Dynamic, RateKeyFn, RateLimitServerOptions, rateLimit };
package/dist/server.js CHANGED
@@ -1,21 +1,33 @@
1
- import { limiter, rateLimit as rateLimit$1, rateLimitHeaders, rateLimitResponse } from "./index.js";
1
+ import { memoryStore, rateLimit as rateLimit$1, rateLimitHeaders, rateLimitResponse } from "./index.js";
2
2
  //#region src/server.ts
3
+ /** Resolve a {@link Dynamic} option for a request (calls it with `io` when it's a function). */
4
+ function resolve(value, io) {
5
+ return typeof value === "function" ? value(io) : value;
6
+ }
3
7
  /** Bind a {@link rateLimit} def to its runtime policy. */
4
8
  function rateLimitServer(def, opts) {
5
- const lim = limiter(opts);
9
+ const store = opts.store ?? memoryStore();
10
+ const prefix = opts.prefix ?? "rl:";
6
11
  const message = opts.message;
12
+ const ruleFor = (io) => ({
13
+ limit: resolve(opts.limit, io),
14
+ window: resolve(opts.window, io),
15
+ algorithm: (opts.algorithm !== void 0 ? resolve(opts.algorithm, io) : void 0) ?? "fixed-window",
16
+ countRejected: (opts.countRejected !== void 0 ? resolve(opts.countRejected, io) : void 0) ?? false
17
+ });
7
18
  const run = async (io) => {
8
19
  const kio = {
9
20
  req: io.req,
10
21
  ctx: io.ctx
11
22
  };
23
+ const rule = ruleFor(kio);
12
24
  const advertise = (info) => {
13
25
  if (!opts.alwaysHeaders) return;
14
26
  for (const [name, value] of Object.entries(rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
15
27
  };
16
28
  const fullBudget = () => ({
17
- limit: lim.rule.limit,
18
- remaining: lim.rule.limit,
29
+ limit: rule.limit,
30
+ remaining: rule.limit,
19
31
  reset: 0,
20
32
  retryAfter: 0
21
33
  });
@@ -26,7 +38,7 @@ function rateLimitServer(def, opts) {
26
38
  }
27
39
  let result;
28
40
  try {
29
- result = await lim.check(opts.key(kio));
41
+ result = await store.consume(prefix + opts.key(kio), rule, Date.now());
30
42
  } catch (err) {
31
43
  try {
32
44
  opts.onError?.(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/rate",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Rate-limiting middleware for @ayepi/core — pluggable stores, multiple algorithms, customizable 429 responses",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -18,7 +18,8 @@
18
18
  "type": "module",
19
19
  "sideEffects": false,
20
20
  "files": [
21
- "dist"
21
+ "dist",
22
+ "ayepi-*.md"
22
23
  ],
23
24
  "exports": {
24
25
  ".": {
@@ -58,7 +59,7 @@
58
59
  },
59
60
  "peerDependencies": {
60
61
  "ioredis": "^5",
61
- "@ayepi/core": "^0.1.0"
62
+ "@ayepi/core": "^0.3.0"
62
63
  },
63
64
  "peerDependenciesMeta": {
64
65
  "ioredis": {
@@ -73,7 +74,7 @@
73
74
  "tsdown": "^0.12.0",
74
75
  "vitest": "^2.1.8",
75
76
  "zod": "^4.4.3",
76
- "@ayepi/core": "0.1.0"
77
+ "@ayepi/core": "0.3.0"
77
78
  },
78
79
  "keywords": [
79
80
  "ayepi",