@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/dist/index.js ADDED
@@ -0,0 +1,331 @@
1
+ import { ctx, middleware } from "@ayepi/core";
2
+ import { unlimitedDoer } from "@ayepi/core/doer";
3
+ //#region src/index.ts
4
+ /**
5
+ * # @ayepi/rate
6
+ *
7
+ * Rate-limiting middleware for ayepi. {@link rateLimit} builds a middleware that
8
+ * derives a **key from the request context** (e.g. the authenticated user, an IP,
9
+ * an API token), checks it against a **store + algorithm**, and — when the limit
10
+ * is exceeded — **short-circuits with a 429 `Response`** (which also maps to a ws
11
+ * error frame). Successful requests expose `ratelimit` info in the handler
12
+ * context.
13
+ *
14
+ * ```ts
15
+ * // shared.ts (frontend-safe): the def declares what it contributes
16
+ * import { rateLimit } from '@ayepi/rate'
17
+ * const limit = rateLimit({ requires: [auth] }) // provides { ratelimit }
18
+ * spec({ endpoints: { ...limit.group({ … }) } })
19
+ *
20
+ * // server.ts: bind the policy (key, limit, window, store)
21
+ * import { rateLimit } from '@ayepi/rate/server'
22
+ * implement(api).middleware(rateLimit.server(limit, {
23
+ * key: (io) => io.ctx.user.id, // io.ctx.user typed via `requires: [auth]`
24
+ * limit: 100,
25
+ * window: 60_000,
26
+ * algorithm: 'sliding-window',
27
+ * }))
28
+ * ```
29
+ *
30
+ * - **Pluggable store** — in-memory by default ({@link memoryStore}); pass a
31
+ * distributed store (see `@ayepi/rate/redis`) to limit across instances.
32
+ * - **Algorithms** — `fixed-window`, `sliding-window`, `token-bucket`.
33
+ * - **Customizable response** — status, message (text or JSON), and headers
34
+ * (draft `RateLimit-*` + `Retry-After` by default, or your own).
35
+ *
36
+ * @module
37
+ */
38
+ /** Default over-limit HTTP status. */
39
+ const DEFAULT_STATUS = 429;
40
+ /** Default key namespace. */
41
+ const DEFAULT_PREFIX = "rl:";
42
+ /** Default algorithm. */
43
+ const DEFAULT_ALGORITHM = "fixed-window";
44
+ /** Tokens consumed per request (token-bucket). */
45
+ const TOKEN_COST = 1;
46
+ /** Milliseconds per second — `Retry-After` / `RateLimit-Reset` are expressed in seconds. */
47
+ const MS_PER_SECOND = 1e3;
48
+ /** Amortized cleanup: sweep expired in-memory entries once every this many `consume` calls. */
49
+ const SWEEP_EVERY = 1e3;
50
+ /** Drop an idle in-memory token bucket after this long with no activity (it refills to full anyway). */
51
+ const BUCKET_IDLE_MS = 600 * 1e3;
52
+ /**
53
+ * Create a standalone {@link Limiter} — the rate-limit primitive the
54
+ * {@link rateLimit} middleware is built on. Use it anywhere you have a key: a
55
+ * plain handler, a queue/cron worker, a CLI, a different framework.
56
+ *
57
+ * ```ts
58
+ * const lim = limiter({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
59
+ * const { allowed, retryAfter } = await lim.check(userId)
60
+ * if (!allowed) throw reject(429, 'RATE_LIMITED', `retry in ${retryAfter}ms`)
61
+ * ```
62
+ */
63
+ function limiter(opts) {
64
+ const store = opts.store ?? memoryStore();
65
+ const rule = {
66
+ limit: opts.limit,
67
+ window: opts.window,
68
+ algorithm: opts.algorithm ?? DEFAULT_ALGORITHM,
69
+ countRejected: opts.countRejected ?? false
70
+ };
71
+ const prefix = opts.prefix ?? DEFAULT_PREFIX;
72
+ return {
73
+ rule,
74
+ check: (key, now) => store.consume(prefix + key, rule, now ?? Date.now()),
75
+ reset: (key) => Promise.resolve(store.reset?.(prefix + key))
76
+ };
77
+ }
78
+ /**
79
+ * Compute the rate-limit response headers for `info`. `true` (default) emits the
80
+ * draft `RateLimit-Limit`/`-Remaining`/`-Reset` headers — plus `Retry-After` **only
81
+ * when the request was rejected** (`retryAfter > 0`); `false` emits none; a function
82
+ * returns your own map (which **replaces** the defaults).
83
+ *
84
+ * Shared by {@link rateLimitResponse} (the 429) and the middleware's `alwaysHeaders`
85
+ * option (informational headers on allowed responses).
86
+ */
87
+ function rateLimitHeaders(info, headers = true) {
88
+ if (headers === false) return {};
89
+ if (typeof headers === "function") return { ...headers(info) };
90
+ const out = {
91
+ "ratelimit-limit": String(info.limit),
92
+ "ratelimit-remaining": String(info.remaining),
93
+ "ratelimit-reset": String(Math.ceil(info.reset / MS_PER_SECOND))
94
+ };
95
+ if (info.retryAfter > 0) out["retry-after"] = String(Math.ceil(info.retryAfter / MS_PER_SECOND));
96
+ return out;
97
+ }
98
+ /**
99
+ * Build a rate-limit (429) `Response` from limiter info — usable on its own,
100
+ * outside any middleware (e.g. from a handler that called {@link limiter} directly).
101
+ */
102
+ function rateLimitResponse(info, opts = {}) {
103
+ const status = opts.status ?? DEFAULT_STATUS;
104
+ const headers = rateLimitHeaders(info, opts.headers);
105
+ const body = typeof opts.message === "function" ? opts.message(info) : opts.message ?? "Too many requests";
106
+ if (typeof body === "string") {
107
+ if (!("content-type" in headers)) headers["content-type"] = "text/plain; charset=utf-8";
108
+ return new Response(body, {
109
+ status,
110
+ headers
111
+ });
112
+ }
113
+ return new Response(JSON.stringify(body), {
114
+ status,
115
+ headers: {
116
+ "content-type": "application/json",
117
+ ...headers
118
+ }
119
+ });
120
+ }
121
+ /**
122
+ * Create a rate-limiting middleware **def**. The def declares what the middleware
123
+ * contributes (`{ ratelimit: RateLimitInfo }`) and its dependencies — but **no**
124
+ * policy. Bind the key/limit/window/store with
125
+ * [`rateLimit.server(def, { key, limit, window })`](./server).
126
+ *
127
+ * @typeParam R - inferred from `requires`; their context types flow into the
128
+ * server-side `key`/`skip`/`message`.
129
+ */
130
+ function rateLimit(opts) {
131
+ return middleware(opts?.name ?? "rateLimit", {
132
+ provides: ctx(),
133
+ requires: opts?.requires ?? []
134
+ });
135
+ }
136
+ /** Minimum re-check delay when a deferred task has no explicit retry hint (ms). */
137
+ const DOER_RETRY_FLOOR = 50;
138
+ /**
139
+ * A {@link Doer} (see `@ayepi/core/doer`) that caps the **start rate** of tasks using a
140
+ * standalone {@link limiter} — the same primitive (and pluggable {@link RateLimitStore}/
141
+ * algorithm) the {@link rateLimit} middleware uses. When the limiter admits a task it is
142
+ * handed to an **inner doer** (default {@link unlimitedDoer}), so you can compose a rate
143
+ * cap with a concurrency/ordering policy (e.g. `rateLimitedDoer({ …, doer: priorityDoer({ max: 4 }) })`).
144
+ * Excess tasks wait, oldest-first; a distributed store rate-limits **across a fleet**.
145
+ *
146
+ * ```ts
147
+ * import { rateLimitedDoer } from '@ayepi/rate'
148
+ * import { createWork } from '@ayepi/work'
149
+ *
150
+ * const doer = rateLimitedDoer({ limit: 100, window: 60_000, algorithm: 'token-bucket' })
151
+ * const w = createWork({ work: [sendEmail] as const, doer })
152
+ * ```
153
+ */
154
+ function rateLimitedDoer(opts) {
155
+ const lim = limiter(opts);
156
+ const inner = opts.doer ?? unlimitedDoer();
157
+ const now = opts.now ?? Date.now;
158
+ const floor = opts.retryFloor ?? DOER_RETRY_FLOOR;
159
+ const keyOf = (o) => typeof opts.key === "function" ? opts.key(o ?? {}) : opts.key ?? "doer";
160
+ const pending = [];
161
+ const idle = [];
162
+ let seq = 0;
163
+ let draining = false;
164
+ let timer = null;
165
+ const arm = (ms) => {
166
+ if (timer) return;
167
+ timer = setTimeout(() => {
168
+ timer = null;
169
+ drain();
170
+ }, Math.max(floor, ms));
171
+ timer.unref?.();
172
+ };
173
+ const drain = async () => {
174
+ if (draining) return;
175
+ draining = true;
176
+ try {
177
+ while (pending.length > 0) {
178
+ if (inner.available() <= 0) {
179
+ arm(floor);
180
+ break;
181
+ }
182
+ let best = 0;
183
+ for (let i = 1; i < pending.length; i++) {
184
+ const a = pending[i];
185
+ const b = pending[best];
186
+ if (a.createdAt < b.createdAt || a.createdAt === b.createdAt && a.seq < b.seq) best = i;
187
+ }
188
+ const task = pending[best];
189
+ let res;
190
+ try {
191
+ res = await lim.check(keyOf(task.opts), now());
192
+ } catch (err) {
193
+ try {
194
+ opts.onError?.(err);
195
+ } catch {}
196
+ arm(floor);
197
+ break;
198
+ }
199
+ if (!res.allowed) {
200
+ arm(res.retryAfter);
201
+ break;
202
+ }
203
+ pending.splice(best, 1);
204
+ inner.do(task.run, task.opts);
205
+ }
206
+ if (pending.length === 0) for (const r of idle.splice(0)) r();
207
+ } finally {
208
+ draining = false;
209
+ }
210
+ };
211
+ return {
212
+ available: () => Math.min(Math.max(0, lim.rule.limit - pending.length), inner.available()),
213
+ do(task, o) {
214
+ pending.push({
215
+ run: task,
216
+ opts: o,
217
+ createdAt: o?.createdAt ?? now(),
218
+ seq: seq++
219
+ });
220
+ drain();
221
+ },
222
+ done: () => pending.length === 0 ? inner.done() : new Promise((r) => idle.push(() => void inner.done().then(r)))
223
+ };
224
+ }
225
+ function fixedWindow(counters, key, rule, now) {
226
+ let e = counters.get(key);
227
+ if (!e || e.reset <= now) {
228
+ e = {
229
+ count: 0,
230
+ reset: now + rule.window
231
+ };
232
+ counters.set(key, e);
233
+ }
234
+ const allowed = e.count < rule.limit;
235
+ if (allowed || rule.countRejected) e.count++;
236
+ const reset = e.reset - now;
237
+ return {
238
+ allowed,
239
+ limit: rule.limit,
240
+ remaining: Math.max(0, rule.limit - e.count),
241
+ reset,
242
+ retryAfter: allowed ? 0 : reset
243
+ };
244
+ }
245
+ function slidingWindow(counters, key, rule, now) {
246
+ const windowStart = Math.floor(now / rule.window) * rule.window;
247
+ const curKey = `${key}|${windowStart}`;
248
+ const prevKey = `${key}|${windowStart - rule.window}`;
249
+ let cur = counters.get(curKey);
250
+ if (!cur || cur.reset <= now) {
251
+ cur = {
252
+ count: 0,
253
+ reset: windowStart + rule.window
254
+ };
255
+ counters.set(curKey, cur);
256
+ }
257
+ const prevCount = counters.get(prevKey)?.count ?? 0;
258
+ const weight = (rule.window - (now - windowStart)) / rule.window;
259
+ const allowed = prevCount * weight + cur.count + 1 <= rule.limit;
260
+ if (allowed || rule.countRejected) cur.count++;
261
+ const weighted = prevCount * weight + cur.count;
262
+ const reset = windowStart + rule.window - now;
263
+ return {
264
+ allowed,
265
+ limit: rule.limit,
266
+ remaining: Math.max(0, Math.floor(rule.limit - weighted)),
267
+ reset,
268
+ retryAfter: allowed ? 0 : reset
269
+ };
270
+ }
271
+ function tokenBucket(buckets, key, rule, now) {
272
+ const cap = rule.limit;
273
+ const refillPerMs = rule.limit / rule.window;
274
+ let b = buckets.get(key);
275
+ if (!b) b = {
276
+ tokens: cap,
277
+ ts: now
278
+ };
279
+ b.tokens = Math.min(cap, b.tokens + (now - b.ts) * refillPerMs);
280
+ b.ts = now;
281
+ const cost = TOKEN_COST;
282
+ let allowed = false;
283
+ if (b.tokens >= cost) {
284
+ b.tokens -= cost;
285
+ allowed = true;
286
+ }
287
+ buckets.set(key, b);
288
+ const remaining = Math.floor(b.tokens);
289
+ const retryAfter = allowed ? 0 : Math.ceil((cost - b.tokens) / refillPerMs);
290
+ const reset = Math.ceil((cap - b.tokens) / refillPerMs);
291
+ return {
292
+ allowed,
293
+ limit: cap,
294
+ remaining,
295
+ reset,
296
+ retryAfter
297
+ };
298
+ }
299
+ /**
300
+ * An in-process {@link RateLimitStore} implementing all three algorithms. The
301
+ * default store — fine for a single instance; use a distributed store (e.g.
302
+ * `@ayepi/rate/redis`) to share limits across pods. Expired entries are swept
303
+ * lazily.
304
+ */
305
+ function memoryStore() {
306
+ const counters = /* @__PURE__ */ new Map();
307
+ const buckets = /* @__PURE__ */ new Map();
308
+ let ops = 0;
309
+ const sweep = (now) => {
310
+ if (++ops % SWEEP_EVERY !== 0) return;
311
+ for (const [k, e] of counters) if (e.reset <= now) counters.delete(k);
312
+ for (const [k, b] of buckets) if (now - b.ts > BUCKET_IDLE_MS) buckets.delete(k);
313
+ };
314
+ return {
315
+ consume(key, rule, now) {
316
+ sweep(now);
317
+ switch (rule.algorithm) {
318
+ case "sliding-window": return slidingWindow(counters, key, rule, now);
319
+ case "token-bucket": return tokenBucket(buckets, key, rule, now);
320
+ default: return fixedWindow(counters, key, rule, now);
321
+ }
322
+ },
323
+ reset(key) {
324
+ counters.delete(key);
325
+ for (const k of counters.keys()) if (k.startsWith(`${key}|`)) counters.delete(k);
326
+ buckets.delete(key);
327
+ }
328
+ };
329
+ }
330
+ //#endregion
331
+ export { limiter, memoryStore, rateLimit, rateLimitHeaders, rateLimitResponse, rateLimitedDoer };
package/dist/redis.cjs ADDED
@@ -0,0 +1,103 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/redis.ts
3
+ /** Tokens consumed per request (token-bucket). */
4
+ const TOKEN_COST = 1;
5
+ /** Keep bucketed keys for this many windows so the previous bucket is still readable. */
6
+ const WINDOW_TTL_FACTOR = 2;
7
+ /** The Lua scripts return 1 for an allowed request. */
8
+ const LUA_ALLOWED = 1;
9
+ const FIXED = `
10
+ local c = redis.call('INCR', KEYS[1])
11
+ if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
12
+ if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('DECR', KEYS[1]) end
13
+ return {c, redis.call('PTTL', KEYS[1])}`;
14
+ const SLIDING = `
15
+ local cur = redis.call('INCR', KEYS[1])
16
+ if cur == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
17
+ local prev = redis.call('GET', KEYS[2])
18
+ prev = prev and tonumber(prev) or 0
19
+ if prev * tonumber(ARGV[2]) + cur > tonumber(ARGV[3]) and ARGV[4] ~= '1' then redis.call('DECR', KEYS[1]) end
20
+ return {cur, prev}`;
21
+ const TOKEN = `
22
+ local d = redis.call('HMGET', KEYS[1], 't', 's')
23
+ local tokens = tonumber(d[1])
24
+ local ts = tonumber(d[2])
25
+ local cap = tonumber(ARGV[1])
26
+ local rate = tonumber(ARGV[2])
27
+ local now = tonumber(ARGV[3])
28
+ local ttl = tonumber(ARGV[4])
29
+ local cost = tonumber(ARGV[5])
30
+ if tokens == nil then tokens = cap; ts = now end
31
+ tokens = math.min(cap, tokens + (now - ts) * rate)
32
+ local allowed = 0
33
+ if tokens >= cost then tokens = tokens - cost; allowed = 1 end
34
+ redis.call('HSET', KEYS[1], 't', tokens, 's', now)
35
+ redis.call('PEXPIRE', KEYS[1], ttl)
36
+ return {allowed, tostring(tokens)}`;
37
+ const num = (v) => typeof v === "number" ? v : Number(v);
38
+ /**
39
+ * Create a Redis-backed {@link RateLimitStore}.
40
+ *
41
+ * @param client - an ioredis connection (used to run the limiter Lua scripts).
42
+ */
43
+ function redisStore(client, opts = {}) {
44
+ const ns = opts.prefix ?? "";
45
+ return {
46
+ async consume(key, rule, now) {
47
+ const k = ns + key;
48
+ if (rule.algorithm === "token-bucket") {
49
+ const cap = rule.limit;
50
+ const rate = rule.limit / rule.window;
51
+ const ttl = Math.ceil(rule.window * WINDOW_TTL_FACTOR);
52
+ const res = await client.eval(TOKEN, 1, k, cap, rate, now, ttl, TOKEN_COST);
53
+ const allowed = num(res[0]) === LUA_ALLOWED;
54
+ const tokens = num(res[1]);
55
+ const retryAfter = allowed ? 0 : Math.ceil((TOKEN_COST - tokens) / rate);
56
+ const reset = Math.ceil((cap - tokens) / rate);
57
+ return {
58
+ allowed,
59
+ limit: cap,
60
+ remaining: Math.floor(tokens),
61
+ reset,
62
+ retryAfter
63
+ };
64
+ }
65
+ const countRejected = rule.countRejected ? "1" : "0";
66
+ if (rule.algorithm === "sliding-window") {
67
+ const windowStart = Math.floor(now / rule.window) * rule.window;
68
+ const curKey = `${k}|${windowStart}`;
69
+ const prevKey = `${k}|${windowStart - rule.window}`;
70
+ const weight = (rule.window - (now - windowStart)) / rule.window;
71
+ const res = await client.eval(SLIDING, 2, curKey, prevKey, rule.window * WINDOW_TTL_FACTOR, weight, rule.limit, countRejected);
72
+ const cur = num(res[0]);
73
+ const weighted = num(res[1]) * weight + cur;
74
+ const allowed = weighted <= rule.limit;
75
+ const reset = windowStart + rule.window - now;
76
+ return {
77
+ allowed,
78
+ limit: rule.limit,
79
+ remaining: Math.max(0, Math.floor(rule.limit - weighted)),
80
+ reset,
81
+ retryAfter: allowed ? 0 : reset
82
+ };
83
+ }
84
+ const res = await client.eval(FIXED, 1, k, rule.window, rule.limit, countRejected);
85
+ const count = num(res[0]);
86
+ const ttl = num(res[1]);
87
+ const allowed = count <= rule.limit;
88
+ const reset = ttl >= 0 ? ttl : rule.window;
89
+ return {
90
+ allowed,
91
+ limit: rule.limit,
92
+ remaining: Math.max(0, rule.limit - count),
93
+ reset,
94
+ retryAfter: allowed ? 0 : reset
95
+ };
96
+ },
97
+ async reset(key) {
98
+ await client.eval(`return redis.call('DEL', KEYS[1])`, 1, ns + key);
99
+ }
100
+ };
101
+ }
102
+ //#endregion
103
+ exports.redisStore = redisStore;
@@ -0,0 +1,21 @@
1
+ import { RateLimitStore } from "./index.cjs";
2
+
3
+ //#region src/redis.d.ts
4
+
5
+ /** The minimal ioredis surface this store uses (ioredis's `Redis` satisfies it). */
6
+ interface RedisEvalLike {
7
+ eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
8
+ }
9
+ /** Options for {@link redisStore}. */
10
+ interface RedisStoreOptions {
11
+ /** Extra key namespace prepended to every key (default `''`). */
12
+ readonly prefix?: string;
13
+ }
14
+ /**
15
+ * Create a Redis-backed {@link RateLimitStore}.
16
+ *
17
+ * @param client - an ioredis connection (used to run the limiter Lua scripts).
18
+ */
19
+ declare function redisStore(client: RedisEvalLike, opts?: RedisStoreOptions): RateLimitStore;
20
+ //#endregion
21
+ export { RedisEvalLike, RedisStoreOptions, redisStore };
@@ -0,0 +1,21 @@
1
+ import { RateLimitStore } from "./index.js";
2
+
3
+ //#region src/redis.d.ts
4
+
5
+ /** The minimal ioredis surface this store uses (ioredis's `Redis` satisfies it). */
6
+ interface RedisEvalLike {
7
+ eval(script: string, numKeys: number, ...args: (string | number)[]): Promise<unknown>;
8
+ }
9
+ /** Options for {@link redisStore}. */
10
+ interface RedisStoreOptions {
11
+ /** Extra key namespace prepended to every key (default `''`). */
12
+ readonly prefix?: string;
13
+ }
14
+ /**
15
+ * Create a Redis-backed {@link RateLimitStore}.
16
+ *
17
+ * @param client - an ioredis connection (used to run the limiter Lua scripts).
18
+ */
19
+ declare function redisStore(client: RedisEvalLike, opts?: RedisStoreOptions): RateLimitStore;
20
+ //#endregion
21
+ export { RedisEvalLike, RedisStoreOptions, redisStore };
package/dist/redis.js ADDED
@@ -0,0 +1,102 @@
1
+ //#region src/redis.ts
2
+ /** Tokens consumed per request (token-bucket). */
3
+ const TOKEN_COST = 1;
4
+ /** Keep bucketed keys for this many windows so the previous bucket is still readable. */
5
+ const WINDOW_TTL_FACTOR = 2;
6
+ /** The Lua scripts return 1 for an allowed request. */
7
+ const LUA_ALLOWED = 1;
8
+ const FIXED = `
9
+ local c = redis.call('INCR', KEYS[1])
10
+ if c == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
11
+ if c > tonumber(ARGV[2]) and ARGV[3] ~= '1' then redis.call('DECR', KEYS[1]) end
12
+ return {c, redis.call('PTTL', KEYS[1])}`;
13
+ const SLIDING = `
14
+ local cur = redis.call('INCR', KEYS[1])
15
+ if cur == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
16
+ local prev = redis.call('GET', KEYS[2])
17
+ prev = prev and tonumber(prev) or 0
18
+ if prev * tonumber(ARGV[2]) + cur > tonumber(ARGV[3]) and ARGV[4] ~= '1' then redis.call('DECR', KEYS[1]) end
19
+ return {cur, prev}`;
20
+ const TOKEN = `
21
+ local d = redis.call('HMGET', KEYS[1], 't', 's')
22
+ local tokens = tonumber(d[1])
23
+ local ts = tonumber(d[2])
24
+ local cap = tonumber(ARGV[1])
25
+ local rate = tonumber(ARGV[2])
26
+ local now = tonumber(ARGV[3])
27
+ local ttl = tonumber(ARGV[4])
28
+ local cost = tonumber(ARGV[5])
29
+ if tokens == nil then tokens = cap; ts = now end
30
+ tokens = math.min(cap, tokens + (now - ts) * rate)
31
+ local allowed = 0
32
+ if tokens >= cost then tokens = tokens - cost; allowed = 1 end
33
+ redis.call('HSET', KEYS[1], 't', tokens, 's', now)
34
+ redis.call('PEXPIRE', KEYS[1], ttl)
35
+ return {allowed, tostring(tokens)}`;
36
+ const num = (v) => typeof v === "number" ? v : Number(v);
37
+ /**
38
+ * Create a Redis-backed {@link RateLimitStore}.
39
+ *
40
+ * @param client - an ioredis connection (used to run the limiter Lua scripts).
41
+ */
42
+ function redisStore(client, opts = {}) {
43
+ const ns = opts.prefix ?? "";
44
+ return {
45
+ async consume(key, rule, now) {
46
+ const k = ns + key;
47
+ if (rule.algorithm === "token-bucket") {
48
+ const cap = rule.limit;
49
+ const rate = rule.limit / rule.window;
50
+ const ttl = Math.ceil(rule.window * WINDOW_TTL_FACTOR);
51
+ const res = await client.eval(TOKEN, 1, k, cap, rate, now, ttl, TOKEN_COST);
52
+ const allowed = num(res[0]) === LUA_ALLOWED;
53
+ const tokens = num(res[1]);
54
+ const retryAfter = allowed ? 0 : Math.ceil((TOKEN_COST - tokens) / rate);
55
+ const reset = Math.ceil((cap - tokens) / rate);
56
+ return {
57
+ allowed,
58
+ limit: cap,
59
+ remaining: Math.floor(tokens),
60
+ reset,
61
+ retryAfter
62
+ };
63
+ }
64
+ const countRejected = rule.countRejected ? "1" : "0";
65
+ if (rule.algorithm === "sliding-window") {
66
+ const windowStart = Math.floor(now / rule.window) * rule.window;
67
+ const curKey = `${k}|${windowStart}`;
68
+ const prevKey = `${k}|${windowStart - rule.window}`;
69
+ const weight = (rule.window - (now - windowStart)) / rule.window;
70
+ const res = await client.eval(SLIDING, 2, curKey, prevKey, rule.window * WINDOW_TTL_FACTOR, weight, rule.limit, countRejected);
71
+ const cur = num(res[0]);
72
+ const weighted = num(res[1]) * weight + cur;
73
+ const allowed = weighted <= rule.limit;
74
+ const reset = windowStart + rule.window - now;
75
+ return {
76
+ allowed,
77
+ limit: rule.limit,
78
+ remaining: Math.max(0, Math.floor(rule.limit - weighted)),
79
+ reset,
80
+ retryAfter: allowed ? 0 : reset
81
+ };
82
+ }
83
+ const res = await client.eval(FIXED, 1, k, rule.window, rule.limit, countRejected);
84
+ const count = num(res[0]);
85
+ const ttl = num(res[1]);
86
+ const allowed = count <= rule.limit;
87
+ const reset = ttl >= 0 ? ttl : rule.window;
88
+ return {
89
+ allowed,
90
+ limit: rule.limit,
91
+ remaining: Math.max(0, rule.limit - count),
92
+ reset,
93
+ retryAfter: allowed ? 0 : reset
94
+ };
95
+ },
96
+ async reset(key) {
97
+ await client.eval(`return redis.call('DEL', KEYS[1])`, 1, ns + key);
98
+ }
99
+ };
100
+ }
101
+ //#endregion
102
+ export { redisStore };
@@ -0,0 +1,66 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_index = require("./index.cjs");
3
+ //#region src/server.ts
4
+ /** Bind a {@link rateLimit} def to its runtime policy. */
5
+ function rateLimitServer(def, opts) {
6
+ const lim = require_index.limiter(opts);
7
+ const message = opts.message;
8
+ const run = async (io) => {
9
+ const kio = {
10
+ req: io.req,
11
+ ctx: io.ctx
12
+ };
13
+ const advertise = (info) => {
14
+ if (!opts.alwaysHeaders) return;
15
+ for (const [name, value] of Object.entries(require_index.rateLimitHeaders(info, opts.headers))) io.setHeader(name, value);
16
+ };
17
+ const fullBudget = () => ({
18
+ limit: lim.rule.limit,
19
+ remaining: lim.rule.limit,
20
+ reset: 0,
21
+ retryAfter: 0
22
+ });
23
+ if (opts.skip?.(kio)) {
24
+ const skipped = fullBudget();
25
+ advertise(skipped);
26
+ return io.next({ ratelimit: skipped });
27
+ }
28
+ let result;
29
+ try {
30
+ result = await lim.check(opts.key(kio));
31
+ } catch (err) {
32
+ try {
33
+ opts.onError?.(err);
34
+ } catch {}
35
+ if (!opts.failOpen) throw err;
36
+ const allowed = fullBudget();
37
+ advertise(allowed);
38
+ return io.next({ ratelimit: allowed });
39
+ }
40
+ const info = {
41
+ limit: result.limit,
42
+ remaining: result.remaining,
43
+ reset: result.reset,
44
+ retryAfter: result.retryAfter
45
+ };
46
+ if (!result.allowed) return require_index.rateLimitResponse(info, {
47
+ status: opts.status,
48
+ headers: opts.headers,
49
+ message: typeof message === "function" ? (i) => message(i, kio) : message
50
+ });
51
+ advertise(info);
52
+ return io.next({ ratelimit: info });
53
+ };
54
+ return {
55
+ def,
56
+ impl: run
57
+ };
58
+ }
59
+ /**
60
+ * The {@link rateLimit} def factory, augmented with a `.server(def, opts)` binder.
61
+ * Import from `@ayepi/rate/server` in your server entry to bind a def created in a
62
+ * frontend-safe spec.
63
+ */
64
+ const rateLimit = Object.assign(require_index.rateLimit, { server: rateLimitServer });
65
+ //#endregion
66
+ exports.rateLimit = rateLimit;