@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 +1 -1
- package/ayepi-rate-stores-doer.md +287 -0
- package/ayepi-rate.md +452 -0
- package/package.json +5 -4
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
|
|
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.
|
|
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.
|
|
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.
|
|
77
|
+
"@ayepi/core": "0.2.0"
|
|
77
78
|
},
|
|
78
79
|
"keywords": [
|
|
79
80
|
"ayepi",
|