@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 +21 -0
- package/README.md +151 -0
- package/dist/index.cjs +337 -0
- package/dist/index.d.cts +189 -0
- package/dist/index.d.ts +189 -0
- package/dist/index.js +331 -0
- package/dist/redis.cjs +103 -0
- package/dist/redis.d.cts +21 -0
- package/dist/redis.d.ts +21 -0
- package/dist/redis.js +102 -0
- package/dist/server.cjs +66 -0
- package/dist/server.d.cts +67 -0
- package/dist/server.d.ts +67 -0
- package/dist/server.js +65 -0
- package/package.json +94 -0
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;
|
package/dist/redis.d.cts
ADDED
|
@@ -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 };
|
package/dist/redis.d.ts
ADDED
|
@@ -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 };
|
package/dist/server.cjs
ADDED
|
@@ -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;
|