@ayepi/rate 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philip Diffenderfer
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # @ayepi/rate
2
+
3
+ Rate-limiting middleware for [`@ayepi/core`](https://www.npmjs.com/package/@ayepi/core).
4
+ Derive a key from the request context, pick an algorithm and a store, and exceeded
5
+ requests **short-circuit with a 429** (which also maps to a ws error frame).
6
+
7
+ ```sh
8
+ pnpm add @ayepi/rate @ayepi/core
9
+ ```
10
+
11
+ `@ayepi/rate` ships as a **def / impl split**. The main entry is frontend-safe and exports
12
+ `rateLimit(opts?)`, a middleware **def factory**. The policy (key, limit, window, store, …)
13
+ is bound on the server via `@ayepi/rate/server`, which augments `rateLimit` with
14
+ `.server(def, opts)`.
15
+
16
+ ```ts
17
+ // shared.ts — frontend-safe: the def + the spec
18
+ import { rateLimit } from '@ayepi/rate'
19
+
20
+ const limit = rateLimit({ requires: [auth] }) // contributes { ratelimit } to the handler ctx
21
+
22
+ const api = spec({ endpoints: { ...limit.group({ getThing: { … } }) } })
23
+ ```
24
+
25
+ ```ts
26
+ // server.ts — binds the policy
27
+ import { rateLimit } from '@ayepi/rate/server'
28
+ import { implement } from '@ayepi/core'
29
+
30
+ const app = implement(api)
31
+ // 100 requests / minute per authenticated user, sliding window
32
+ .middleware(rateLimit.server(limit, {
33
+ key: (io) => io.ctx.user.id,
34
+ limit: 100,
35
+ window: 60_000,
36
+ algorithm: 'sliding-window',
37
+ }))
38
+ .server()
39
+ ```
40
+
41
+ On allowed requests the handler gets `ctx.ratelimit` (`{ limit, remaining, reset,
42
+ retryAfter }`); on exceeded requests the chain short-circuits with the 429.
43
+
44
+ ## Def vs server
45
+
46
+ - `rateLimit(opts?)` (def factory, `@ayepi/rate`) — frontend-safe. `opts = { name?,
47
+ requires? }`. Declares the contract and **contributes `{ ratelimit }`** to the handler
48
+ context. A spec importing only this entry is safe to bundle for the frontend.
49
+ - `rateLimit.server(def, opts)` (`@ayepi/rate/server`) — binds the policy. These options
50
+ move here: `key, limit, window, algorithm?, store?, prefix?, countRejected?, status?,
51
+ message?, headers?, alwaysHeaders?, skip?`. Bind it with `implement(api).middleware(...)`.
52
+
53
+ ## Standalone (without middleware)
54
+
55
+ The middleware is a thin wrapper over two primitives, **both still on the main `@ayepi/rate`
56
+ entry** (frontend-unrelated; use them anywhere — a handler, a queue/cron worker, a CLI,
57
+ another framework):
58
+
59
+ ```ts
60
+ import { limiter, rateLimitResponse } from '@ayepi/rate'
61
+
62
+ const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
63
+
64
+ const { allowed, remaining, retryAfter } = await lim.check(userId)
65
+ if (!allowed) {
66
+ // do whatever you want with the decision:
67
+ throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`) // …or
68
+ return rateLimitResponse({ limit: 100, remaining, reset: 0, retryAfter }) // a ready-made 429
69
+ }
70
+
71
+ await lim.reset(userId) // clear a key
72
+ ```
73
+
74
+ - `limiter(opts)` → `{ check(key, now?), reset(key), rule }` — the actual limiting
75
+ (pluggable store + algorithm), no HTTP involved.
76
+ - `rateLimitResponse(info, opts?)` → a `Response` (status/message/headers), if you
77
+ want one.
78
+
79
+ `rateLimit.server()` === `limiter()` + `rateLimitResponse()` + key/skip/requires wiring.
80
+
81
+ ## Rate-limited doer (for `@ayepi/work`)
82
+
83
+ `rateLimitedDoer` is a [`Doer`](https://www.npmjs.com/package/@ayepi/core) (from
84
+ `@ayepi/core/doer`) that caps the **start rate** of tasks through the same `limiter()`
85
+ primitive — so an `@ayepi/work` engine processes work no faster than a budget allows.
86
+ It also stays on the main `@ayepi/rate` entry. Excess tasks wait, oldest-first, and a
87
+ distributed store limits across a fleet:
88
+
89
+ ```ts
90
+ import { rateLimitedDoer } from '@ayepi/rate'
91
+ import { createWork } from '@ayepi/work'
92
+
93
+ const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
94
+ const w = createWork({ work: [sendEmail] as const, doer }) // ≤ 100 sends/min
95
+ ```
96
+
97
+ ## Algorithms
98
+
99
+ - `fixed-window` (default) — simple counter per window.
100
+ - `sliding-window` — weights the previous window for a smoother limit.
101
+ - `token-bucket` — steady rate with bursts up to `limit`.
102
+
103
+ ## Stores (cross-instance)
104
+
105
+ The default store is in-memory (single process). To limit across pods, use the
106
+ Redis store (each algorithm runs as one atomic Lua script). The store is a `.server`
107
+ option:
108
+
109
+ ```ts
110
+ import Redis from 'ioredis'
111
+ import { rateLimit } from '@ayepi/rate/server'
112
+ import { redisStore } from '@ayepi/rate/redis'
113
+
114
+ rateLimit.server(limit, {
115
+ key: (io) => io.ctx.user.id, limit: 100, window: 60_000, store: redisStore(new Redis(url)),
116
+ })
117
+ ```
118
+
119
+ Implement the `RateLimitStore` interface for any other backend.
120
+
121
+ ## Customizing the response
122
+
123
+ All of these are `.server` options:
124
+
125
+ ```ts
126
+ rateLimit.server(limit, {
127
+ key: (io) => clientIp(io.req),
128
+ limit: 20,
129
+ window: 1000,
130
+ status: 429, // default 429
131
+ message: (info) => ({ error: 'slow down', retryAfter: info.retryAfter }), // string | JSON | fn
132
+ headers: true, // draft RateLimit-* (+ Retry-After when blocked); false to omit; or a fn for custom headers
133
+ alwaysHeaders: true, // also set RateLimit-* on allowed responses (default false)
134
+ countRejected: false, // default: an over-limit request doesn't count against the limit
135
+ skip: (io) => io.req.headers.get('x-admin') === '1',
136
+ })
137
+ ```
138
+
139
+ ## For AI coding agents
140
+
141
+ This package ships dense, machine-oriented reference docs written for **AI coding agents**
142
+ (Claude Code, Cursor, and the like) to understand and drive the package — point your agent at them:
143
+
144
+ - [`ayepi-rate-stores-doer.md`](./ayepi-rate-stores-doer.md)
145
+ - [`ayepi-rate.md`](./ayepi-rate.md)
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.
148
+
149
+ ## License
150
+
151
+ MIT © Philip Diffenderfer
package/dist/index.cjs ADDED
@@ -0,0 +1,337 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _ayepi_core = require("@ayepi/core");
3
+ let _ayepi_core_doer = require("@ayepi/core/doer");
4
+ //#region src/index.ts
5
+ /**
6
+ * # @ayepi/rate
7
+ *
8
+ * Rate-limiting middleware for ayepi. {@link rateLimit} builds a middleware that
9
+ * derives a **key from the request context** (e.g. the authenticated user, an IP,
10
+ * an API token), checks it against a **store + algorithm**, and — when the limit
11
+ * is exceeded — **short-circuits with a 429 `Response`** (which also maps to a ws
12
+ * error frame). Successful requests expose `ratelimit` info in the handler
13
+ * context.
14
+ *
15
+ * ```ts
16
+ * // shared.ts (frontend-safe): the def declares what it contributes
17
+ * import { rateLimit } from '@ayepi/rate'
18
+ * const limit = rateLimit({ requires: [auth] }) // provides { ratelimit }
19
+ * spec({ endpoints: { ...limit.group({ … }) } })
20
+ *
21
+ * // server.ts: bind the policy (key, limit, window, store)
22
+ * import { rateLimit } from '@ayepi/rate/server'
23
+ * implement(api).middleware(rateLimit.server(limit, {
24
+ * key: (io) => io.ctx.user.id, // io.ctx.user typed via `requires: [auth]`
25
+ * limit: 100,
26
+ * window: 60_000,
27
+ * algorithm: 'sliding-window',
28
+ * }))
29
+ * ```
30
+ *
31
+ * - **Pluggable store** — in-memory by default ({@link memoryStore}); pass a
32
+ * distributed store (see `@ayepi/rate/redis`) to limit across instances.
33
+ * - **Algorithms** — `fixed-window`, `sliding-window`, `token-bucket`.
34
+ * - **Customizable response** — status, message (text or JSON), and headers
35
+ * (draft `RateLimit-*` + `Retry-After` by default, or your own).
36
+ *
37
+ * @module
38
+ */
39
+ /** Default over-limit HTTP status. */
40
+ const DEFAULT_STATUS = 429;
41
+ /** Default key namespace. */
42
+ const DEFAULT_PREFIX = "rl:";
43
+ /** Default algorithm. */
44
+ const DEFAULT_ALGORITHM = "fixed-window";
45
+ /** Tokens consumed per request (token-bucket). */
46
+ const TOKEN_COST = 1;
47
+ /** Milliseconds per second — `Retry-After` / `RateLimit-Reset` are expressed in seconds. */
48
+ const MS_PER_SECOND = 1e3;
49
+ /** Amortized cleanup: sweep expired in-memory entries once every this many `consume` calls. */
50
+ const SWEEP_EVERY = 1e3;
51
+ /** Drop an idle in-memory token bucket after this long with no activity (it refills to full anyway). */
52
+ const BUCKET_IDLE_MS = 600 * 1e3;
53
+ /**
54
+ * Create a standalone {@link Limiter} — the rate-limit primitive the
55
+ * {@link rateLimit} middleware is built on. Use it anywhere you have a key: a
56
+ * plain handler, a queue/cron worker, a CLI, a different framework.
57
+ *
58
+ * ```ts
59
+ * const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
60
+ * const { allowed, retryAfter } = await lim.check(userId)
61
+ * if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
62
+ * ```
63
+ */
64
+ function limiter(opts) {
65
+ const store = opts.store ?? memoryStore();
66
+ const rule = {
67
+ limit: opts.limit,
68
+ window: opts.window,
69
+ algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
70
+ countRejected: opts.countRejected ?? false
71
+ };
72
+ const prefix = opts.prefix ?? DEFAULT_PREFIX;
73
+ return {
74
+ rule,
75
+ check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
76
+ reset: (key) => Promise.resolve(store.reset?.(prefix + key))
77
+ };
78
+ }
79
+ /**
80
+ * Compute the rate-limit response headers for `info`. `true` (default) emits the
81
+ * draft `RateLimit-Limit`/`-Remaining`/`-Reset` headers — plus `Retry-After` **only
82
+ * when the request was rejected** (`retryAfter > 0`); `false` emits none; a function
83
+ * returns your own map (which **replaces** the defaults).
84
+ *
85
+ * Shared by {@link rateLimitResponse} (the 429) and the middleware's `alwaysHeaders`
86
+ * option (informational headers on allowed responses).
87
+ */
88
+ function rateLimitHeaders(info, headers = true) {
89
+ if (headers === false) return {};
90
+ if (typeof headers === "function") return { ...headers(info) };
91
+ const out = {
92
+ "ratelimit-limit": String(info.limit),
93
+ "ratelimit-remaining": String(info.remaining),
94
+ "ratelimit-reset": String(Math.ceil(info.reset / MS_PER_SECOND))
95
+ };
96
+ if (info.retryAfter > 0) out["retry-after"] = String(Math.ceil(info.retryAfter / MS_PER_SECOND));
97
+ return out;
98
+ }
99
+ /**
100
+ * Build a rate-limit (429) `Response` from limiter info — usable on its own,
101
+ * outside any middleware (e.g. from a handler that called {@link limiter} directly).
102
+ */
103
+ function rateLimitResponse(info, opts = {}) {
104
+ const status = opts.status ?? DEFAULT_STATUS;
105
+ const headers = rateLimitHeaders(info, opts.headers);
106
+ const body = typeof opts.message === "function" ? opts.message(info) : opts.message ?? "Too many requests";
107
+ if (typeof body === "string") {
108
+ if (!("content-type" in headers)) headers["content-type"] = "text/plain; charset=utf-8";
109
+ return new Response(body, {
110
+ status,
111
+ headers
112
+ });
113
+ }
114
+ return new Response(JSON.stringify(body), {
115
+ status,
116
+ headers: {
117
+ "content-type": "application/json",
118
+ ...headers
119
+ }
120
+ });
121
+ }
122
+ /**
123
+ * Create a rate-limiting middleware **def**. The def declares what the middleware
124
+ * contributes (`{ ratelimit: RateLimitInfo }`) and its dependencies — but **no**
125
+ * policy. Bind the key/limit/window/store with
126
+ * [`rateLimit.server(def, { key, limit, window })`](./server).
127
+ *
128
+ * @typeParam R - inferred from `requires`; their context types flow into the
129
+ * server-side `key`/`skip`/`message`.
130
+ */
131
+ function rateLimit(opts) {
132
+ return (0, _ayepi_core.middleware)(opts?.name ?? "rateLimit", {
133
+ provides: (0, _ayepi_core.ctx)(),
134
+ requires: opts?.requires ?? []
135
+ });
136
+ }
137
+ /** Minimum re-check delay when a deferred task has no explicit retry hint (ms). */
138
+ const DOER_RETRY_FLOOR = 50;
139
+ /**
140
+ * A {@link Doer} (see `@ayepi/core/doer`) that caps the **start rate** of tasks using a
141
+ * standalone {@link limiter} — the same primitive (and pluggable {@link RateLimitStore}/
142
+ * algorithm) the {@link rateLimit} middleware uses. When the limiter admits a task it is
143
+ * handed to an **inner doer** (default {@link unlimitedDoer}), so you can compose a rate
144
+ * cap with a concurrency/ordering policy (e.g. `rateLimitedDoer({ …, doer: priorityDoer({ max: 4 }) })`).
145
+ * Excess tasks wait, oldest-first; a distributed store rate-limits **across a fleet**.
146
+ *
147
+ * ```ts
148
+ * import { rateLimitedDoer } from '@ayepi/rate'
149
+ * import { createWork } from '@ayepi/work'
150
+ *
151
+ * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
152
+ * const w = createWork({ work: [sendEmail] as const, doer })
153
+ * ```
154
+ */
155
+ function rateLimitedDoer(opts) {
156
+ const lim = limiter(opts);
157
+ const inner = opts.doer ?? (0, _ayepi_core_doer.unlimitedDoer)();
158
+ const now = opts.now ?? Date.now;
159
+ const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
160
+ const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
161
+ const pending = [];
162
+ const idle = [];
163
+ let seq = 0;
164
+ let draining = false;
165
+ let timer = null;
166
+ const arm = (ms) => {
167
+ if (timer) return;
168
+ timer = setTimeout(() => {
169
+ timer = null;
170
+ drain();
171
+ }, Math.max(floor, ms));
172
+ timer.unref?.();
173
+ };
174
+ const drain = async () => {
175
+ if (draining) return;
176
+ draining = true;
177
+ try {
178
+ while (pending.length > 0) {
179
+ if (inner.available() <= 0) {
180
+ arm(floor);
181
+ break;
182
+ }
183
+ let best = 0;
184
+ for (let i = 1; i < pending.length; i++) {
185
+ const a = pending[i];
186
+ const b = pending[best];
187
+ if (a.createdAt < b.createdAt || a.createdAt === b.createdAt && a.seq < b.seq) best = i;
188
+ }
189
+ const task = pending[best];
190
+ let res;
191
+ try {
192
+ res = await lim.check(keyOf(task.opts), now());
193
+ } catch (err) {
194
+ try {
195
+ opts.onError?.(err);
196
+ } catch {}
197
+ arm(floor);
198
+ break;
199
+ }
200
+ if (!res.allowed) {
201
+ arm(res.retryAfter);
202
+ break;
203
+ }
204
+ pending.splice(best, 1);
205
+ inner.do(task.run, task.opts);
206
+ }
207
+ if (pending.length === 0) for (const r of idle.splice(0)) r();
208
+ } finally {
209
+ draining = false;
210
+ }
211
+ };
212
+ return {
213
+ available: () => Math.min(Math.max(0, lim.rule.limit - pending.length), inner.available()),
214
+ do(task, o) {
215
+ pending.push({
216
+ run: task,
217
+ opts: o,
218
+ createdAt: o?.createdAt ?? now(),
219
+ seq: seq++
220
+ });
221
+ drain();
222
+ },
223
+ done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
224
+ };
225
+ }
226
+ function fixedWindow(counters, key, rule, now) {
227
+ let e = counters.get(key);
228
+ if (!e || e.reset <= now) {
229
+ e = {
230
+ count: 0,
231
+ reset: now + rule.window
232
+ };
233
+ counters.set(key, e);
234
+ }
235
+ const allowed = e.count < rule.limit;
236
+ if (allowed || rule.countRejected) e.count++;
237
+ const reset = e.reset - now;
238
+ return {
239
+ allowed,
240
+ limit: rule.limit,
241
+ remaining: Math.max(0, rule.limit - e.count),
242
+ reset,
243
+ retryAfter: allowed ? 0 : reset
244
+ };
245
+ }
246
+ function slidingWindow(counters, key, rule, now) {
247
+ const windowStart = Math.floor(now / rule.window) * rule.window;
248
+ const curKey = `${key}|${windowStart}`;
249
+ const prevKey = `${key}|${windowStart - rule.window}`;
250
+ let cur = counters.get(curKey);
251
+ if (!cur || cur.reset <= now) {
252
+ cur = {
253
+ count: 0,
254
+ reset: windowStart + rule.window
255
+ };
256
+ counters.set(curKey, cur);
257
+ }
258
+ const prevCount = counters.get(prevKey)?.count ?? 0;
259
+ const weight = (rule.window - (now - windowStart)) / rule.window;
260
+ const allowed = prevCount * weight + cur.count + 1 <= rule.limit;
261
+ if (allowed || rule.countRejected) cur.count++;
262
+ const weighted = prevCount * weight + cur.count;
263
+ const reset = windowStart + rule.window - now;
264
+ return {
265
+ allowed,
266
+ limit: rule.limit,
267
+ remaining: Math.max(0, Math.floor(rule.limit - weighted)),
268
+ reset,
269
+ retryAfter: allowed ? 0 : reset
270
+ };
271
+ }
272
+ function tokenBucket(buckets, key, rule, now) {
273
+ const cap = rule.limit;
274
+ const refillPerMs = rule.limit / rule.window;
275
+ let b = buckets.get(key);
276
+ if (!b) b = {
277
+ tokens: cap,
278
+ ts: now
279
+ };
280
+ b.tokens = Math.min(cap, b.tokens + (now - b.ts) * refillPerMs);
281
+ b.ts = now;
282
+ const cost = TOKEN_COST;
283
+ let allowed = false;
284
+ if (b.tokens >= cost) {
285
+ b.tokens -= cost;
286
+ allowed = true;
287
+ }
288
+ buckets.set(key, b);
289
+ const remaining = Math.floor(b.tokens);
290
+ const retryAfter = allowed ? 0 : Math.ceil((cost - b.tokens) / refillPerMs);
291
+ const reset = Math.ceil((cap - b.tokens) / refillPerMs);
292
+ return {
293
+ allowed,
294
+ limit: cap,
295
+ remaining,
296
+ reset,
297
+ retryAfter
298
+ };
299
+ }
300
+ /**
301
+ * An in-process {@link RateLimitStore} implementing all three algorithms. The
302
+ * default store — fine for a single instance; use a distributed store (e.g.
303
+ * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
304
+ * lazily.
305
+ */
306
+ function memoryStore() {
307
+ const counters = /* @__PURE__ */ new Map();
308
+ const buckets = /* @__PURE__ */ new Map();
309
+ let ops = 0;
310
+ const sweep = (now) => {
311
+ if (++ops % SWEEP_EVERY !== 0) return;
312
+ for (const [k, e] of counters) if (e.reset <= now) counters.delete(k);
313
+ for (const [k, b] of buckets) if (now - b.ts > BUCKET_IDLE_MS) buckets.delete(k);
314
+ };
315
+ return {
316
+ consume(key, rule, now) {
317
+ sweep(now);
318
+ switch (rule.algorithm) {
319
+ case "sliding-window": return slidingWindow(counters, key, rule, now);
320
+ case "token-bucket": return tokenBucket(buckets, key, rule, now);
321
+ default: return fixedWindow(counters, key, rule, now);
322
+ }
323
+ },
324
+ reset(key) {
325
+ counters.delete(key);
326
+ for (const k of counters.keys()) if (k.startsWith(`${key}|`)) counters.delete(k);
327
+ buckets.delete(key);
328
+ }
329
+ };
330
+ }
331
+ //#endregion
332
+ exports.limiter = limiter;
333
+ exports.memoryStore = memoryStore;
334
+ exports.rateLimit = rateLimit;
335
+ exports.rateLimitHeaders = rateLimitHeaders;
336
+ exports.rateLimitResponse = rateLimitResponse;
337
+ exports.rateLimitedDoer = rateLimitedDoer;