@joint-ops/hitlimit-bun 1.1.1 → 1.1.3
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 +22 -37
- package/dist/core/limiter.d.ts.map +1 -1
- package/dist/elysia.js +85 -62
- package/dist/hono.js +85 -62
- package/dist/index.js +85 -62
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +42 -54
- package/dist/stores/redis.d.ts.map +1 -1
- package/dist/stores/redis.js +100 -9
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -6,36 +6,24 @@
|
|
|
6
6
|
[](https://www.typescriptlang.org/)
|
|
7
7
|
[](https://bun.sh)
|
|
8
8
|
|
|
9
|
-
> The fastest rate limiter for Bun
|
|
9
|
+
> The fastest rate limiter for Bun — 5.6M+ ops/sec | Bun.serve, Elysia & Hono
|
|
10
10
|
|
|
11
|
-
**hitlimit-bun** is a
|
|
11
|
+
**hitlimit-bun** is a Bun-native rate limiting library. Memory-first with 5.62M ops/sec under real-world load. Atomic Redis Lua scripts for distributed systems. Native bun:sqlite for persistence. Zero runtime dependencies.
|
|
12
12
|
|
|
13
13
|
**[Documentation](https://hitlimit.jointops.dev/docs/bun)** | **[GitHub](https://github.com/JointOps/hitlimit-monorepo)** | **[npm](https://www.npmjs.com/package/@joint-ops/hitlimit-bun)**
|
|
14
14
|
|
|
15
|
-
##
|
|
15
|
+
## Why hitlimit-bun?
|
|
16
16
|
|
|
17
|
-
**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
- **🚀 Memory-First** - 7.29M ops/sec by default (v1.1.0+), 14.6x faster than SQLite
|
|
31
|
-
- **Bun Native** - Built specifically for Bun's runtime, not a Node.js port
|
|
32
|
-
- **Zero Config** - Works out of the box with sensible defaults
|
|
33
|
-
- **Framework Support** - First-class Elysia and Hono integration
|
|
34
|
-
- **Optional Persistence** - SQLite (500K ops/sec) or Redis (6.6K ops/sec) when needed
|
|
35
|
-
- **TypeScript First** - Full type safety and IntelliSense support
|
|
36
|
-
- **Auto-Ban** - Automatically ban repeat offenders after threshold violations
|
|
37
|
-
- **Shared Limits** - Group rate limits via groupId for teams/tenants
|
|
38
|
-
- **Tiny Footprint** - ~18KB core bundle, zero runtime dependencies
|
|
17
|
+
- **5.62M ops/sec** under real-world load (10K IPs), ~15x faster than SQLite
|
|
18
|
+
- **Bun native** — built for Bun's runtime, not a Node.js port
|
|
19
|
+
- **3 frameworks** — Bun.serve, Elysia, Hono from one package
|
|
20
|
+
- **3 storage backends** — Memory, bun:sqlite, Redis (atomic Lua scripts)
|
|
21
|
+
- **Atomic Redis** — Single-roundtrip Lua scripts with EVALSHA caching
|
|
22
|
+
- **Zero runtime dependencies** — nothing extra to install
|
|
23
|
+
- **Human-readable windows** — `'1m'`, `'15m'`, `'1h'` instead of milliseconds
|
|
24
|
+
- **Tiered limits** — Free/Pro/Enterprise in 8 lines
|
|
25
|
+
- **Auto-ban** — Ban repeat offenders after threshold violations
|
|
26
|
+
- **TypeScript native** — Full type safety and IntelliSense
|
|
39
27
|
|
|
40
28
|
## Installation
|
|
41
29
|
|
|
@@ -304,7 +292,7 @@ Bun.serve({
|
|
|
304
292
|
|
|
305
293
|
### Redis Store
|
|
306
294
|
|
|
307
|
-
For distributed systems and multi-server deployments.
|
|
295
|
+
For distributed systems and multi-server deployments. Uses atomic Lua scripts — single-roundtrip with EVALSHA caching.
|
|
308
296
|
|
|
309
297
|
```typescript
|
|
310
298
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
@@ -352,13 +340,13 @@ Retry-After: 42
|
|
|
352
340
|
|
|
353
341
|
hitlimit-bun is optimized for Bun's runtime with native performance:
|
|
354
342
|
|
|
355
|
-
### Store Benchmarks
|
|
343
|
+
### Store Benchmarks
|
|
356
344
|
|
|
357
345
|
| Store | Operations/sec | vs Node.js |
|
|
358
346
|
|-------|----------------|------------|
|
|
359
|
-
| **Memory** |
|
|
360
|
-
| **bun:sqlite** |
|
|
361
|
-
| **Redis** | 6,
|
|
347
|
+
| **Memory** | 5,620,000+ | +68% faster |
|
|
348
|
+
| **bun:sqlite** | 383,000+ | ~same |
|
|
349
|
+
| **Redis** | 6,800+ | ~same |
|
|
362
350
|
|
|
363
351
|
### HTTP Throughput
|
|
364
352
|
|
|
@@ -367,7 +355,7 @@ hitlimit-bun is optimized for Bun's runtime with native performance:
|
|
|
367
355
|
| **Bun.serve** | 105,000 req/s | 12% |
|
|
368
356
|
| **Elysia** | 115,000 req/s | 11% |
|
|
369
357
|
|
|
370
|
-
> **Note:**
|
|
358
|
+
> **Note:** These are our benchmarks and we've done our best to keep them fair and reproducible. Results vary by hardware and environment — clone the repo and run them yourself. They're not set in stone — if you find issues or have suggestions for improvement, please open an issue or PR.
|
|
371
359
|
|
|
372
360
|
### Why bun:sqlite is So Fast
|
|
373
361
|
|
|
@@ -421,7 +409,7 @@ new Elysia()
|
|
|
421
409
|
|
|
422
410
|
## Related Packages
|
|
423
411
|
|
|
424
|
-
- [@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit) - Node.js rate limiting for Express, NestJS
|
|
412
|
+
- [@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit) - Node.js rate limiting for Express, Fastify, Hono, NestJS
|
|
425
413
|
|
|
426
414
|
## Why Not Use Node.js Rate Limiters in Bun?
|
|
427
415
|
|
|
@@ -429,14 +417,11 @@ Node.js rate limiters like express-rate-limit use better-sqlite3 which relies on
|
|
|
429
417
|
|
|
430
418
|
**hitlimit-bun** is built specifically for Bun:
|
|
431
419
|
- Uses native `bun:sqlite` (no N-API overhead)
|
|
420
|
+
- Atomic Redis Lua scripts for distributed deployments
|
|
432
421
|
- No FFI overhead or Node.js polyfills
|
|
433
|
-
- First-class Elysia
|
|
434
|
-
- Optimized for Bun.serve's request handling
|
|
422
|
+
- First-class Bun.serve, Elysia, and Hono support
|
|
435
423
|
|
|
436
424
|
## License
|
|
437
425
|
|
|
438
426
|
MIT - Use freely in personal and commercial projects.
|
|
439
427
|
|
|
440
|
-
## Keywords
|
|
441
|
-
|
|
442
|
-
bun rate limit, bun rate limiter, bun middleware, bun api, bun server, bun serve, bun framework, bun native, bun sqlite, elysia rate limit, elysia plugin, elysia middleware, elysia throttle, elysia framework, api rate limiting, throttle requests, request throttling, bun api protection, ddos protection, brute force protection, login protection, redis rate limit, high performance rate limit, fast rate limiter, sliding window, fixed window, rate-limiter-flexible bun, express-rate-limit bun, bun http, bun backend, bun rest api
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"limiter.d.ts","sourceRoot":"","sources":["../../src/core/limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAM7F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB;AAsCD,wBAAsB,cAAc,CAAC,QAAQ,EAC3C,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,UAAU,CAAC,
|
|
1
|
+
{"version":3,"file":"limiter.d.ts","sourceRoot":"","sources":["../../src/core/limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAM7F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB;AAsCD,wBAAsB,cAAc,CAAC,QAAQ,EAC3C,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,UAAU,CAAC,CAgErB;AAGD,wBAAsB,UAAU,CAAC,QAAQ,EACvC,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,cAAc,CAAC,CA2GzB"}
|
package/dist/elysia.js
CHANGED
|
@@ -5,46 +5,62 @@ class MemoryStore {
|
|
|
5
5
|
hits = new Map;
|
|
6
6
|
bans = new Map;
|
|
7
7
|
violations = new Map;
|
|
8
|
+
_result = { count: 0, resetAt: 0 };
|
|
9
|
+
sweepInterval;
|
|
10
|
+
constructor() {
|
|
11
|
+
this.sweepInterval = setInterval(() => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [key, entry] of this.hits) {
|
|
14
|
+
if (now >= entry.resetAt)
|
|
15
|
+
this.hits.delete(key);
|
|
16
|
+
}
|
|
17
|
+
for (const [key, ban] of this.bans) {
|
|
18
|
+
if (now >= ban.expiresAt)
|
|
19
|
+
this.bans.delete(key);
|
|
20
|
+
}
|
|
21
|
+
for (const [key, violation] of this.violations) {
|
|
22
|
+
if (now >= violation.resetAt)
|
|
23
|
+
this.violations.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}, 1e4);
|
|
26
|
+
if (typeof this.sweepInterval.unref === "function") {
|
|
27
|
+
this.sweepInterval.unref();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
8
30
|
hit(key, windowMs, _limit) {
|
|
9
31
|
const entry = this.hits.get(key);
|
|
10
32
|
if (entry !== undefined) {
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
const now2 = Date.now();
|
|
34
|
+
if (now2 >= entry.resetAt) {
|
|
35
|
+
entry.count = 1;
|
|
36
|
+
entry.resetAt = now2 + windowMs;
|
|
37
|
+
} else {
|
|
38
|
+
entry.count++;
|
|
39
|
+
}
|
|
40
|
+
this._result.count = entry.count;
|
|
41
|
+
this._result.resetAt = entry.resetAt;
|
|
42
|
+
return this._result;
|
|
13
43
|
}
|
|
14
44
|
const now = Date.now();
|
|
15
45
|
const resetAt = now + windowMs;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
timeoutId.unref();
|
|
21
|
-
}
|
|
22
|
-
this.hits.set(key, { count: 1, resetAt, timeoutId });
|
|
23
|
-
return { count: 1, resetAt };
|
|
46
|
+
this.hits.set(key, { count: 1, resetAt });
|
|
47
|
+
this._result.count = 1;
|
|
48
|
+
this._result.resetAt = resetAt;
|
|
49
|
+
return this._result;
|
|
24
50
|
}
|
|
25
51
|
isBanned(key) {
|
|
26
52
|
const ban = this.bans.get(key);
|
|
27
53
|
if (!ban)
|
|
28
54
|
return false;
|
|
29
55
|
if (Date.now() >= ban.expiresAt) {
|
|
30
|
-
clearTimeout(ban.timeoutId);
|
|
31
56
|
this.bans.delete(key);
|
|
32
57
|
return false;
|
|
33
58
|
}
|
|
34
59
|
return true;
|
|
35
60
|
}
|
|
36
61
|
ban(key, durationMs) {
|
|
37
|
-
const existing = this.bans.get(key);
|
|
38
|
-
if (existing)
|
|
39
|
-
clearTimeout(existing.timeoutId);
|
|
40
62
|
const expiresAt = Date.now() + durationMs;
|
|
41
|
-
|
|
42
|
-
this.bans.delete(key);
|
|
43
|
-
}, durationMs);
|
|
44
|
-
if (typeof timeoutId.unref === "function") {
|
|
45
|
-
timeoutId.unref();
|
|
46
|
-
}
|
|
47
|
-
this.bans.set(key, { expiresAt, timeoutId });
|
|
63
|
+
this.bans.set(key, { expiresAt });
|
|
48
64
|
}
|
|
49
65
|
recordViolation(key, windowMs) {
|
|
50
66
|
const entry = this.violations.get(key);
|
|
@@ -52,47 +68,19 @@ class MemoryStore {
|
|
|
52
68
|
entry.count++;
|
|
53
69
|
return entry.count;
|
|
54
70
|
}
|
|
55
|
-
if (entry)
|
|
56
|
-
clearTimeout(entry.timeoutId);
|
|
57
71
|
const resetAt = Date.now() + windowMs;
|
|
58
|
-
|
|
59
|
-
this.violations.delete(key);
|
|
60
|
-
}, windowMs);
|
|
61
|
-
if (typeof timeoutId.unref === "function") {
|
|
62
|
-
timeoutId.unref();
|
|
63
|
-
}
|
|
64
|
-
this.violations.set(key, { count: 1, resetAt, timeoutId });
|
|
72
|
+
this.violations.set(key, { count: 1, resetAt });
|
|
65
73
|
return 1;
|
|
66
74
|
}
|
|
67
75
|
reset(key) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.hits.delete(key);
|
|
72
|
-
}
|
|
73
|
-
const ban = this.bans.get(key);
|
|
74
|
-
if (ban) {
|
|
75
|
-
clearTimeout(ban.timeoutId);
|
|
76
|
-
this.bans.delete(key);
|
|
77
|
-
}
|
|
78
|
-
const violation = this.violations.get(key);
|
|
79
|
-
if (violation) {
|
|
80
|
-
clearTimeout(violation.timeoutId);
|
|
81
|
-
this.violations.delete(key);
|
|
82
|
-
}
|
|
76
|
+
this.hits.delete(key);
|
|
77
|
+
this.bans.delete(key);
|
|
78
|
+
this.violations.delete(key);
|
|
83
79
|
}
|
|
84
80
|
shutdown() {
|
|
85
|
-
|
|
86
|
-
clearTimeout(entry.timeoutId);
|
|
87
|
-
}
|
|
81
|
+
clearInterval(this.sweepInterval);
|
|
88
82
|
this.hits.clear();
|
|
89
|
-
for (const [, entry] of this.bans) {
|
|
90
|
-
clearTimeout(entry.timeoutId);
|
|
91
|
-
}
|
|
92
83
|
this.bans.clear();
|
|
93
|
-
for (const [, entry] of this.violations) {
|
|
94
|
-
clearTimeout(entry.timeoutId);
|
|
95
|
-
}
|
|
96
84
|
this.violations.clear();
|
|
97
85
|
}
|
|
98
86
|
}
|
|
@@ -214,6 +202,49 @@ async function checkLimit(config, req) {
|
|
|
214
202
|
tierName = await config.tier(req);
|
|
215
203
|
}
|
|
216
204
|
const { limit, windowMs } = resolveTier(config, tierName);
|
|
205
|
+
if (limit === Infinity) {
|
|
206
|
+
return {
|
|
207
|
+
allowed: true,
|
|
208
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
209
|
+
headers: {},
|
|
210
|
+
body: {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (config.ban && config.store.hitWithBan) {
|
|
214
|
+
const r = await config.store.hitWithBan(key, windowMs, limit, config.ban.threshold, config.ban.durationMs);
|
|
215
|
+
if (r.banned && r.count === 0) {
|
|
216
|
+
const info3 = {
|
|
217
|
+
limit,
|
|
218
|
+
remaining: 0,
|
|
219
|
+
resetIn: Math.ceil((r.banExpiresAt - Date.now()) / 1000),
|
|
220
|
+
resetAt: r.banExpiresAt,
|
|
221
|
+
key,
|
|
222
|
+
tier: tierName,
|
|
223
|
+
banned: true,
|
|
224
|
+
banExpiresAt: r.banExpiresAt,
|
|
225
|
+
group: groupId
|
|
226
|
+
};
|
|
227
|
+
return { allowed: false, info: info3, headers: buildHeaders(info3, config.headers, false), body: buildBody(config.response, info3) };
|
|
228
|
+
}
|
|
229
|
+
const now2 = Date.now();
|
|
230
|
+
const allowed2 = r.count <= limit;
|
|
231
|
+
const info2 = {
|
|
232
|
+
limit,
|
|
233
|
+
remaining: Math.max(0, limit - r.count),
|
|
234
|
+
resetIn: Math.max(0, Math.ceil((r.resetAt - now2) / 1000)),
|
|
235
|
+
resetAt: r.resetAt,
|
|
236
|
+
key,
|
|
237
|
+
tier: tierName,
|
|
238
|
+
group: groupId
|
|
239
|
+
};
|
|
240
|
+
if (r.violations > 0)
|
|
241
|
+
info2.violations = r.violations;
|
|
242
|
+
if (r.banned) {
|
|
243
|
+
info2.banned = true;
|
|
244
|
+
info2.banExpiresAt = r.banExpiresAt;
|
|
245
|
+
}
|
|
246
|
+
return { allowed: allowed2, info: info2, headers: buildHeaders(info2, config.headers, allowed2), body: allowed2 ? {} : buildBody(config.response, info2) };
|
|
247
|
+
}
|
|
217
248
|
if (config.ban && config.store.isBanned) {
|
|
218
249
|
const banned = await config.store.isBanned(key);
|
|
219
250
|
if (banned) {
|
|
@@ -237,14 +268,6 @@ async function checkLimit(config, req) {
|
|
|
237
268
|
};
|
|
238
269
|
}
|
|
239
270
|
}
|
|
240
|
-
if (limit === Infinity) {
|
|
241
|
-
return {
|
|
242
|
-
allowed: true,
|
|
243
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
244
|
-
headers: {},
|
|
245
|
-
body: {}
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
271
|
const result = await config.store.hit(key, windowMs, limit);
|
|
249
272
|
const now = Date.now();
|
|
250
273
|
const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
|
package/dist/hono.js
CHANGED
|
@@ -5,46 +5,62 @@ class MemoryStore {
|
|
|
5
5
|
hits = new Map;
|
|
6
6
|
bans = new Map;
|
|
7
7
|
violations = new Map;
|
|
8
|
+
_result = { count: 0, resetAt: 0 };
|
|
9
|
+
sweepInterval;
|
|
10
|
+
constructor() {
|
|
11
|
+
this.sweepInterval = setInterval(() => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [key, entry] of this.hits) {
|
|
14
|
+
if (now >= entry.resetAt)
|
|
15
|
+
this.hits.delete(key);
|
|
16
|
+
}
|
|
17
|
+
for (const [key, ban] of this.bans) {
|
|
18
|
+
if (now >= ban.expiresAt)
|
|
19
|
+
this.bans.delete(key);
|
|
20
|
+
}
|
|
21
|
+
for (const [key, violation] of this.violations) {
|
|
22
|
+
if (now >= violation.resetAt)
|
|
23
|
+
this.violations.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}, 1e4);
|
|
26
|
+
if (typeof this.sweepInterval.unref === "function") {
|
|
27
|
+
this.sweepInterval.unref();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
8
30
|
hit(key, windowMs, _limit) {
|
|
9
31
|
const entry = this.hits.get(key);
|
|
10
32
|
if (entry !== undefined) {
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
const now2 = Date.now();
|
|
34
|
+
if (now2 >= entry.resetAt) {
|
|
35
|
+
entry.count = 1;
|
|
36
|
+
entry.resetAt = now2 + windowMs;
|
|
37
|
+
} else {
|
|
38
|
+
entry.count++;
|
|
39
|
+
}
|
|
40
|
+
this._result.count = entry.count;
|
|
41
|
+
this._result.resetAt = entry.resetAt;
|
|
42
|
+
return this._result;
|
|
13
43
|
}
|
|
14
44
|
const now = Date.now();
|
|
15
45
|
const resetAt = now + windowMs;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
timeoutId.unref();
|
|
21
|
-
}
|
|
22
|
-
this.hits.set(key, { count: 1, resetAt, timeoutId });
|
|
23
|
-
return { count: 1, resetAt };
|
|
46
|
+
this.hits.set(key, { count: 1, resetAt });
|
|
47
|
+
this._result.count = 1;
|
|
48
|
+
this._result.resetAt = resetAt;
|
|
49
|
+
return this._result;
|
|
24
50
|
}
|
|
25
51
|
isBanned(key) {
|
|
26
52
|
const ban = this.bans.get(key);
|
|
27
53
|
if (!ban)
|
|
28
54
|
return false;
|
|
29
55
|
if (Date.now() >= ban.expiresAt) {
|
|
30
|
-
clearTimeout(ban.timeoutId);
|
|
31
56
|
this.bans.delete(key);
|
|
32
57
|
return false;
|
|
33
58
|
}
|
|
34
59
|
return true;
|
|
35
60
|
}
|
|
36
61
|
ban(key, durationMs) {
|
|
37
|
-
const existing = this.bans.get(key);
|
|
38
|
-
if (existing)
|
|
39
|
-
clearTimeout(existing.timeoutId);
|
|
40
62
|
const expiresAt = Date.now() + durationMs;
|
|
41
|
-
|
|
42
|
-
this.bans.delete(key);
|
|
43
|
-
}, durationMs);
|
|
44
|
-
if (typeof timeoutId.unref === "function") {
|
|
45
|
-
timeoutId.unref();
|
|
46
|
-
}
|
|
47
|
-
this.bans.set(key, { expiresAt, timeoutId });
|
|
63
|
+
this.bans.set(key, { expiresAt });
|
|
48
64
|
}
|
|
49
65
|
recordViolation(key, windowMs) {
|
|
50
66
|
const entry = this.violations.get(key);
|
|
@@ -52,47 +68,19 @@ class MemoryStore {
|
|
|
52
68
|
entry.count++;
|
|
53
69
|
return entry.count;
|
|
54
70
|
}
|
|
55
|
-
if (entry)
|
|
56
|
-
clearTimeout(entry.timeoutId);
|
|
57
71
|
const resetAt = Date.now() + windowMs;
|
|
58
|
-
|
|
59
|
-
this.violations.delete(key);
|
|
60
|
-
}, windowMs);
|
|
61
|
-
if (typeof timeoutId.unref === "function") {
|
|
62
|
-
timeoutId.unref();
|
|
63
|
-
}
|
|
64
|
-
this.violations.set(key, { count: 1, resetAt, timeoutId });
|
|
72
|
+
this.violations.set(key, { count: 1, resetAt });
|
|
65
73
|
return 1;
|
|
66
74
|
}
|
|
67
75
|
reset(key) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.hits.delete(key);
|
|
72
|
-
}
|
|
73
|
-
const ban = this.bans.get(key);
|
|
74
|
-
if (ban) {
|
|
75
|
-
clearTimeout(ban.timeoutId);
|
|
76
|
-
this.bans.delete(key);
|
|
77
|
-
}
|
|
78
|
-
const violation = this.violations.get(key);
|
|
79
|
-
if (violation) {
|
|
80
|
-
clearTimeout(violation.timeoutId);
|
|
81
|
-
this.violations.delete(key);
|
|
82
|
-
}
|
|
76
|
+
this.hits.delete(key);
|
|
77
|
+
this.bans.delete(key);
|
|
78
|
+
this.violations.delete(key);
|
|
83
79
|
}
|
|
84
80
|
shutdown() {
|
|
85
|
-
|
|
86
|
-
clearTimeout(entry.timeoutId);
|
|
87
|
-
}
|
|
81
|
+
clearInterval(this.sweepInterval);
|
|
88
82
|
this.hits.clear();
|
|
89
|
-
for (const [, entry] of this.bans) {
|
|
90
|
-
clearTimeout(entry.timeoutId);
|
|
91
|
-
}
|
|
92
83
|
this.bans.clear();
|
|
93
|
-
for (const [, entry] of this.violations) {
|
|
94
|
-
clearTimeout(entry.timeoutId);
|
|
95
|
-
}
|
|
96
84
|
this.violations.clear();
|
|
97
85
|
}
|
|
98
86
|
}
|
|
@@ -214,6 +202,49 @@ async function checkLimit(config, req) {
|
|
|
214
202
|
tierName = await config.tier(req);
|
|
215
203
|
}
|
|
216
204
|
const { limit, windowMs } = resolveTier(config, tierName);
|
|
205
|
+
if (limit === Infinity) {
|
|
206
|
+
return {
|
|
207
|
+
allowed: true,
|
|
208
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
209
|
+
headers: {},
|
|
210
|
+
body: {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (config.ban && config.store.hitWithBan) {
|
|
214
|
+
const r = await config.store.hitWithBan(key, windowMs, limit, config.ban.threshold, config.ban.durationMs);
|
|
215
|
+
if (r.banned && r.count === 0) {
|
|
216
|
+
const info3 = {
|
|
217
|
+
limit,
|
|
218
|
+
remaining: 0,
|
|
219
|
+
resetIn: Math.ceil((r.banExpiresAt - Date.now()) / 1000),
|
|
220
|
+
resetAt: r.banExpiresAt,
|
|
221
|
+
key,
|
|
222
|
+
tier: tierName,
|
|
223
|
+
banned: true,
|
|
224
|
+
banExpiresAt: r.banExpiresAt,
|
|
225
|
+
group: groupId
|
|
226
|
+
};
|
|
227
|
+
return { allowed: false, info: info3, headers: buildHeaders(info3, config.headers, false), body: buildBody(config.response, info3) };
|
|
228
|
+
}
|
|
229
|
+
const now2 = Date.now();
|
|
230
|
+
const allowed2 = r.count <= limit;
|
|
231
|
+
const info2 = {
|
|
232
|
+
limit,
|
|
233
|
+
remaining: Math.max(0, limit - r.count),
|
|
234
|
+
resetIn: Math.max(0, Math.ceil((r.resetAt - now2) / 1000)),
|
|
235
|
+
resetAt: r.resetAt,
|
|
236
|
+
key,
|
|
237
|
+
tier: tierName,
|
|
238
|
+
group: groupId
|
|
239
|
+
};
|
|
240
|
+
if (r.violations > 0)
|
|
241
|
+
info2.violations = r.violations;
|
|
242
|
+
if (r.banned) {
|
|
243
|
+
info2.banned = true;
|
|
244
|
+
info2.banExpiresAt = r.banExpiresAt;
|
|
245
|
+
}
|
|
246
|
+
return { allowed: allowed2, info: info2, headers: buildHeaders(info2, config.headers, allowed2), body: allowed2 ? {} : buildBody(config.response, info2) };
|
|
247
|
+
}
|
|
217
248
|
if (config.ban && config.store.isBanned) {
|
|
218
249
|
const banned = await config.store.isBanned(key);
|
|
219
250
|
if (banned) {
|
|
@@ -237,14 +268,6 @@ async function checkLimit(config, req) {
|
|
|
237
268
|
};
|
|
238
269
|
}
|
|
239
270
|
}
|
|
240
|
-
if (limit === Infinity) {
|
|
241
|
-
return {
|
|
242
|
-
allowed: true,
|
|
243
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
244
|
-
headers: {},
|
|
245
|
-
body: {}
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
271
|
const result = await config.store.hit(key, windowMs, limit);
|
|
249
272
|
const now = Date.now();
|
|
250
273
|
const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
|
package/dist/index.js
CHANGED
|
@@ -5,46 +5,62 @@ class MemoryStore {
|
|
|
5
5
|
hits = new Map;
|
|
6
6
|
bans = new Map;
|
|
7
7
|
violations = new Map;
|
|
8
|
+
_result = { count: 0, resetAt: 0 };
|
|
9
|
+
sweepInterval;
|
|
10
|
+
constructor() {
|
|
11
|
+
this.sweepInterval = setInterval(() => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [key, entry] of this.hits) {
|
|
14
|
+
if (now >= entry.resetAt)
|
|
15
|
+
this.hits.delete(key);
|
|
16
|
+
}
|
|
17
|
+
for (const [key, ban] of this.bans) {
|
|
18
|
+
if (now >= ban.expiresAt)
|
|
19
|
+
this.bans.delete(key);
|
|
20
|
+
}
|
|
21
|
+
for (const [key, violation] of this.violations) {
|
|
22
|
+
if (now >= violation.resetAt)
|
|
23
|
+
this.violations.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}, 1e4);
|
|
26
|
+
if (typeof this.sweepInterval.unref === "function") {
|
|
27
|
+
this.sweepInterval.unref();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
8
30
|
hit(key, windowMs, _limit) {
|
|
9
31
|
const entry = this.hits.get(key);
|
|
10
32
|
if (entry !== undefined) {
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
const now2 = Date.now();
|
|
34
|
+
if (now2 >= entry.resetAt) {
|
|
35
|
+
entry.count = 1;
|
|
36
|
+
entry.resetAt = now2 + windowMs;
|
|
37
|
+
} else {
|
|
38
|
+
entry.count++;
|
|
39
|
+
}
|
|
40
|
+
this._result.count = entry.count;
|
|
41
|
+
this._result.resetAt = entry.resetAt;
|
|
42
|
+
return this._result;
|
|
13
43
|
}
|
|
14
44
|
const now = Date.now();
|
|
15
45
|
const resetAt = now + windowMs;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
timeoutId.unref();
|
|
21
|
-
}
|
|
22
|
-
this.hits.set(key, { count: 1, resetAt, timeoutId });
|
|
23
|
-
return { count: 1, resetAt };
|
|
46
|
+
this.hits.set(key, { count: 1, resetAt });
|
|
47
|
+
this._result.count = 1;
|
|
48
|
+
this._result.resetAt = resetAt;
|
|
49
|
+
return this._result;
|
|
24
50
|
}
|
|
25
51
|
isBanned(key) {
|
|
26
52
|
const ban = this.bans.get(key);
|
|
27
53
|
if (!ban)
|
|
28
54
|
return false;
|
|
29
55
|
if (Date.now() >= ban.expiresAt) {
|
|
30
|
-
clearTimeout(ban.timeoutId);
|
|
31
56
|
this.bans.delete(key);
|
|
32
57
|
return false;
|
|
33
58
|
}
|
|
34
59
|
return true;
|
|
35
60
|
}
|
|
36
61
|
ban(key, durationMs) {
|
|
37
|
-
const existing = this.bans.get(key);
|
|
38
|
-
if (existing)
|
|
39
|
-
clearTimeout(existing.timeoutId);
|
|
40
62
|
const expiresAt = Date.now() + durationMs;
|
|
41
|
-
|
|
42
|
-
this.bans.delete(key);
|
|
43
|
-
}, durationMs);
|
|
44
|
-
if (typeof timeoutId.unref === "function") {
|
|
45
|
-
timeoutId.unref();
|
|
46
|
-
}
|
|
47
|
-
this.bans.set(key, { expiresAt, timeoutId });
|
|
63
|
+
this.bans.set(key, { expiresAt });
|
|
48
64
|
}
|
|
49
65
|
recordViolation(key, windowMs) {
|
|
50
66
|
const entry = this.violations.get(key);
|
|
@@ -52,47 +68,19 @@ class MemoryStore {
|
|
|
52
68
|
entry.count++;
|
|
53
69
|
return entry.count;
|
|
54
70
|
}
|
|
55
|
-
if (entry)
|
|
56
|
-
clearTimeout(entry.timeoutId);
|
|
57
71
|
const resetAt = Date.now() + windowMs;
|
|
58
|
-
|
|
59
|
-
this.violations.delete(key);
|
|
60
|
-
}, windowMs);
|
|
61
|
-
if (typeof timeoutId.unref === "function") {
|
|
62
|
-
timeoutId.unref();
|
|
63
|
-
}
|
|
64
|
-
this.violations.set(key, { count: 1, resetAt, timeoutId });
|
|
72
|
+
this.violations.set(key, { count: 1, resetAt });
|
|
65
73
|
return 1;
|
|
66
74
|
}
|
|
67
75
|
reset(key) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.hits.delete(key);
|
|
72
|
-
}
|
|
73
|
-
const ban = this.bans.get(key);
|
|
74
|
-
if (ban) {
|
|
75
|
-
clearTimeout(ban.timeoutId);
|
|
76
|
-
this.bans.delete(key);
|
|
77
|
-
}
|
|
78
|
-
const violation = this.violations.get(key);
|
|
79
|
-
if (violation) {
|
|
80
|
-
clearTimeout(violation.timeoutId);
|
|
81
|
-
this.violations.delete(key);
|
|
82
|
-
}
|
|
76
|
+
this.hits.delete(key);
|
|
77
|
+
this.bans.delete(key);
|
|
78
|
+
this.violations.delete(key);
|
|
83
79
|
}
|
|
84
80
|
shutdown() {
|
|
85
|
-
|
|
86
|
-
clearTimeout(entry.timeoutId);
|
|
87
|
-
}
|
|
81
|
+
clearInterval(this.sweepInterval);
|
|
88
82
|
this.hits.clear();
|
|
89
|
-
for (const [, entry] of this.bans) {
|
|
90
|
-
clearTimeout(entry.timeoutId);
|
|
91
|
-
}
|
|
92
83
|
this.bans.clear();
|
|
93
|
-
for (const [, entry] of this.violations) {
|
|
94
|
-
clearTimeout(entry.timeoutId);
|
|
95
|
-
}
|
|
96
84
|
this.violations.clear();
|
|
97
85
|
}
|
|
98
86
|
}
|
|
@@ -211,6 +199,49 @@ async function checkLimit(config, req) {
|
|
|
211
199
|
tierName = await config.tier(req);
|
|
212
200
|
}
|
|
213
201
|
const { limit, windowMs } = resolveTier(config, tierName);
|
|
202
|
+
if (limit === Infinity) {
|
|
203
|
+
return {
|
|
204
|
+
allowed: true,
|
|
205
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
206
|
+
headers: {},
|
|
207
|
+
body: {}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (config.ban && config.store.hitWithBan) {
|
|
211
|
+
const r = await config.store.hitWithBan(key, windowMs, limit, config.ban.threshold, config.ban.durationMs);
|
|
212
|
+
if (r.banned && r.count === 0) {
|
|
213
|
+
const info3 = {
|
|
214
|
+
limit,
|
|
215
|
+
remaining: 0,
|
|
216
|
+
resetIn: Math.ceil((r.banExpiresAt - Date.now()) / 1000),
|
|
217
|
+
resetAt: r.banExpiresAt,
|
|
218
|
+
key,
|
|
219
|
+
tier: tierName,
|
|
220
|
+
banned: true,
|
|
221
|
+
banExpiresAt: r.banExpiresAt,
|
|
222
|
+
group: groupId
|
|
223
|
+
};
|
|
224
|
+
return { allowed: false, info: info3, headers: buildHeaders(info3, config.headers, false), body: buildBody(config.response, info3) };
|
|
225
|
+
}
|
|
226
|
+
const now2 = Date.now();
|
|
227
|
+
const allowed2 = r.count <= limit;
|
|
228
|
+
const info2 = {
|
|
229
|
+
limit,
|
|
230
|
+
remaining: Math.max(0, limit - r.count),
|
|
231
|
+
resetIn: Math.max(0, Math.ceil((r.resetAt - now2) / 1000)),
|
|
232
|
+
resetAt: r.resetAt,
|
|
233
|
+
key,
|
|
234
|
+
tier: tierName,
|
|
235
|
+
group: groupId
|
|
236
|
+
};
|
|
237
|
+
if (r.violations > 0)
|
|
238
|
+
info2.violations = r.violations;
|
|
239
|
+
if (r.banned) {
|
|
240
|
+
info2.banned = true;
|
|
241
|
+
info2.banExpiresAt = r.banExpiresAt;
|
|
242
|
+
}
|
|
243
|
+
return { allowed: allowed2, info: info2, headers: buildHeaders(info2, config.headers, allowed2), body: allowed2 ? {} : buildBody(config.response, info2) };
|
|
244
|
+
}
|
|
214
245
|
if (config.ban && config.store.isBanned) {
|
|
215
246
|
const banned = await config.store.isBanned(key);
|
|
216
247
|
if (banned) {
|
|
@@ -234,14 +265,6 @@ async function checkLimit(config, req) {
|
|
|
234
265
|
};
|
|
235
266
|
}
|
|
236
267
|
}
|
|
237
|
-
if (limit === Infinity) {
|
|
238
|
-
return {
|
|
239
|
-
allowed: true,
|
|
240
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
241
|
-
headers: {},
|
|
242
|
-
body: {}
|
|
243
|
-
};
|
|
244
|
-
}
|
|
245
268
|
const result = await config.store.hit(key, windowMs, limit);
|
|
246
269
|
const now = Date.now();
|
|
247
270
|
const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/stores/memory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/stores/memory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AA2G3E,wBAAgB,WAAW,IAAI,aAAa,CAE3C"}
|
package/dist/stores/memory.js
CHANGED
|
@@ -5,46 +5,62 @@ class MemoryStore {
|
|
|
5
5
|
hits = new Map;
|
|
6
6
|
bans = new Map;
|
|
7
7
|
violations = new Map;
|
|
8
|
+
_result = { count: 0, resetAt: 0 };
|
|
9
|
+
sweepInterval;
|
|
10
|
+
constructor() {
|
|
11
|
+
this.sweepInterval = setInterval(() => {
|
|
12
|
+
const now = Date.now();
|
|
13
|
+
for (const [key, entry] of this.hits) {
|
|
14
|
+
if (now >= entry.resetAt)
|
|
15
|
+
this.hits.delete(key);
|
|
16
|
+
}
|
|
17
|
+
for (const [key, ban] of this.bans) {
|
|
18
|
+
if (now >= ban.expiresAt)
|
|
19
|
+
this.bans.delete(key);
|
|
20
|
+
}
|
|
21
|
+
for (const [key, violation] of this.violations) {
|
|
22
|
+
if (now >= violation.resetAt)
|
|
23
|
+
this.violations.delete(key);
|
|
24
|
+
}
|
|
25
|
+
}, 1e4);
|
|
26
|
+
if (typeof this.sweepInterval.unref === "function") {
|
|
27
|
+
this.sweepInterval.unref();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
8
30
|
hit(key, windowMs, _limit) {
|
|
9
31
|
const entry = this.hits.get(key);
|
|
10
32
|
if (entry !== undefined) {
|
|
11
|
-
|
|
12
|
-
|
|
33
|
+
const now2 = Date.now();
|
|
34
|
+
if (now2 >= entry.resetAt) {
|
|
35
|
+
entry.count = 1;
|
|
36
|
+
entry.resetAt = now2 + windowMs;
|
|
37
|
+
} else {
|
|
38
|
+
entry.count++;
|
|
39
|
+
}
|
|
40
|
+
this._result.count = entry.count;
|
|
41
|
+
this._result.resetAt = entry.resetAt;
|
|
42
|
+
return this._result;
|
|
13
43
|
}
|
|
14
44
|
const now = Date.now();
|
|
15
45
|
const resetAt = now + windowMs;
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
timeoutId.unref();
|
|
21
|
-
}
|
|
22
|
-
this.hits.set(key, { count: 1, resetAt, timeoutId });
|
|
23
|
-
return { count: 1, resetAt };
|
|
46
|
+
this.hits.set(key, { count: 1, resetAt });
|
|
47
|
+
this._result.count = 1;
|
|
48
|
+
this._result.resetAt = resetAt;
|
|
49
|
+
return this._result;
|
|
24
50
|
}
|
|
25
51
|
isBanned(key) {
|
|
26
52
|
const ban = this.bans.get(key);
|
|
27
53
|
if (!ban)
|
|
28
54
|
return false;
|
|
29
55
|
if (Date.now() >= ban.expiresAt) {
|
|
30
|
-
clearTimeout(ban.timeoutId);
|
|
31
56
|
this.bans.delete(key);
|
|
32
57
|
return false;
|
|
33
58
|
}
|
|
34
59
|
return true;
|
|
35
60
|
}
|
|
36
61
|
ban(key, durationMs) {
|
|
37
|
-
const existing = this.bans.get(key);
|
|
38
|
-
if (existing)
|
|
39
|
-
clearTimeout(existing.timeoutId);
|
|
40
62
|
const expiresAt = Date.now() + durationMs;
|
|
41
|
-
|
|
42
|
-
this.bans.delete(key);
|
|
43
|
-
}, durationMs);
|
|
44
|
-
if (typeof timeoutId.unref === "function") {
|
|
45
|
-
timeoutId.unref();
|
|
46
|
-
}
|
|
47
|
-
this.bans.set(key, { expiresAt, timeoutId });
|
|
63
|
+
this.bans.set(key, { expiresAt });
|
|
48
64
|
}
|
|
49
65
|
recordViolation(key, windowMs) {
|
|
50
66
|
const entry = this.violations.get(key);
|
|
@@ -52,47 +68,19 @@ class MemoryStore {
|
|
|
52
68
|
entry.count++;
|
|
53
69
|
return entry.count;
|
|
54
70
|
}
|
|
55
|
-
if (entry)
|
|
56
|
-
clearTimeout(entry.timeoutId);
|
|
57
71
|
const resetAt = Date.now() + windowMs;
|
|
58
|
-
|
|
59
|
-
this.violations.delete(key);
|
|
60
|
-
}, windowMs);
|
|
61
|
-
if (typeof timeoutId.unref === "function") {
|
|
62
|
-
timeoutId.unref();
|
|
63
|
-
}
|
|
64
|
-
this.violations.set(key, { count: 1, resetAt, timeoutId });
|
|
72
|
+
this.violations.set(key, { count: 1, resetAt });
|
|
65
73
|
return 1;
|
|
66
74
|
}
|
|
67
75
|
reset(key) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
this.hits.delete(key);
|
|
72
|
-
}
|
|
73
|
-
const ban = this.bans.get(key);
|
|
74
|
-
if (ban) {
|
|
75
|
-
clearTimeout(ban.timeoutId);
|
|
76
|
-
this.bans.delete(key);
|
|
77
|
-
}
|
|
78
|
-
const violation = this.violations.get(key);
|
|
79
|
-
if (violation) {
|
|
80
|
-
clearTimeout(violation.timeoutId);
|
|
81
|
-
this.violations.delete(key);
|
|
82
|
-
}
|
|
76
|
+
this.hits.delete(key);
|
|
77
|
+
this.bans.delete(key);
|
|
78
|
+
this.violations.delete(key);
|
|
83
79
|
}
|
|
84
80
|
shutdown() {
|
|
85
|
-
|
|
86
|
-
clearTimeout(entry.timeoutId);
|
|
87
|
-
}
|
|
81
|
+
clearInterval(this.sweepInterval);
|
|
88
82
|
this.hits.clear();
|
|
89
|
-
for (const [, entry] of this.bans) {
|
|
90
|
-
clearTimeout(entry.timeoutId);
|
|
91
|
-
}
|
|
92
83
|
this.bans.clear();
|
|
93
|
-
for (const [, entry] of this.violations) {
|
|
94
|
-
clearTimeout(entry.timeoutId);
|
|
95
|
-
}
|
|
96
84
|
this.violations.clear();
|
|
97
85
|
}
|
|
98
86
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,2BAA2B,CAAA;AAG7F,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AA6KD,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa,CAErE"}
|
package/dist/stores/redis.js
CHANGED
|
@@ -1,30 +1,121 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/stores/redis.ts
|
|
3
3
|
import Redis from "ioredis";
|
|
4
|
+
var HIT_SCRIPT = `
|
|
5
|
+
local key = KEYS[1]
|
|
6
|
+
local windowMs = tonumber(ARGV[1])
|
|
7
|
+
local count = redis.call('INCR', key)
|
|
8
|
+
local ttl = redis.call('PTTL', key)
|
|
9
|
+
if ttl < 0 then
|
|
10
|
+
redis.call('PEXPIRE', key, windowMs)
|
|
11
|
+
ttl = windowMs
|
|
12
|
+
end
|
|
13
|
+
return {count, ttl}
|
|
14
|
+
`;
|
|
15
|
+
var HIT_WITH_BAN_SCRIPT = `
|
|
16
|
+
local hitKey = KEYS[1]
|
|
17
|
+
local banKey = KEYS[2]
|
|
18
|
+
local violationKey = KEYS[3]
|
|
19
|
+
local windowMs = tonumber(ARGV[1])
|
|
20
|
+
local limit = tonumber(ARGV[2])
|
|
21
|
+
local banThreshold = tonumber(ARGV[3])
|
|
22
|
+
local banDurationMs = tonumber(ARGV[4])
|
|
23
|
+
|
|
24
|
+
-- Check ban first
|
|
25
|
+
local banTTL = redis.call('PTTL', banKey)
|
|
26
|
+
if banTTL > 0 then
|
|
27
|
+
return {-1, banTTL, 1, 0}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
-- Hit counter
|
|
31
|
+
local count = redis.call('INCR', hitKey)
|
|
32
|
+
local ttl = redis.call('PTTL', hitKey)
|
|
33
|
+
if ttl < 0 then
|
|
34
|
+
redis.call('PEXPIRE', hitKey, windowMs)
|
|
35
|
+
ttl = windowMs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
-- Track violations if over limit
|
|
39
|
+
local banned = 0
|
|
40
|
+
local violations = 0
|
|
41
|
+
if count > limit then
|
|
42
|
+
violations = redis.call('INCR', violationKey)
|
|
43
|
+
local vTTL = redis.call('PTTL', violationKey)
|
|
44
|
+
if vTTL < 0 then
|
|
45
|
+
redis.call('PEXPIRE', violationKey, banDurationMs)
|
|
46
|
+
end
|
|
47
|
+
if violations >= banThreshold then
|
|
48
|
+
redis.call('SET', banKey, '1', 'PX', banDurationMs)
|
|
49
|
+
banned = 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
return {count, ttl, banned, violations}
|
|
53
|
+
`;
|
|
4
54
|
|
|
5
55
|
class RedisStore {
|
|
6
56
|
redis;
|
|
7
57
|
prefix;
|
|
8
58
|
banPrefix;
|
|
9
59
|
violationPrefix;
|
|
60
|
+
hitSHA = null;
|
|
61
|
+
hitWithBanSHA = null;
|
|
10
62
|
constructor(options = {}) {
|
|
11
63
|
this.redis = new Redis(options.url ?? "redis://localhost:6379");
|
|
12
64
|
this.prefix = options.keyPrefix ?? "hitlimit:";
|
|
13
65
|
this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
|
|
14
66
|
this.violationPrefix = (options.keyPrefix ?? "hitlimit:") + "violations:";
|
|
15
67
|
}
|
|
68
|
+
async loadScripts() {
|
|
69
|
+
if (!this.hitSHA) {
|
|
70
|
+
this.hitSHA = await this.redis.script("LOAD", HIT_SCRIPT);
|
|
71
|
+
}
|
|
72
|
+
if (!this.hitWithBanSHA) {
|
|
73
|
+
this.hitWithBanSHA = await this.redis.script("LOAD", HIT_WITH_BAN_SCRIPT);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async evalScript(sha, script, keys, args) {
|
|
77
|
+
try {
|
|
78
|
+
return await this.redis.evalsha(sha, keys.length, ...keys, ...args);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.message && err.message.includes("NOSCRIPT")) {
|
|
81
|
+
const newSHA = await this.redis.script("LOAD", script);
|
|
82
|
+
if (script === HIT_SCRIPT)
|
|
83
|
+
this.hitSHA = newSHA;
|
|
84
|
+
else if (script === HIT_WITH_BAN_SCRIPT)
|
|
85
|
+
this.hitWithBanSHA = newSHA;
|
|
86
|
+
return await this.redis.evalsha(newSHA, keys.length, ...keys, ...args);
|
|
87
|
+
}
|
|
88
|
+
throw err;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
16
91
|
async hit(key, windowMs, _limit) {
|
|
92
|
+
await this.loadScripts();
|
|
17
93
|
const redisKey = this.prefix + key;
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
94
|
+
const result = await this.evalScript(this.hitSHA, HIT_SCRIPT, [redisKey], [windowMs]);
|
|
95
|
+
const count = result[0];
|
|
96
|
+
const ttl = result[1];
|
|
97
|
+
return { count, resetAt: Date.now() + ttl };
|
|
98
|
+
}
|
|
99
|
+
async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
|
|
100
|
+
await this.loadScripts();
|
|
101
|
+
const hitKey = this.prefix + key;
|
|
102
|
+
const banKey = this.banPrefix + key;
|
|
103
|
+
const violationKey = this.violationPrefix + key;
|
|
104
|
+
const result = await this.evalScript(this.hitWithBanSHA, HIT_WITH_BAN_SCRIPT, [hitKey, banKey, violationKey], [windowMs, limit, banThreshold, banDurationMs]);
|
|
105
|
+
const count = result[0];
|
|
106
|
+
const ttl = result[1];
|
|
107
|
+
const banned = result[2] === 1;
|
|
108
|
+
const violations = result[3];
|
|
109
|
+
if (count === -1) {
|
|
110
|
+
return { count: 0, resetAt: Date.now() + ttl, banned: true, violations: 0, banExpiresAt: Date.now() + ttl };
|
|
25
111
|
}
|
|
26
|
-
|
|
27
|
-
|
|
112
|
+
return {
|
|
113
|
+
count,
|
|
114
|
+
resetAt: Date.now() + ttl,
|
|
115
|
+
banned,
|
|
116
|
+
violations,
|
|
117
|
+
banExpiresAt: banned ? Date.now() + banDurationMs : 0
|
|
118
|
+
};
|
|
28
119
|
}
|
|
29
120
|
async isBanned(key) {
|
|
30
121
|
const result = await this.redis.exists(this.banPrefix + key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joint-ops/hitlimit-bun",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.3",
|
|
4
4
|
"description": "Ultra-fast Bun-native rate limiting - Memory-first with 6M+ ops/sec for Bun.serve, Elysia & Hono",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Shayan M Hussain",
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
"test:watch": "bun test --watch"
|
|
118
118
|
},
|
|
119
119
|
"dependencies": {
|
|
120
|
-
"@joint-ops/hitlimit-types": "1.1.
|
|
120
|
+
"@joint-ops/hitlimit-types": "1.1.3"
|
|
121
121
|
},
|
|
122
122
|
"peerDependencies": {
|
|
123
123
|
"elysia": ">=1.0.0",
|