@ayepi/rate 0.1.0 → 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
@@ -144,7 +144,7 @@ This package ships dense, machine-oriented reference docs written for **AI codin
144
144
  - [`ayepi-rate-stores-doer.md`](./ayepi-rate-stores-doer.md)
145
145
  - [`ayepi-rate.md`](./ayepi-rate.md)
146
146
 
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.
147
+ They ship with this package and also live in the [repo](https://github.com/ClickerMonkey/ayepi/tree/main/packages/rate).
148
148
 
149
149
  ## License
150
150
 
@@ -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,452 @@
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` (extends `LimiterOptions`). |
119
+
120
+ ### Redis subpath `@ayepi/rate/redis`
121
+
122
+ | Export | Kind | Purpose |
123
+ | --- | --- | --- |
124
+ | `redisStore` | function | A distributed `RateLimitStore` backed by ioredis. |
125
+ | `RedisStoreOptions` | interface | `{ prefix? }`. |
126
+ | `RedisEvalLike` | interface | Minimal ioredis surface (`eval`) the store needs. |
127
+
128
+ > The package exposes exactly three import specifiers: `@ayepi/rate`, `@ayepi/rate/server`,
129
+ > and `@ayepi/rate/redis` (per `package.json#exports`). `rateLimitedDoer` and the standalone
130
+ > primitives live on the **main entry** — import them from `@ayepi/rate`, not a `/doer`
131
+ > subpath. `rateLimit`'s `.server` binder is the **only** thing on `@ayepi/rate/server`.
132
+
133
+ ---
134
+
135
+ ## `rateLimit` — the def + the `.server` impl
136
+
137
+ ### The def (`@ayepi/rate`)
138
+
139
+ ```ts
140
+ function rateLimit<const R extends readonly AnyMiddleware[] = readonly []>(
141
+ opts?: RateLimitDefOptions<R>,
142
+ ): RateLimitDef<{ ratelimit: RateLimitInfo }, R>
143
+
144
+ interface RateLimitDefOptions<R extends readonly AnyMiddleware[]> {
145
+ /** Middleware this one depends on — their context is available (and typed) in `key`/`skip`/`message` on the impl. */
146
+ readonly requires?: R;
147
+ /** Middleware name for docs/debugging (default `'rateLimit'`). */
148
+ readonly name?: string;
149
+ }
150
+ ```
151
+
152
+ The def declares a `@ayepi/core` middleware that **provides `{ ratelimit: RateLimitInfo }`**
153
+ to the handler context on allowed requests (and short-circuits with a 429 otherwise, once
154
+ bound). It is frontend-safe and carries no policy. Compose the def exactly like any other
155
+ ayepi middleware — attach it with `.endpoint()`, `.group()`, `use(...)` / `.with()`, or list
156
+ it in another middleware's `requires` (see `ayepi-core-middleware.md`).
157
+
158
+ ### The impl (`@ayepi/rate/server`)
159
+
160
+ ```ts
161
+ rateLimit.server: <const R extends readonly AnyMiddleware[]>(
162
+ def: RateLimitDef<{ ratelimit: RateLimitInfo }, R>,
163
+ opts: RateLimitServerOptions<StackCtx<R>, R>,
164
+ ) => BoundMiddleware // pass to implement(api).middleware(...)
165
+ ```
166
+
167
+ `.server` binds the policy and returns the bound pair. It composes with the chainable
168
+ `implement(api)` builder: `implement(api).middleware(rateLimit.server(def, opts))`.
169
+
170
+ ### `RateLimitServerOptions`
171
+
172
+ ```ts
173
+ interface RateLimitServerOptions<Ctx extends object, R extends readonly AnyMiddleware[]>
174
+ extends LimiterOptions {
175
+ /** Derive the rate-limit key from the request context (e.g. `io.ctx.user.id`). */
176
+ readonly key: (io: RateKeyIO<Ctx>) => string;
177
+ /** Over-limit status code (default `429`). */
178
+ readonly status?: number;
179
+ /** Over-limit body — string, JSON value, or a function of (info, io). */
180
+ readonly message?: string | Json | ((info: RateLimitInfo, io: RateKeyIO<Ctx>) => string | Json);
181
+ /** Response headers (see `RateLimitResponseOptions`). */
182
+ readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
183
+ /** Also emit the `RateLimit-*` headers on allowed/skipped responses (default `false`). */
184
+ readonly alwaysHeaders?: boolean;
185
+ /** Bypass the limiter for some requests (e.g. an allow-list). */
186
+ readonly skip?: (io: RateKeyIO<Ctx>) => boolean;
187
+ /** Serve through (as allowed) when the **store** errors, instead of failing the request. Default `false` (fail-closed). */
188
+ readonly failOpen?: boolean;
189
+ /** Observe a store error (e.g. Redis down). Fires regardless of `failOpen`. Off by default; must not throw. */
190
+ readonly onError?: (err: unknown) => void;
191
+ }
192
+ ```
193
+
194
+ > **Store errors.** By default the limiter is **fail-closed**: if the store (e.g. a Redis
195
+ > outage) throws, the error propagates and the request is rejected — a store outage doesn't
196
+ > silently lift the limit. Set `failOpen: true` to serve such requests through instead, and/or
197
+ > `onError` to observe the failure (it fires either way). `rateLimitedDoer` takes an `onError`
198
+ > too: a store error there is reported and admission retried — it never strands pending tasks.
199
+
200
+ …plus everything from `LimiterOptions`:
201
+
202
+ ```ts
203
+ interface LimiterOptions {
204
+ /** Max requests (or token-bucket capacity) per window. */
205
+ readonly limit: number;
206
+ /** Window length in milliseconds (also the token refill period). */
207
+ readonly window: number;
208
+ /** Algorithm (default `'fixed-window'`). */
209
+ readonly algorithm?: Algorithm;
210
+ /** Backend store (default an in-process `memoryStore`). */
211
+ readonly store?: RateLimitStore;
212
+ /** Key prefix/namespace (default `'rl:'`). */
213
+ readonly prefix?: string;
214
+ /** Count requests that are themselves rejected (over-limit) against the limit (default `false`). */
215
+ readonly countRejected?: boolean;
216
+ }
217
+ ```
218
+
219
+ Notes grounded in the source:
220
+
221
+ - **`key` is required** (a `.server` option). It runs per request; its return value is the
222
+ limiter key (the configured `prefix` is prepended internally — default `'rl:'`).
223
+ - **`requires`** is declared on the **def** and flows context types into `key`/`skip`/`message`
224
+ on the impl. With `requires: [auth]`, `io.ctx.user` is typed. Without `requires`, `io.ctx`
225
+ is the empty context and you must read from `io.req` (e.g. `io.req.headers.get('x-user')`).
226
+ - **`skip`** runs before the limiter. When it returns `true`, the request is admitted with
227
+ a synthetic `ctx.ratelimit` of `{ limit, remaining: limit, reset: 0, retryAfter: 0 }` —
228
+ no store hit.
229
+ - **`countRejected`** (default `false`) — a request that is **itself rejected** (over the
230
+ limit) does **not** count against the limit, so a client can't extend its own block by
231
+ continuing to hammer the endpoint. This matters most for `sliding-window`, where a
232
+ counted rejection would carry into the next window. Set `true` for the stricter "every
233
+ attempt consumes budget" behavior. (No effect on `token-bucket`, which never charges a
234
+ request it can't admit.) Threads through to the Redis store as well.
235
+ - **`alwaysHeaders`** (default `false`) — also emit the `RateLimit-*` headers on **allowed**
236
+ (and skipped) responses, not just the 429, so every response advertises the caller's
237
+ remaining budget. Uses the same `headers` formatting; `Retry-After` is omitted when the
238
+ request wasn't rate-limited.
239
+ - **`message`** for the middleware takes `(info, io)`; `rateLimitResponse`'s standalone
240
+ `message` takes only `(info)`. `rateLimit.server` adapts between them.
241
+
242
+ ### `RateKeyIO` / `RateLimitInfo`
243
+
244
+ ```ts
245
+ interface RateKeyIO<Ctx extends object> {
246
+ readonly req: Request;
247
+ readonly ctx: Ctx;
248
+ }
249
+
250
+ interface RateLimitInfo {
251
+ readonly limit: number; // the configured request limit for the window
252
+ readonly remaining: number; // requests/tokens left before the limit is hit
253
+ readonly reset: number; // ms until the window/bucket resets
254
+ readonly retryAfter: number; // ms to wait before retrying (0 when allowed)
255
+ }
256
+ ```
257
+
258
+ ### Example — apply to a group of endpoints
259
+
260
+ ```ts
261
+ // shared.ts
262
+ import { rateLimit } from '@ayepi/rate'
263
+ import { spec } from '@ayepi/core'
264
+
265
+ const limit = rateLimit({ requires: [auth] })
266
+
267
+ const api = spec({
268
+ endpoints: {
269
+ ...limit.group({
270
+ listThings: { response: z.array(Thing) },
271
+ createThing: { body: NewThing, response: Thing },
272
+ }),
273
+ },
274
+ })
275
+
276
+ // server.ts
277
+ import { rateLimit } from '@ayepi/rate/server'
278
+
279
+ implement(api).middleware(rateLimit.server(limit, {
280
+ key: (io) => io.ctx.user.id,
281
+ limit: 100,
282
+ window: 60_000,
283
+ algorithm: 'sliding-window',
284
+ }))
285
+ ```
286
+
287
+ ### Example — a single endpoint, reading `ctx.ratelimit`
288
+
289
+ ```ts
290
+ // shared.ts
291
+ const limit = rateLimit({ requires: [auth] })
292
+ const api = spec({
293
+ endpoints: { hit: limit.endpoint({ response: z.object({ ok: z.boolean(), remaining: z.number() }) }) },
294
+ })
295
+
296
+ // server.ts
297
+ import { rateLimit } from '@ayepi/rate/server'
298
+
299
+ const app = implement(api)
300
+ .middleware(rateLimit.server(limit, { key: (io) => io.ctx.user.id, limit: 2, window: 60_000 }))
301
+ .handlers({
302
+ hit: ({ ratelimit }) => ({ ok: true, remaining: ratelimit.remaining }),
303
+ })
304
+ .server()
305
+ ```
306
+
307
+ ### Example — per-IP limiting without `requires`
308
+
309
+ ```ts
310
+ // shared.ts
311
+ const limit = rateLimit()
312
+ // server.ts
313
+ rateLimit.server(limit, {
314
+ key: (io) => io.req.headers.get('x-forwarded-for') ?? 'anon',
315
+ limit: 20,
316
+ window: 1_000,
317
+ })
318
+ ```
319
+
320
+ ### Example — choosing an algorithm
321
+
322
+ ```ts
323
+ rateLimit.server(limit, { key, limit: 100, window: 60_000, algorithm: 'fixed-window' }) // default; simple counter per window
324
+ rateLimit.server(limit, { key, limit: 100, window: 60_000, algorithm: 'sliding-window' }) // smoother; weights previous window
325
+ rateLimit.server(limit, { key, limit: 100, window: 60_000, algorithm: 'token-bucket' }) // steady rate, bursts up to `limit`
326
+ ```
327
+
328
+ ### Example — custom 429 (status, JSON body, headers, skip)
329
+
330
+ ```ts
331
+ rateLimit.server(limit, {
332
+ key: (io) => clientIp(io.req),
333
+ limit: 20,
334
+ window: 1_000,
335
+ status: 503, // default 429
336
+ message: (info, io) => ({ error: 'slow down', retryAfter: info.retryAfter }), // string | JSON | fn(info, io)
337
+ headers: (info) => ({ 'x-ratelimit': String(info.limit) }), // custom headers REPLACE the defaults
338
+ skip: (io) => io.req.headers.get('x-admin') === '1', // allow-list bypass
339
+ })
340
+ ```
341
+
342
+ - `headers: true` (default) emits draft `ratelimit-limit` / `ratelimit-remaining` /
343
+ `ratelimit-reset` — plus `retry-after` **only when the request was rejected** (`reset`
344
+ and `retry-after` in **seconds**, `Math.ceil`'d).
345
+ - `headers: false` emits none of those.
346
+ - A `headers` function returns your own map and **replaces** the defaults entirely.
347
+ - `alwaysHeaders: true` applies the same formatting to allowed/skipped responses too (via
348
+ `io.setHeader`), so clients always see their remaining budget.
349
+ - A string `message` is sent as `text/plain; charset=utf-8`; a JSON `message` is
350
+ `JSON.stringify`'d as `application/json`. Default body is `'Too many requests'`.
351
+
352
+ ---
353
+
354
+ ## Standalone primitives (no middleware)
355
+
356
+ The middleware impl is a thin wrapper over `limiter()` + `rateLimitResponse()`. Both stay on
357
+ the **main `@ayepi/rate` entry**, unchanged by the def/impl split — use them directly in a
358
+ plain handler, a worker, a CLI, or another framework.
359
+
360
+ ### `limiter`
361
+
362
+ ```ts
363
+ function limiter(opts: LimiterOptions): Limiter
364
+
365
+ interface Limiter {
366
+ /** Record a hit for `key` (at `now`, default `Date.now()`) and return the decision. */
367
+ check(key: string, now?: number): MaybePromise<RateLimitResult>;
368
+ /** Clear all state for `key`. */
369
+ reset(key: string): MaybePromise<void>;
370
+ /** The rule this limiter enforces. */
371
+ readonly rule: RateLimitRule;
372
+ }
373
+ ```
374
+
375
+ `RateLimitResult` is `RateLimitInfo` plus `readonly allowed: boolean`.
376
+
377
+ ```ts
378
+ import { limiter, reject } from '@ayepi/rate' // reject is from @ayepi/core
379
+
380
+ const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
381
+
382
+ const { allowed, remaining, retryAfter } = await lim.check(userId)
383
+ if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
384
+
385
+ await lim.reset(userId) // clear a key
386
+ ```
387
+
388
+ `check` may return a value or a promise depending on the store (`memoryStore` is sync,
389
+ `redisStore` is async) — `await` it to handle both.
390
+
391
+ ### `rateLimitResponse`
392
+
393
+ ```ts
394
+ function rateLimitResponse(info: RateLimitInfo, opts?: RateLimitResponseOptions): Response
395
+
396
+ interface RateLimitResponseOptions {
397
+ readonly status?: number; // default 429
398
+ readonly message?: string | Json | ((info: RateLimitInfo) => string | Json);
399
+ readonly headers?: boolean | ((info: RateLimitInfo) => Record<string, string>);
400
+ }
401
+ ```
402
+
403
+ Builds the same 429 the middleware emits, but as a free-standing `Response` you can return
404
+ from any handler that called `limiter()` directly:
405
+
406
+ ```ts
407
+ const result = await lim.check(userId)
408
+ if (!result.allowed) return rateLimitResponse(result, { message: { error: 'nope' } })
409
+ ```
410
+
411
+ ### `rateLimitHeaders`
412
+
413
+ ```ts
414
+ function rateLimitHeaders(
415
+ info: RateLimitInfo,
416
+ headers?: boolean | ((info: RateLimitInfo) => Record<string, string>), // default true
417
+ ): Record<string, string>
418
+ ```
419
+
420
+ The header map `rateLimitResponse` and the middleware's `alwaysHeaders` both use:
421
+ `true` → draft `ratelimit-limit`/`-remaining`/`-reset` (plus `retry-after` only when
422
+ `info.retryAfter > 0`); `false` → `{}`; a function → your own map. Handy if you call
423
+ `limiter()` directly and want to set the same headers on your own `Response`:
424
+
425
+ ```ts
426
+ const r = await lim.check(userId)
427
+ for (const [k, v] of Object.entries(rateLimitHeaders(r))) res.headers.set(k, v)
428
+ ```
429
+
430
+ ---
431
+
432
+ ## Stores, the Redis store, and the rate-limited doer
433
+
434
+ These topics — the pluggable `RateLimitStore` interface, the bundled `memoryStore`, the
435
+ distributed `@ayepi/rate/redis` store, `rateLimitedDoer`, the algorithm internals, and the
436
+ gotchas — live in the companion file to keep this one focused:
437
+
438
+ - **[`ayepi-rate-stores-doer.md`](./ayepi-rate-stores-doer.md)**
439
+ - **Stores** — `RateLimitStore` interface, `memoryStore` (default, bundled),
440
+ `redisStore` (`@ayepi/rate/redis`) + `RedisStoreOptions` / `RedisEvalLike`.
441
+ - **`rateLimitedDoer`** — capping task start rate, composing with an inner doer,
442
+ per-key buckets.
443
+ - **How it works under the hood** — the three algorithms, store consultation, middleware
444
+ chain composition, the doer drain loop.
445
+ - **Gotchas / constraints.**
446
+
447
+ ---
448
+
449
+ See also: **`ayepi-rate-stores-doer.md`** (stores, Redis, the doer, internals, gotchas),
450
+ **`ayepi-core-middleware.md`** (middleware composition, `requires`, `StackCtx`,
451
+ `.group()`/`.endpoint()`, short-circuit `Response` semantics) and `@ayepi/core/doer` (the
452
+ `Doer` interface and bundled policies `unlimitedDoer`/`priorityDoer`/`ageDoer`/`balancedDoer`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ayepi/rate",
3
- "version": "0.1.0",
3
+ "version": "0.2.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.2.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.2.0"
77
78
  },
78
79
  "keywords": [
79
80
  "ayepi",