@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 CHANGED
@@ -6,36 +6,24 @@
6
6
  [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
7
7
  [![Bun](https://img.shields.io/badge/Bun-Native-black.svg)](https://bun.sh)
8
8
 
9
- > The fastest rate limiter for Bun - 7M+ ops/sec with memory-first design | Elysia, Hono & Bun.serve
9
+ > The fastest rate limiter for Bun 5.6M+ ops/sec | Bun.serve, Elysia & Hono
10
10
 
11
- **hitlimit-bun** is a blazing-fast, Bun-native rate limiting library for Bun.serve, Elysia, and Hono applications. **Memory-first by default** with 7.29M ops/sec performance (14.6x faster than SQLite). Optional persistence with native bun:sqlite or Redis when you need it.
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
- ## Why hitlimit-bun?
15
+ ## Why hitlimit-bun?
16
16
 
17
- **Memory-first for maximum performance.** 14.6x faster than SQLite.
18
-
19
- ```
20
- ┌─────────────────────────────────────────────────────────────────┐
21
- │ │
22
- │ Memory (v1.1+) ██████████████████████████████ 7.29M ops/s
23
- │ SQLite (v1.0) █░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 500K ops/s │
24
- │ │
25
- │ 14.6x performance improvement with memory default │
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 (Bun 1.3)
343
+ ### Store Benchmarks
356
344
 
357
345
  | Store | Operations/sec | vs Node.js |
358
346
  |-------|----------------|------------|
359
- | **Memory** | 7,290,000+ | +52% faster |
360
- | **bun:sqlite** | 500,000+ | ~same |
361
- | **Redis** | 6,600+ | ~same |
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:** Benchmark results vary by hardware and environment. Run your own benchmarks to see results on your specific setup.
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 framework support
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,CA8CrB;AAGD,wBAAsB,UAAU,CAAC,QAAQ,EACvC,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,cAAc,CAAC,CA6EzB"}
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
- entry.count++;
12
- return { count: entry.count, resetAt: entry.resetAt };
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
- const timeoutId = setTimeout(() => {
17
- this.hits.delete(key);
18
- }, windowMs);
19
- if (typeof timeoutId.unref === "function") {
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
- const timeoutId = setTimeout(() => {
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
- const timeoutId = setTimeout(() => {
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
- const entry = this.hits.get(key);
69
- if (entry) {
70
- clearTimeout(entry.timeoutId);
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
- for (const [, entry] of this.hits) {
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
- entry.count++;
12
- return { count: entry.count, resetAt: entry.resetAt };
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
- const timeoutId = setTimeout(() => {
17
- this.hits.delete(key);
18
- }, windowMs);
19
- if (typeof timeoutId.unref === "function") {
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
- const timeoutId = setTimeout(() => {
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
- const timeoutId = setTimeout(() => {
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
- const entry = this.hits.get(key);
69
- if (entry) {
70
- clearTimeout(entry.timeoutId);
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
- for (const [, entry] of this.hits) {
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
- entry.count++;
12
- return { count: entry.count, resetAt: entry.resetAt };
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
- const timeoutId = setTimeout(() => {
17
- this.hits.delete(key);
18
- }, windowMs);
19
- if (typeof timeoutId.unref === "function") {
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
- const timeoutId = setTimeout(() => {
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
- const timeoutId = setTimeout(() => {
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
- const entry = this.hits.get(key);
69
- if (entry) {
70
- clearTimeout(entry.timeoutId);
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
- for (const [, entry] of this.hits) {
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;AAiJ3E,wBAAgB,WAAW,IAAI,aAAa,CAE3C"}
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"}
@@ -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
- entry.count++;
12
- return { count: entry.count, resetAt: entry.resetAt };
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
- const timeoutId = setTimeout(() => {
17
- this.hits.delete(key);
18
- }, windowMs);
19
- if (typeof timeoutId.unref === "function") {
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
- const timeoutId = setTimeout(() => {
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
- const timeoutId = setTimeout(() => {
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
- const entry = this.hits.get(key);
69
- if (entry) {
70
- clearTimeout(entry.timeoutId);
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
- for (const [, entry] of this.hits) {
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,EAAe,MAAM,2BAA2B,CAAA;AAG3E,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAqED,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa,CAErE"}
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"}
@@ -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 now = Date.now();
19
- const results = await this.redis.multi().incr(redisKey).pttl(redisKey).exec();
20
- const count = results[0][1];
21
- let ttl = results[1][1];
22
- if (ttl < 0) {
23
- await this.redis.pexpire(redisKey, windowMs);
24
- ttl = windowMs;
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
- const resetAt = now + ttl;
27
- return { count, resetAt };
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.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.1"
120
+ "@joint-ops/hitlimit-types": "1.1.3"
121
121
  },
122
122
  "peerDependencies": {
123
123
  "elysia": ">=1.0.0",