@joint-ops/hitlimit-bun 1.0.6 → 1.1.1

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,36 @@
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 - Native bun:sqlite performance | Elysia rate limit plugin
9
+ > The fastest rate limiter for Bun - 7M+ ops/sec with memory-first design | Elysia, Hono & Bun.serve
10
10
 
11
- **hitlimit-bun** is a high-performance, Bun-native rate limiting library for Bun.serve and Elysia applications. Built specifically for Bun's runtime with native bun:sqlite for maximum performance. The only rate limiter designed from the ground up for Bun.
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.
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
- **hitlimit-bun uses Bun's native SQLite** - no FFI overhead, no Node.js polyfills.
17
+ **Memory-first for maximum performance.** 14.6x faster than SQLite.
18
18
 
19
19
  ```
20
20
  ┌─────────────────────────────────────────────────────────────────┐
21
21
  │ │
22
- bun:sqlite ████████████████████████░░░ 386,000 ops/s
23
- better-sqlite3 ████████████████████████░░░ 400,000 ops/s*
22
+ Memory (v1.1+) ██████████████████████████████ 7.29M ops/s
23
+ SQLite (v1.0) █░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 500K ops/s
24
24
  │ │
25
- *estimated - bun:sqlite has zero FFI overhead
25
+ 14.6x performance improvement with memory default
26
26
  │ │
27
27
  └─────────────────────────────────────────────────────────────────┘
28
28
  ```
29
29
 
30
+ - **🚀 Memory-First** - 7.29M ops/sec by default (v1.1.0+), 14.6x faster than SQLite
30
31
  - **Bun Native** - Built specifically for Bun's runtime, not a Node.js port
31
- - **6.1M ops/sec** - Memory store (multi-IP scenarios)
32
- - **386K ops/sec** - With bun:sqlite (multi-IP scenarios)
33
32
  - **Zero Config** - Works out of the box with sensible defaults
34
- - **Elysia Plugin** - First-class Elysia framework integration
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
35
  - **TypeScript First** - Full type safety and IntelliSense support
36
36
  - **Auto-Ban** - Automatically ban repeat offenders after threshold violations
37
37
  - **Shared Limits** - Group rate limits via groupId for teams/tenants
38
- - **Tiny Footprint** - ~23KB total, zero runtime dependencies
38
+ - **Tiny Footprint** - ~18KB core bundle, zero runtime dependencies
39
39
 
40
40
  ## Installation
41
41
 
@@ -67,6 +67,20 @@ new Elysia()
67
67
  .listen(3000)
68
68
  ```
69
69
 
70
+ ### Hono Rate Limiting
71
+
72
+ ```typescript
73
+ import { Hono } from 'hono'
74
+ import { hitlimit } from '@joint-ops/hitlimit-bun/hono'
75
+
76
+ const app = new Hono()
77
+
78
+ app.use(hitlimit({ limit: 100, window: '1m' }))
79
+ app.get('/', (c) => c.text('Hello Bun!'))
80
+
81
+ Bun.serve({ port: 3000, fetch: app.fetch })
82
+ ```
83
+
70
84
  ### Using createHitLimit
71
85
 
72
86
  ```typescript
@@ -235,7 +249,7 @@ hitlimit({
235
249
  retryAfter: true // Retry-After header on 429
236
250
  },
237
251
 
238
- // Store (default: bun:sqlite)
252
+ // Store (default: memory)
239
253
  store: sqliteStore({ path: './ratelimit.db' }),
240
254
 
241
255
  // Skip rate limiting
@@ -260,39 +274,30 @@ hitlimit({
260
274
 
261
275
  ## Storage Backends
262
276
 
263
- ### SQLite Store (Default)
277
+ ### Memory Store (Default)
264
278
 
265
- Uses Bun's native bun:sqlite for maximum performance. Default store.
279
+ Fastest option, used by default. No persistence.
266
280
 
267
281
  ```typescript
268
282
  import { hitlimit } from '@joint-ops/hitlimit-bun'
269
283
 
270
- // Default - uses bun:sqlite with in-memory database
284
+ // Default - uses memory store (no config needed)
271
285
  Bun.serve({
272
286
  fetch: hitlimit({}, handler)
273
287
  })
274
-
275
- // Custom path for persistence
276
- import { sqliteStore } from '@joint-ops/hitlimit-bun'
277
-
278
- Bun.serve({
279
- fetch: hitlimit({
280
- store: sqliteStore({ path: './ratelimit.db' })
281
- }, handler)
282
- })
283
288
  ```
284
289
 
285
- ### Memory Store
290
+ ### SQLite Store
286
291
 
287
- For simple use cases without persistence.
292
+ Uses Bun's native bun:sqlite for persistent rate limiting.
288
293
 
289
294
  ```typescript
290
295
  import { hitlimit } from '@joint-ops/hitlimit-bun'
291
- import { memoryStore } from '@joint-ops/hitlimit-bun/stores/memory'
296
+ import { sqliteStore } from '@joint-ops/hitlimit-bun'
292
297
 
293
298
  Bun.serve({
294
299
  fetch: hitlimit({
295
- store: memoryStore()
300
+ store: sqliteStore({ path: './ratelimit.db' })
296
301
  }, handler)
297
302
  })
298
303
  ```
@@ -351,9 +356,9 @@ hitlimit-bun is optimized for Bun's runtime with native performance:
351
356
 
352
357
  | Store | Operations/sec | vs Node.js |
353
358
  |-------|----------------|------------|
354
- | **Memory** | 7,210,000+ | +130% faster |
355
- | **bun:sqlite** | 520,000+ | **+10% faster** 🔥 |
356
- | **Redis** | 6,900+ | +3% faster |
359
+ | **Memory** | 7,290,000+ | +52% faster |
360
+ | **bun:sqlite** | 500,000+ | ~same |
361
+ | **Redis** | 6,600+ | ~same |
357
362
 
358
363
  ### HTTP Throughput
359
364
 
@@ -423,7 +428,7 @@ new Elysia()
423
428
  Node.js rate limiters like express-rate-limit use better-sqlite3 which relies on N-API bindings. In Bun, this adds overhead and loses the performance benefits of Bun's native runtime.
424
429
 
425
430
  **hitlimit-bun** is built specifically for Bun:
426
- - Uses native `bun:sqlite` (2.7x faster than better-sqlite3)
431
+ - Uses native `bun:sqlite` (no N-API overhead)
427
432
  - No FFI overhead or Node.js polyfills
428
433
  - First-class Elysia framework support
429
434
  - Optimized for Bun.serve's request handling
package/dist/elysia.d.ts CHANGED
@@ -3,6 +3,17 @@ import type { HitLimitOptions } from '@joint-ops/hitlimit-types';
3
3
  export interface ElysiaHitLimitOptions extends HitLimitOptions<{
4
4
  request: Request;
5
5
  }> {
6
+ /**
7
+ * @deprecated Use `store: sqliteStore({ path })` instead.
8
+ *
9
+ * Starting with v1.1.0, the default store is Memory for 15.7x better performance.
10
+ * If you need SQLite persistence:
11
+ *
12
+ * ```typescript
13
+ * import { sqliteStore } from '@joint-ops/hitlimit-bun/stores/sqlite'
14
+ * hitlimit({ store: sqliteStore({ path: './db.sqlite' }) })
15
+ * ```
16
+ */
6
17
  sqlitePath?: string;
7
18
  name?: string;
8
19
  }
@@ -1 +1 @@
1
- {"version":3,"file":"elysia.d.ts","sourceRoot":"","sources":["../src/elysia.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAKhE,MAAM,WAAW,qBAAsB,SAAQ,eAAe,CAAC;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;IAClF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAQD,wBAAgB,QAAQ,CAAC,OAAO,GAAE,qBAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4C3D"}
1
+ {"version":3,"file":"elysia.d.ts","sourceRoot":"","sources":["../src/elysia.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,KAAK,EAAE,eAAe,EAAgE,MAAM,2BAA2B,CAAA;AAK9H,MAAM,WAAW,qBAAsB,SAAQ,eAAe,CAAC;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;IAClF;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAkBD,wBAAgB,QAAQ,CAAC,OAAO,GAAE,qBAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyG3D"}
package/dist/elysia.js CHANGED
@@ -1,99 +1,103 @@
1
1
  // @bun
2
- // src/stores/sqlite.ts
3
- import { Database } from "bun:sqlite";
4
-
5
- class BunSqliteStore {
6
- db;
7
- hitStmt;
8
- getStmt;
9
- resetStmt;
10
- isBannedStmt;
11
- banStmt;
12
- recordViolationStmt;
13
- getViolationStmt;
14
- resetBanStmt;
15
- resetViolationStmt;
16
- cleanupTimer;
17
- constructor(options = {}) {
18
- this.db = new Database(options.path ?? ":memory:");
19
- this.db.exec(`
20
- CREATE TABLE IF NOT EXISTS hitlimit (
21
- key TEXT PRIMARY KEY,
22
- count INTEGER NOT NULL,
23
- reset_at INTEGER NOT NULL
24
- )
25
- `);
26
- this.db.exec(`
27
- CREATE TABLE IF NOT EXISTS hitlimit_bans (
28
- key TEXT PRIMARY KEY,
29
- expires_at INTEGER NOT NULL
30
- )
31
- `);
32
- this.db.exec(`
33
- CREATE TABLE IF NOT EXISTS hitlimit_violations (
34
- key TEXT PRIMARY KEY,
35
- count INTEGER NOT NULL DEFAULT 1,
36
- reset_at INTEGER NOT NULL
37
- )
38
- `);
39
- this.hitStmt = this.db.prepare(`
40
- INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
41
- ON CONFLICT(key) DO UPDATE SET
42
- count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
43
- reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
44
- `);
45
- this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
46
- this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
47
- this.isBannedStmt = this.db.prepare("SELECT 1 FROM hitlimit_bans WHERE key = ?1 AND expires_at > ?2");
48
- this.banStmt = this.db.prepare("INSERT OR REPLACE INTO hitlimit_bans (key, expires_at) VALUES (?1, ?2)");
49
- this.recordViolationStmt = this.db.prepare(`
50
- INSERT INTO hitlimit_violations (key, count, reset_at) VALUES (?1, 1, ?2)
51
- ON CONFLICT(key) DO UPDATE SET
52
- count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
53
- reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
54
- `);
55
- this.getViolationStmt = this.db.prepare("SELECT count FROM hitlimit_violations WHERE key = ?");
56
- this.resetBanStmt = this.db.prepare("DELETE FROM hitlimit_bans WHERE key = ?");
57
- this.resetViolationStmt = this.db.prepare("DELETE FROM hitlimit_violations WHERE key = ?");
58
- this.cleanupTimer = setInterval(() => {
59
- const now = Date.now();
60
- this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(now);
61
- this.db.prepare("DELETE FROM hitlimit_bans WHERE expires_at <= ?").run(now);
62
- this.db.prepare("DELETE FROM hitlimit_violations WHERE reset_at <= ?").run(now);
63
- }, 60000);
64
- }
2
+ // src/stores/memory.ts
3
+ class MemoryStore {
4
+ isSync = true;
5
+ hits = new Map;
6
+ bans = new Map;
7
+ violations = new Map;
65
8
  hit(key, windowMs, _limit) {
9
+ const entry = this.hits.get(key);
10
+ if (entry !== undefined) {
11
+ entry.count++;
12
+ return { count: entry.count, resetAt: entry.resetAt };
13
+ }
66
14
  const now = Date.now();
67
15
  const resetAt = now + windowMs;
68
- this.hitStmt.run(key, resetAt, now);
69
- const row = this.getStmt.get(key);
70
- return { count: row.count, resetAt: row.reset_at };
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 };
71
24
  }
72
25
  isBanned(key) {
73
- return this.isBannedStmt.get(key, Date.now()) !== null;
26
+ const ban = this.bans.get(key);
27
+ if (!ban)
28
+ return false;
29
+ if (Date.now() >= ban.expiresAt) {
30
+ clearTimeout(ban.timeoutId);
31
+ this.bans.delete(key);
32
+ return false;
33
+ }
34
+ return true;
74
35
  }
75
36
  ban(key, durationMs) {
76
- this.banStmt.run(key, Date.now() + durationMs);
37
+ const existing = this.bans.get(key);
38
+ if (existing)
39
+ clearTimeout(existing.timeoutId);
40
+ 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 });
77
48
  }
78
49
  recordViolation(key, windowMs) {
79
- const now = Date.now();
80
- const resetAt = now + windowMs;
81
- this.recordViolationStmt.run(key, resetAt, now);
82
- const row = this.getViolationStmt.get(key);
83
- return row?.count ?? 1;
50
+ const entry = this.violations.get(key);
51
+ if (entry && Date.now() < entry.resetAt) {
52
+ entry.count++;
53
+ return entry.count;
54
+ }
55
+ if (entry)
56
+ clearTimeout(entry.timeoutId);
57
+ 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 });
65
+ return 1;
84
66
  }
85
67
  reset(key) {
86
- this.resetStmt.run(key);
87
- this.resetBanStmt.run(key);
88
- this.resetViolationStmt.run(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
+ }
89
83
  }
90
84
  shutdown() {
91
- clearInterval(this.cleanupTimer);
92
- this.db.close();
85
+ for (const [, entry] of this.hits) {
86
+ clearTimeout(entry.timeoutId);
87
+ }
88
+ this.hits.clear();
89
+ for (const [, entry] of this.bans) {
90
+ clearTimeout(entry.timeoutId);
91
+ }
92
+ this.bans.clear();
93
+ for (const [, entry] of this.violations) {
94
+ clearTimeout(entry.timeoutId);
95
+ }
96
+ this.violations.clear();
93
97
  }
94
98
  }
95
- function sqliteStore(options) {
96
- return new BunSqliteStore(options);
99
+ function memoryStore() {
100
+ return new MemoryStore;
97
101
  }
98
102
 
99
103
  // src/elysia.ts
@@ -277,10 +281,69 @@ var instanceCounter = 0;
277
281
  function getDefaultKey(_ctx) {
278
282
  return "unknown";
279
283
  }
284
+ function buildResponseBody(response, info) {
285
+ if (typeof response === "function") {
286
+ return response(info);
287
+ }
288
+ return { ...response, limit: info.limit, remaining: info.remaining, resetIn: info.resetIn };
289
+ }
280
290
  function hitlimit(options = {}) {
281
291
  const pluginName = options.name ?? `hitlimit-${instanceCounter++}`;
282
- const store = options.store ?? sqliteStore({ path: options.sqlitePath });
292
+ if (options.sqlitePath && !options.store) {
293
+ console.warn("[hitlimit-bun] DEPRECATION WARNING: " + "sqlitePath is deprecated and will be ignored. " + "Use store: sqliteStore({ path }) instead. " + "See migration guide: https://hitlimit.jointops.dev/docs/migration/v1.1.0");
294
+ }
295
+ const store = options.store ?? memoryStore();
283
296
  const config = resolveConfig(options, store, getDefaultKey);
297
+ const hasSkip = !!config.skip;
298
+ const hasTiers = !!(config.tier && config.tiers);
299
+ const hasBan = !!config.ban;
300
+ const hasGroup = !!config.group;
301
+ const standardHeaders = config.headers.standard;
302
+ const legacyHeaders = config.headers.legacy;
303
+ const retryAfterHeader = config.headers.retryAfter;
304
+ const limit = config.limit;
305
+ const windowMs = config.windowMs;
306
+ const responseConfig = config.response;
307
+ const isSyncStore = store.isSync === true;
308
+ const isSyncKey = !options.key;
309
+ if (!hasSkip && !hasTiers && !hasBan && !hasGroup && isSyncStore && isSyncKey) {
310
+ return new Elysia({ name: pluginName }).onBeforeHandle({ as: "scoped" }, ({ set }) => {
311
+ const key = "unknown";
312
+ const result = store.hit(key, windowMs, limit);
313
+ const allowed = result.count <= limit;
314
+ const remaining = Math.max(0, limit - result.count);
315
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
316
+ if (standardHeaders) {
317
+ set.headers["RateLimit-Limit"] = String(limit);
318
+ set.headers["RateLimit-Remaining"] = String(remaining);
319
+ set.headers["RateLimit-Reset"] = String(resetIn);
320
+ }
321
+ if (legacyHeaders) {
322
+ set.headers["X-RateLimit-Limit"] = String(limit);
323
+ set.headers["X-RateLimit-Remaining"] = String(remaining);
324
+ set.headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000));
325
+ }
326
+ if (!allowed) {
327
+ if (retryAfterHeader) {
328
+ set.headers["Retry-After"] = String(resetIn);
329
+ }
330
+ const body = buildResponseBody(responseConfig, {
331
+ limit,
332
+ remaining: 0,
333
+ resetIn,
334
+ resetAt: result.resetAt,
335
+ key
336
+ });
337
+ set.status = 429;
338
+ const headers = { "Content-Type": "application/json" };
339
+ for (const [k, v] of Object.entries(set.headers)) {
340
+ if (v != null)
341
+ headers[k] = String(v);
342
+ }
343
+ return new Response(JSON.stringify(body), { status: 429, headers });
344
+ }
345
+ });
346
+ }
284
347
  return new Elysia({ name: pluginName }).onBeforeHandle({ as: "scoped" }, async ({ request, set }) => {
285
348
  const ctx = { request };
286
349
  if (config.skip) {
@@ -296,13 +359,12 @@ function hitlimit(options = {}) {
296
359
  });
297
360
  if (!result.allowed) {
298
361
  set.status = 429;
299
- return new Response(JSON.stringify(result.body), {
300
- status: 429,
301
- headers: {
302
- "Content-Type": "application/json",
303
- ...result.headers
304
- }
305
- });
362
+ const headers = { "Content-Type": "application/json" };
363
+ for (const [k, v] of Object.entries(result.headers)) {
364
+ if (v != null)
365
+ headers[k] = String(v);
366
+ }
367
+ return new Response(JSON.stringify(result.body), { status: 429, headers });
306
368
  }
307
369
  } catch (error) {
308
370
  const action = await config.onStoreError(error, ctx);
package/dist/hono.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import type { Context } from 'hono';
2
+ import type { HitLimitOptions } from '@joint-ops/hitlimit-types';
3
+ export declare function hitlimit(options?: HitLimitOptions<Context>): import("hono").MiddlewareHandler<any, string, {}, Response | (Response & import("hono").TypedResponse<{
4
+ [x: string]: any;
5
+ }, 429, "json">)>;
6
+ //# sourceMappingURL=hono.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"hono.d.ts","sourceRoot":"","sources":["../src/hono.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,KAAK,EAAE,eAAe,EAAgE,MAAM,2BAA2B,CAAA;AAqB9H,wBAAgB,QAAQ,CAAC,OAAO,GAAE,eAAe,CAAC,OAAO,CAAM;;kBAkF9D"}
package/dist/hono.js ADDED
@@ -0,0 +1,364 @@
1
+ // @bun
2
+ // src/stores/memory.ts
3
+ class MemoryStore {
4
+ isSync = true;
5
+ hits = new Map;
6
+ bans = new Map;
7
+ violations = new Map;
8
+ hit(key, windowMs, _limit) {
9
+ const entry = this.hits.get(key);
10
+ if (entry !== undefined) {
11
+ entry.count++;
12
+ return { count: entry.count, resetAt: entry.resetAt };
13
+ }
14
+ const now = Date.now();
15
+ 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 };
24
+ }
25
+ isBanned(key) {
26
+ const ban = this.bans.get(key);
27
+ if (!ban)
28
+ return false;
29
+ if (Date.now() >= ban.expiresAt) {
30
+ clearTimeout(ban.timeoutId);
31
+ this.bans.delete(key);
32
+ return false;
33
+ }
34
+ return true;
35
+ }
36
+ ban(key, durationMs) {
37
+ const existing = this.bans.get(key);
38
+ if (existing)
39
+ clearTimeout(existing.timeoutId);
40
+ 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 });
48
+ }
49
+ recordViolation(key, windowMs) {
50
+ const entry = this.violations.get(key);
51
+ if (entry && Date.now() < entry.resetAt) {
52
+ entry.count++;
53
+ return entry.count;
54
+ }
55
+ if (entry)
56
+ clearTimeout(entry.timeoutId);
57
+ 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 });
65
+ return 1;
66
+ }
67
+ 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
+ }
83
+ }
84
+ shutdown() {
85
+ for (const [, entry] of this.hits) {
86
+ clearTimeout(entry.timeoutId);
87
+ }
88
+ this.hits.clear();
89
+ for (const [, entry] of this.bans) {
90
+ clearTimeout(entry.timeoutId);
91
+ }
92
+ this.bans.clear();
93
+ for (const [, entry] of this.violations) {
94
+ clearTimeout(entry.timeoutId);
95
+ }
96
+ this.violations.clear();
97
+ }
98
+ }
99
+ function memoryStore() {
100
+ return new MemoryStore;
101
+ }
102
+
103
+ // src/hono.ts
104
+ import { createMiddleware } from "hono/factory";
105
+
106
+ // src/core/utils.ts
107
+ var UNITS = {
108
+ s: 1000,
109
+ m: 60 * 1000,
110
+ h: 60 * 60 * 1000,
111
+ d: 24 * 60 * 60 * 1000
112
+ };
113
+ function parseWindow(window) {
114
+ if (typeof window === "number")
115
+ return window;
116
+ const match = window.match(/^(\d+)(s|m|h|d)$/);
117
+ if (!match)
118
+ throw new Error(`Invalid window format: ${window}`);
119
+ return parseInt(match[1]) * UNITS[match[2]];
120
+ }
121
+
122
+ // src/core/config.ts
123
+ function resolveConfig(options, defaultStore, defaultKey) {
124
+ return {
125
+ limit: options.limit ?? 100,
126
+ windowMs: parseWindow(options.window ?? "1m"),
127
+ key: options.key ?? defaultKey,
128
+ tiers: options.tiers,
129
+ tier: options.tier,
130
+ response: options.response ?? { hitlimit: true, message: "Whoa there! Rate limit exceeded." },
131
+ headers: {
132
+ standard: options.headers?.standard ?? true,
133
+ legacy: options.headers?.legacy ?? true,
134
+ retryAfter: options.headers?.retryAfter ?? true
135
+ },
136
+ store: options.store ?? defaultStore,
137
+ onStoreError: options.onStoreError ?? (() => "allow"),
138
+ skip: options.skip,
139
+ ban: options.ban ? { threshold: options.ban.threshold, durationMs: parseWindow(options.ban.duration) } : null,
140
+ group: options.group ?? null
141
+ };
142
+ }
143
+
144
+ // src/core/headers.ts
145
+ function buildHeaders(info, config, allowed) {
146
+ const headers = {};
147
+ if (config.standard) {
148
+ headers["RateLimit-Limit"] = String(info.limit);
149
+ headers["RateLimit-Remaining"] = String(info.remaining);
150
+ headers["RateLimit-Reset"] = String(Math.ceil(info.resetAt / 1000));
151
+ }
152
+ if (config.legacy) {
153
+ headers["X-RateLimit-Limit"] = String(info.limit);
154
+ headers["X-RateLimit-Remaining"] = String(info.remaining);
155
+ headers["X-RateLimit-Reset"] = String(Math.ceil(info.resetAt / 1000));
156
+ }
157
+ if (!allowed && config.retryAfter) {
158
+ headers["Retry-After"] = String(info.resetIn);
159
+ }
160
+ if (info.banned) {
161
+ headers["X-RateLimit-Ban"] = "true";
162
+ if (info.banExpiresAt) {
163
+ headers["X-RateLimit-Ban-Expires"] = String(Math.ceil(info.banExpiresAt / 1000));
164
+ }
165
+ }
166
+ return headers;
167
+ }
168
+
169
+ // src/core/response.ts
170
+ function buildBody(response, info) {
171
+ if (typeof response === "function") {
172
+ return response(info);
173
+ }
174
+ const body = {
175
+ ...response,
176
+ limit: info.limit,
177
+ remaining: info.remaining,
178
+ resetIn: info.resetIn
179
+ };
180
+ if (info.banned) {
181
+ body.banned = true;
182
+ body.banExpiresAt = info.banExpiresAt;
183
+ body.message = "You have been temporarily banned due to repeated rate limit violations";
184
+ }
185
+ return body;
186
+ }
187
+
188
+ // src/core/limiter.ts
189
+ async function resolveKey(config, req) {
190
+ let key = await config.key(req);
191
+ let groupId;
192
+ if (config.group) {
193
+ groupId = typeof config.group === "function" ? await config.group(req) : config.group;
194
+ key = `group:${groupId}:${key}`;
195
+ }
196
+ return { key, groupId };
197
+ }
198
+ function resolveTier(config, tierName) {
199
+ if (tierName && config.tiers) {
200
+ const tierConfig = config.tiers[tierName];
201
+ if (tierConfig) {
202
+ return {
203
+ limit: tierConfig.limit,
204
+ windowMs: tierConfig.window ? parseWindow(tierConfig.window) : config.windowMs
205
+ };
206
+ }
207
+ }
208
+ return { limit: config.limit, windowMs: config.windowMs };
209
+ }
210
+ async function checkLimit(config, req) {
211
+ const { key, groupId } = await resolveKey(config, req);
212
+ let tierName;
213
+ if (config.tier && config.tiers) {
214
+ tierName = await config.tier(req);
215
+ }
216
+ const { limit, windowMs } = resolveTier(config, tierName);
217
+ if (config.ban && config.store.isBanned) {
218
+ const banned = await config.store.isBanned(key);
219
+ if (banned) {
220
+ const banResetIn = Math.ceil(config.ban.durationMs / 1000);
221
+ const info2 = {
222
+ limit,
223
+ remaining: 0,
224
+ resetIn: banResetIn,
225
+ resetAt: Date.now() + config.ban.durationMs,
226
+ key,
227
+ tier: tierName,
228
+ banned: true,
229
+ banExpiresAt: Date.now() + config.ban.durationMs,
230
+ group: groupId
231
+ };
232
+ return {
233
+ allowed: false,
234
+ info: info2,
235
+ headers: buildHeaders(info2, config.headers, false),
236
+ body: buildBody(config.response, info2)
237
+ };
238
+ }
239
+ }
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
+ const result = await config.store.hit(key, windowMs, limit);
249
+ const now = Date.now();
250
+ const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
251
+ const remaining = Math.max(0, limit - result.count);
252
+ const allowed = result.count <= limit;
253
+ const info = {
254
+ limit,
255
+ remaining,
256
+ resetIn,
257
+ resetAt: result.resetAt,
258
+ key,
259
+ tier: tierName,
260
+ group: groupId
261
+ };
262
+ if (!allowed && config.ban && config.store.recordViolation) {
263
+ const violations = await config.store.recordViolation(key, config.ban.durationMs);
264
+ info.violations = violations;
265
+ if (violations >= config.ban.threshold && config.store.ban) {
266
+ await config.store.ban(key, config.ban.durationMs);
267
+ info.banned = true;
268
+ info.banExpiresAt = now + config.ban.durationMs;
269
+ }
270
+ }
271
+ return {
272
+ allowed,
273
+ info,
274
+ headers: buildHeaders(info, config.headers, allowed),
275
+ body: allowed ? {} : buildBody(config.response, info)
276
+ };
277
+ }
278
+
279
+ // src/hono.ts
280
+ function getDefaultKey(c) {
281
+ return c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
282
+ }
283
+ function buildResponseBody(response, info) {
284
+ if (typeof response === "function") {
285
+ return response(info);
286
+ }
287
+ return { ...response, limit: info.limit, remaining: info.remaining, resetIn: info.resetIn };
288
+ }
289
+ function hitlimit(options = {}) {
290
+ const store = options.store ?? memoryStore();
291
+ const config = resolveConfig(options, store, getDefaultKey);
292
+ const hasSkip = !!config.skip;
293
+ const hasTiers = !!(config.tier && config.tiers);
294
+ const hasBan = !!config.ban;
295
+ const hasGroup = !!config.group;
296
+ const standardHeaders = config.headers.standard;
297
+ const legacyHeaders = config.headers.legacy;
298
+ const retryAfterHeader = config.headers.retryAfter;
299
+ const limit = config.limit;
300
+ const windowMs = config.windowMs;
301
+ const responseConfig = config.response;
302
+ const isSyncStore = store.isSync === true;
303
+ const isSyncKey = !options.key;
304
+ if (!hasSkip && !hasTiers && !hasBan && !hasGroup && isSyncStore && isSyncKey) {
305
+ return createMiddleware(async (c, next) => {
306
+ const key = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || c.req.header("x-real-ip") || "unknown";
307
+ const result = store.hit(key, windowMs, limit);
308
+ const allowed = result.count <= limit;
309
+ const remaining = Math.max(0, limit - result.count);
310
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
311
+ if (standardHeaders) {
312
+ c.header("RateLimit-Limit", String(limit));
313
+ c.header("RateLimit-Remaining", String(remaining));
314
+ c.header("RateLimit-Reset", String(resetIn));
315
+ }
316
+ if (legacyHeaders) {
317
+ c.header("X-RateLimit-Limit", String(limit));
318
+ c.header("X-RateLimit-Remaining", String(remaining));
319
+ c.header("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
320
+ }
321
+ if (!allowed) {
322
+ if (retryAfterHeader) {
323
+ c.header("Retry-After", String(resetIn));
324
+ }
325
+ const body = buildResponseBody(responseConfig, {
326
+ limit,
327
+ remaining: 0,
328
+ resetIn,
329
+ resetAt: result.resetAt,
330
+ key
331
+ });
332
+ return c.json(body, 429);
333
+ }
334
+ await next();
335
+ });
336
+ }
337
+ return createMiddleware(async (c, next) => {
338
+ if (config.skip) {
339
+ const shouldSkip = await config.skip(c);
340
+ if (shouldSkip) {
341
+ await next();
342
+ return;
343
+ }
344
+ }
345
+ try {
346
+ const result = await checkLimit(config, c);
347
+ for (const [key, value] of Object.entries(result.headers)) {
348
+ c.header(key, value);
349
+ }
350
+ if (!result.allowed) {
351
+ return c.json(result.body, 429);
352
+ }
353
+ } catch (error) {
354
+ const action = await config.onStoreError(error, c);
355
+ if (action === "deny") {
356
+ return c.json({ hitlimit: true, message: "Rate limit error" }, 429);
357
+ }
358
+ }
359
+ await next();
360
+ });
361
+ }
362
+ export {
363
+ hitlimit
364
+ };
package/dist/index.d.ts CHANGED
@@ -2,9 +2,20 @@ import type { HitLimitOptions } from '@joint-ops/hitlimit-types';
2
2
  import { checkLimit } from './core/limiter.js';
3
3
  export type { HitLimitOptions, HitLimitInfo, HitLimitResult, HitLimitStore, StoreResult, TierConfig, HeadersConfig, ResolvedConfig, KeyGenerator, TierResolver, SkipFunction, StoreErrorHandler, ResponseFormatter, ResponseConfig, BanConfig, GroupIdResolver } from '@joint-ops/hitlimit-types';
4
4
  export { DEFAULT_LIMIT, DEFAULT_WINDOW, DEFAULT_WINDOW_MS, DEFAULT_MESSAGE } from '@joint-ops/hitlimit-types';
5
- export { sqliteStore } from './stores/sqlite.js';
5
+ export { memoryStore } from './stores/memory.js';
6
6
  export { checkLimit };
7
7
  export interface BunHitLimitOptions extends HitLimitOptions<Request> {
8
+ /**
9
+ * @deprecated Use `store: sqliteStore({ path })` instead.
10
+ *
11
+ * Starting with v1.1.0, the default store is Memory for 15.7x better performance.
12
+ * If you need SQLite persistence:
13
+ *
14
+ * ```typescript
15
+ * import { sqliteStore } from '@joint-ops/hitlimit-bun/stores/sqlite'
16
+ * hitlimit({ store: sqliteStore({ path: './db.sqlite' }) }, handler)
17
+ * ```
18
+ */
8
19
  sqlitePath?: string;
9
20
  }
10
21
  type BunServer = {
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAEhE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAG9C,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AACjS,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAC7G,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAAE,UAAU,EAAE,CAAA;AAErB,MAAM,WAAW,kBAAmB,SAAQ,eAAe,CAAC,OAAO,CAAC;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,KAAK,SAAS,GAAG;IAAE,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CAAE,CAAA;AAExE,KAAK,YAAY,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAMrF,wBAAgB,QAAQ,CACtB,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,YAAY,GACpB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAiJnE;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAA;IAClF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CACzC;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAgD3E"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAEhE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAG9C,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AACjS,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAC7G,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAAE,UAAU,EAAE,CAAA;AAErB,MAAM,WAAW,kBAAmB,SAAQ,eAAe,CAAC,OAAO,CAAC;IAClE;;;;;;;;;;OAUG;IACH,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,KAAK,SAAS,GAAG;IAAE,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CAAE,CAAA;AAExE,KAAK,YAAY,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAMrF,wBAAgB,QAAQ,CACtB,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,YAAY,GACpB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAsPnE;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAA;IAClF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CACzC;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,kBAAuB,GAAG,UAAU,CA0D3E"}
package/dist/index.js CHANGED
@@ -1,99 +1,103 @@
1
1
  // @bun
2
- // src/stores/sqlite.ts
3
- import { Database } from "bun:sqlite";
4
-
5
- class BunSqliteStore {
6
- db;
7
- hitStmt;
8
- getStmt;
9
- resetStmt;
10
- isBannedStmt;
11
- banStmt;
12
- recordViolationStmt;
13
- getViolationStmt;
14
- resetBanStmt;
15
- resetViolationStmt;
16
- cleanupTimer;
17
- constructor(options = {}) {
18
- this.db = new Database(options.path ?? ":memory:");
19
- this.db.exec(`
20
- CREATE TABLE IF NOT EXISTS hitlimit (
21
- key TEXT PRIMARY KEY,
22
- count INTEGER NOT NULL,
23
- reset_at INTEGER NOT NULL
24
- )
25
- `);
26
- this.db.exec(`
27
- CREATE TABLE IF NOT EXISTS hitlimit_bans (
28
- key TEXT PRIMARY KEY,
29
- expires_at INTEGER NOT NULL
30
- )
31
- `);
32
- this.db.exec(`
33
- CREATE TABLE IF NOT EXISTS hitlimit_violations (
34
- key TEXT PRIMARY KEY,
35
- count INTEGER NOT NULL DEFAULT 1,
36
- reset_at INTEGER NOT NULL
37
- )
38
- `);
39
- this.hitStmt = this.db.prepare(`
40
- INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
41
- ON CONFLICT(key) DO UPDATE SET
42
- count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
43
- reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
44
- `);
45
- this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
46
- this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
47
- this.isBannedStmt = this.db.prepare("SELECT 1 FROM hitlimit_bans WHERE key = ?1 AND expires_at > ?2");
48
- this.banStmt = this.db.prepare("INSERT OR REPLACE INTO hitlimit_bans (key, expires_at) VALUES (?1, ?2)");
49
- this.recordViolationStmt = this.db.prepare(`
50
- INSERT INTO hitlimit_violations (key, count, reset_at) VALUES (?1, 1, ?2)
51
- ON CONFLICT(key) DO UPDATE SET
52
- count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
53
- reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
54
- `);
55
- this.getViolationStmt = this.db.prepare("SELECT count FROM hitlimit_violations WHERE key = ?");
56
- this.resetBanStmt = this.db.prepare("DELETE FROM hitlimit_bans WHERE key = ?");
57
- this.resetViolationStmt = this.db.prepare("DELETE FROM hitlimit_violations WHERE key = ?");
58
- this.cleanupTimer = setInterval(() => {
59
- const now = Date.now();
60
- this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(now);
61
- this.db.prepare("DELETE FROM hitlimit_bans WHERE expires_at <= ?").run(now);
62
- this.db.prepare("DELETE FROM hitlimit_violations WHERE reset_at <= ?").run(now);
63
- }, 60000);
64
- }
2
+ // src/stores/memory.ts
3
+ class MemoryStore {
4
+ isSync = true;
5
+ hits = new Map;
6
+ bans = new Map;
7
+ violations = new Map;
65
8
  hit(key, windowMs, _limit) {
9
+ const entry = this.hits.get(key);
10
+ if (entry !== undefined) {
11
+ entry.count++;
12
+ return { count: entry.count, resetAt: entry.resetAt };
13
+ }
66
14
  const now = Date.now();
67
15
  const resetAt = now + windowMs;
68
- this.hitStmt.run(key, resetAt, now);
69
- const row = this.getStmt.get(key);
70
- return { count: row.count, resetAt: row.reset_at };
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 };
71
24
  }
72
25
  isBanned(key) {
73
- return this.isBannedStmt.get(key, Date.now()) !== null;
26
+ const ban = this.bans.get(key);
27
+ if (!ban)
28
+ return false;
29
+ if (Date.now() >= ban.expiresAt) {
30
+ clearTimeout(ban.timeoutId);
31
+ this.bans.delete(key);
32
+ return false;
33
+ }
34
+ return true;
74
35
  }
75
36
  ban(key, durationMs) {
76
- this.banStmt.run(key, Date.now() + durationMs);
37
+ const existing = this.bans.get(key);
38
+ if (existing)
39
+ clearTimeout(existing.timeoutId);
40
+ 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 });
77
48
  }
78
49
  recordViolation(key, windowMs) {
79
- const now = Date.now();
80
- const resetAt = now + windowMs;
81
- this.recordViolationStmt.run(key, resetAt, now);
82
- const row = this.getViolationStmt.get(key);
83
- return row?.count ?? 1;
50
+ const entry = this.violations.get(key);
51
+ if (entry && Date.now() < entry.resetAt) {
52
+ entry.count++;
53
+ return entry.count;
54
+ }
55
+ if (entry)
56
+ clearTimeout(entry.timeoutId);
57
+ 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 });
65
+ return 1;
84
66
  }
85
67
  reset(key) {
86
- this.resetStmt.run(key);
87
- this.resetBanStmt.run(key);
88
- this.resetViolationStmt.run(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
+ }
89
83
  }
90
84
  shutdown() {
91
- clearInterval(this.cleanupTimer);
92
- this.db.close();
85
+ for (const [, entry] of this.hits) {
86
+ clearTimeout(entry.timeoutId);
87
+ }
88
+ this.hits.clear();
89
+ for (const [, entry] of this.bans) {
90
+ clearTimeout(entry.timeoutId);
91
+ }
92
+ this.bans.clear();
93
+ for (const [, entry] of this.violations) {
94
+ clearTimeout(entry.timeoutId);
95
+ }
96
+ this.violations.clear();
93
97
  }
94
98
  }
95
- function sqliteStore(options) {
96
- return new BunSqliteStore(options);
99
+ function memoryStore() {
100
+ return new MemoryStore;
97
101
  }
98
102
 
99
103
  // src/core/utils.ts
@@ -283,7 +287,10 @@ function hitlimit(options, handler) {
283
287
  const defaultKey = (req) => {
284
288
  return activeServer?.requestIP(req)?.address || "unknown";
285
289
  };
286
- const store = options.store ?? sqliteStore({ path: options.sqlitePath });
290
+ if (options.sqlitePath && !options.store) {
291
+ console.warn("[hitlimit-bun] DEPRECATION WARNING: " + "sqlitePath is deprecated and will be ignored. " + "Use store: sqliteStore({ path }) instead. " + "See migration guide: https://hitlimit.jointops.dev/docs/migration/v1.1.0");
292
+ }
293
+ const store = options.store ?? memoryStore();
287
294
  const config = resolveConfig(options, store, options.key ?? defaultKey);
288
295
  const hasSkip = !!config.skip;
289
296
  const hasTiers = !!(config.tier && config.tiers);
@@ -297,6 +304,80 @@ function hitlimit(options, handler) {
297
304
  const response = config.response;
298
305
  const customKey = options.key;
299
306
  const blockedBody = JSON.stringify(response);
307
+ const isSyncStore = store.isSync === true;
308
+ const isSyncKey = !customKey;
309
+ if (!hasSkip && !hasTiers && !hasBan && !hasGroup && isSyncStore && isSyncKey) {
310
+ return (req, server) => {
311
+ const ip = server.requestIP(req)?.address || "unknown";
312
+ const result = store.hit(ip, windowMs, limit);
313
+ const allowed = result.count <= limit;
314
+ if (!allowed) {
315
+ const headers = { "Content-Type": "application/json" };
316
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
317
+ if (standardHeaders) {
318
+ headers["RateLimit-Limit"] = String(limit);
319
+ headers["RateLimit-Remaining"] = "0";
320
+ headers["RateLimit-Reset"] = String(resetIn);
321
+ }
322
+ if (legacyHeaders) {
323
+ headers["X-RateLimit-Limit"] = String(limit);
324
+ headers["X-RateLimit-Remaining"] = "0";
325
+ headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000));
326
+ }
327
+ if (retryAfterHeader) {
328
+ headers["Retry-After"] = String(resetIn);
329
+ }
330
+ return new Response(blockedBody, { status: 429, headers });
331
+ }
332
+ const res = handler(req, server);
333
+ if (res instanceof Promise) {
334
+ return res.then((response2) => {
335
+ if (standardHeaders || legacyHeaders) {
336
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
337
+ const remaining = Math.max(0, limit - result.count);
338
+ const newHeaders = new Headers(response2.headers);
339
+ if (standardHeaders) {
340
+ newHeaders.set("RateLimit-Limit", String(limit));
341
+ newHeaders.set("RateLimit-Remaining", String(remaining));
342
+ newHeaders.set("RateLimit-Reset", String(resetIn));
343
+ }
344
+ if (legacyHeaders) {
345
+ newHeaders.set("X-RateLimit-Limit", String(limit));
346
+ newHeaders.set("X-RateLimit-Remaining", String(remaining));
347
+ newHeaders.set("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
348
+ }
349
+ return new Response(response2.body, {
350
+ status: response2.status,
351
+ statusText: response2.statusText,
352
+ headers: newHeaders
353
+ });
354
+ }
355
+ return response2;
356
+ });
357
+ }
358
+ if (standardHeaders || legacyHeaders) {
359
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
360
+ const remaining = Math.max(0, limit - result.count);
361
+ const newHeaders = new Headers(res.headers);
362
+ if (standardHeaders) {
363
+ newHeaders.set("RateLimit-Limit", String(limit));
364
+ newHeaders.set("RateLimit-Remaining", String(remaining));
365
+ newHeaders.set("RateLimit-Reset", String(resetIn));
366
+ }
367
+ if (legacyHeaders) {
368
+ newHeaders.set("X-RateLimit-Limit", String(limit));
369
+ newHeaders.set("X-RateLimit-Remaining", String(remaining));
370
+ newHeaders.set("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
371
+ }
372
+ return new Response(res.body, {
373
+ status: res.status,
374
+ statusText: res.statusText,
375
+ headers: newHeaders
376
+ });
377
+ }
378
+ return res;
379
+ };
380
+ }
300
381
  if (!hasSkip && !hasTiers && !hasBan && !hasGroup) {
301
382
  return async (req, server) => {
302
383
  try {
@@ -402,7 +483,10 @@ function createHitLimit(options = {}) {
402
483
  const defaultKey = (req) => {
403
484
  return activeServer?.requestIP(req)?.address || "unknown";
404
485
  };
405
- const store = options.store ?? sqliteStore({ path: options.sqlitePath });
486
+ if (options.sqlitePath && !options.store) {
487
+ console.warn("[hitlimit-bun] DEPRECATION WARNING: " + "sqlitePath is deprecated and will be ignored. " + "Use store: sqliteStore({ path }) instead. " + "See migration guide: https://hitlimit.jointops.dev/docs/migration/v1.1.0");
488
+ }
489
+ const store = options.store ?? memoryStore();
406
490
  const config = resolveConfig(options, store, options.key ?? defaultKey);
407
491
  return {
408
492
  async check(req, server) {
@@ -439,7 +523,7 @@ function createHitLimit(options = {}) {
439
523
  };
440
524
  }
441
525
  export {
442
- sqliteStore,
526
+ memoryStore,
443
527
  hitlimit,
444
528
  createHitLimit,
445
529
  checkLimit,
@@ -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;AAgJ3E,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;AAiJ3E,wBAAgB,WAAW,IAAI,aAAa,CAE3C"}
@@ -1,6 +1,7 @@
1
1
  // @bun
2
2
  // src/stores/memory.ts
3
3
  class MemoryStore {
4
+ isSync = true;
4
5
  hits = new Map;
5
6
  bans = new Map;
6
7
  violations = new Map;
@@ -1 +1 @@
1
- {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/stores/sqlite.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAE3E,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AA+GD,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/stores/sqlite.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAE3E,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAgHD,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
@@ -3,6 +3,7 @@
3
3
  import { Database } from "bun:sqlite";
4
4
 
5
5
  class BunSqliteStore {
6
+ isSync = true;
6
7
  db;
7
8
  hitStmt;
8
9
  getStmt;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@joint-ops/hitlimit-bun",
3
- "version": "1.0.6",
4
- "description": "Fast Bun-native rate limiting for Bun.serve & Elysia - API throttling with bun:sqlite, high performance request limiting",
3
+ "version": "1.1.1",
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",
7
7
  "email": "shayanhussain48@gmail.com",
@@ -19,10 +19,16 @@
19
19
  },
20
20
  "keywords": [
21
21
  "rate-limit",
22
- "rate-limiter",
23
- "rate-limiting",
24
- "ratelimit",
25
22
  "bun",
23
+ "rate-limiter",
24
+ "memory",
25
+ "fast",
26
+ "elysia",
27
+ "bun.serve",
28
+ "middleware",
29
+ "sqlite",
30
+ "redis",
31
+ "hono",
26
32
  "bun-rate-limit",
27
33
  "bun-sqlite",
28
34
  "bun-middleware",
@@ -30,15 +36,12 @@
30
36
  "bun-server",
31
37
  "bun-native",
32
38
  "bun-http",
33
- "elysia",
34
39
  "elysia-plugin",
35
40
  "elysia-rate-limit",
36
41
  "elysia-middleware",
37
42
  "elysia-throttle",
38
- "hono",
39
43
  "hono-rate-limit",
40
44
  "hono-bun",
41
- "middleware",
42
45
  "throttle",
43
46
  "throttling",
44
47
  "api",
@@ -50,7 +53,6 @@
50
53
  "brute-force",
51
54
  "brute-force-protection",
52
55
  "login-protection",
53
- "redis",
54
56
  "redis-rate-limit",
55
57
  "typescript",
56
58
  "high-performance",
@@ -64,7 +66,6 @@
64
66
  "http-rate-limit",
65
67
  "bun-api",
66
68
  "bun-framework",
67
- "fast",
68
69
  "lightweight",
69
70
  "zero-dependency",
70
71
  "esm"
@@ -84,6 +85,11 @@
84
85
  "bun": "./dist/elysia.js",
85
86
  "import": "./dist/elysia.js"
86
87
  },
88
+ "./hono": {
89
+ "types": "./dist/hono.d.ts",
90
+ "bun": "./dist/hono.js",
91
+ "import": "./dist/hono.js"
92
+ },
87
93
  "./stores/memory": {
88
94
  "types": "./dist/stores/memory.d.ts",
89
95
  "bun": "./dist/stores/memory.js",
@@ -105,22 +111,26 @@
105
111
  ],
106
112
  "sideEffects": false,
107
113
  "scripts": {
108
- "build": "bun build ./src/index.ts ./src/elysia.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts --outdir=./dist --target=bun --external=elysia --external=ioredis --external=@sinclair/typebox && tsc --emitDeclarationOnly",
114
+ "build": "bun build ./src/index.ts ./src/elysia.ts ./src/hono.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts --outdir=./dist --target=bun --external=elysia --external=hono --external=ioredis --external=@sinclair/typebox && tsc --emitDeclarationOnly",
109
115
  "clean": "rm -rf dist",
110
116
  "test": "bun test",
111
117
  "test:watch": "bun test --watch"
112
118
  },
113
119
  "dependencies": {
114
- "@joint-ops/hitlimit-types": "1.0.6"
120
+ "@joint-ops/hitlimit-types": "1.1.1"
115
121
  },
116
122
  "peerDependencies": {
117
123
  "elysia": ">=1.0.0",
124
+ "hono": ">=4.0.0",
118
125
  "ioredis": ">=5.0.0"
119
126
  },
120
127
  "peerDependenciesMeta": {
121
128
  "elysia": {
122
129
  "optional": true
123
130
  },
131
+ "hono": {
132
+ "optional": true
133
+ },
124
134
  "ioredis": {
125
135
  "optional": true
126
136
  }
@@ -129,6 +139,7 @@
129
139
  "@sinclair/typebox": "^0.34.48",
130
140
  "@types/bun": "latest",
131
141
  "elysia": "^1.0.0",
142
+ "hono": "^4.11.9",
132
143
  "ioredis": "^5.3.0",
133
144
  "typescript": "^5.3.0"
134
145
  }