@joint-ops/hitlimit-bun 1.1.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,49 +1,40 @@
1
1
  # @joint-ops/hitlimit-bun
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@joint-ops/hitlimit-bun.svg)](https://www.npmjs.com/package/@joint-ops/hitlimit-bun)
4
- [![npm downloads](https://img.shields.io/npm/dm/@joint-ops/hitlimit-bun.svg)](https://www.npmjs.com/package/@joint-ops/hitlimit-bun)
5
- [![GitHub](https://img.shields.io/github/license/JointOps/hitlimit-monorepo)](https://github.com/JointOps/hitlimit-monorepo)
6
- [![TypeScript](https://img.shields.io/badge/TypeScript-Ready-blue.svg)](https://www.typescriptlang.org/)
7
- [![Bun](https://img.shields.io/badge/Bun-Native-black.svg)](https://bun.sh)
3
+ > Rate limiting built for Bun. Not ported — built.
8
4
 
9
- > The fastest rate limiter for Bun 5.6M+ ops/sec | Bun.serve, Elysia & Hono
5
+ **12.38M ops/sec** on memory. **8.32M at 10K IPs**. Native bun:sqlite. Atomic Redis Lua. Postgres. Zero dependencies.
10
6
 
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
-
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)**
7
+ ```bash
8
+ bun add @joint-ops/hitlimit-bun
9
+ ```
14
10
 
15
- ## Why hitlimit-bun?
11
+ ```typescript
12
+ Bun.serve({
13
+ fetch: hitlimit({}, (req) => new Response('Hello!'))
14
+ })
15
+ ```
16
16
 
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
17
+ One line. Done. Works with **Bun.serve**, **Elysia**, and **Hono** out of the box.
27
18
 
28
- ## Installation
19
+ **[Docs](https://hitlimit.jointops.dev/docs/bun)** · **[GitHub](https://github.com/JointOps/hitlimit-monorepo)** · **[Benchmarks](https://github.com/JointOps/hitlimit-monorepo/tree/main/benchmarks)**
29
20
 
30
- ```bash
31
- bun add @joint-ops/hitlimit-bun
32
- ```
21
+ ---
33
22
 
34
- ## Quick Start
23
+ ## 30 Seconds to Production
35
24
 
36
- ### Bun.serve Rate Limiting
25
+ ### Bun.serve
37
26
 
38
27
  ```typescript
39
28
  import { hitlimit } from '@joint-ops/hitlimit-bun'
40
29
 
41
30
  Bun.serve({
42
- fetch: hitlimit({}, (req) => new Response('Hello!'))
31
+ fetch: hitlimit({ limit: 100, window: '1m' }, (req) => {
32
+ return new Response('Hello!')
33
+ })
43
34
  })
44
35
  ```
45
36
 
46
- ### Elysia Rate Limiting
37
+ ### Elysia
47
38
 
48
39
  ```typescript
49
40
  import { Elysia } from 'elysia'
@@ -51,377 +42,161 @@ import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
51
42
 
52
43
  new Elysia()
53
44
  .use(hitlimit({ limit: 100, window: '1m' }))
54
- .get('/', () => 'Hello World!')
45
+ .get('/', () => 'Hello!')
55
46
  .listen(3000)
56
47
  ```
57
48
 
58
- ### Hono Rate Limiting
49
+ ### Hono
59
50
 
60
51
  ```typescript
61
52
  import { Hono } from 'hono'
62
53
  import { hitlimit } from '@joint-ops/hitlimit-bun/hono'
63
54
 
64
55
  const app = new Hono()
65
-
66
56
  app.use(hitlimit({ limit: 100, window: '1m' }))
67
- app.get('/', (c) => c.text('Hello Bun!'))
68
-
57
+ app.get('/', (c) => c.text('Hello!'))
69
58
  Bun.serve({ port: 3000, fetch: app.fetch })
70
59
  ```
71
60
 
72
- ### Using createHitLimit
73
-
74
- ```typescript
75
- import { createHitLimit } from '@joint-ops/hitlimit-bun'
76
-
77
- const limiter = createHitLimit({ limit: 100, window: '1m' })
78
-
79
- Bun.serve({
80
- async fetch(req, server) {
81
- // Returns a 429 Response if blocked, or null if allowed
82
- const blocked = await limiter.check(req, server)
83
- if (blocked) return blocked
84
-
85
- return new Response('Hello!')
86
- }
87
- })
88
- ```
89
-
90
- ## Features
61
+ ---
91
62
 
92
- ### API Rate Limiting
63
+ ## What You Get
93
64
 
94
- Protect your Bun APIs from abuse with high-performance rate limiting.
95
-
96
- ```typescript
97
- Bun.serve({
98
- fetch: hitlimit({ limit: 1000, window: '1h' }, handler)
99
- })
100
- ```
101
-
102
- ### Login & Authentication Protection
103
-
104
- Prevent brute force attacks on login endpoints.
105
-
106
- ```typescript
107
- const authLimiter = createHitLimit({ limit: 5, window: '15m' })
108
-
109
- Bun.serve({
110
- async fetch(req, server) {
111
- const url = new URL(req.url)
112
-
113
- if (url.pathname.startsWith('/auth')) {
114
- const blocked = await authLimiter.check(req, server)
115
- if (blocked) return blocked
116
- }
117
-
118
- return handler(req, server)
119
- }
120
- })
121
- ```
122
-
123
- ### Tiered Rate Limits
124
-
125
- Different limits for different user tiers (free, pro, enterprise).
65
+ **Tiered limits** Free, Pro, Enterprise:
126
66
 
127
67
  ```typescript
128
68
  hitlimit({
129
- tiers: {
130
- free: { limit: 100, window: '1h' },
131
- pro: { limit: 5000, window: '1h' },
132
- enterprise: { limit: Infinity }
133
- },
69
+ tiers: { free: { limit: 100, window: '1h' }, pro: { limit: 5000, window: '1h' } },
134
70
  tier: (req) => req.headers.get('x-tier') || 'free'
135
71
  }, handler)
136
72
  ```
137
73
 
138
- ### Custom Rate Limit Keys
139
-
140
- Rate limit by IP address, user ID, API key, or any custom identifier.
141
-
142
- ```typescript
143
- hitlimit({
144
- key: (req) => req.headers.get('x-api-key') || 'anonymous'
145
- }, handler)
146
- ```
147
-
148
- ### Auto-Ban Repeat Offenders
149
-
150
- Automatically ban clients that repeatedly exceed rate limits.
74
+ **Auto-ban** Repeat offenders get blocked:
151
75
 
152
76
  ```typescript
153
- hitlimit({
154
- limit: 10,
155
- window: '1m',
156
- ban: {
157
- threshold: 5, // Ban after 5 violations
158
- duration: '1h' // Ban lasts 1 hour
159
- }
160
- }, handler)
77
+ hitlimit({ limit: 10, window: '1m', ban: { threshold: 5, duration: '1h' } }, handler)
161
78
  ```
162
79
 
163
- Banned clients receive `X-RateLimit-Ban: true` header and `banned: true` in the response body.
164
-
165
- ### Grouped / Shared Limits
166
-
167
- Rate limit by organization, API key, or any shared identifier.
80
+ **Custom keys** Rate limit by anything:
168
81
 
169
82
  ```typescript
170
- // Per-API-key rate limiting
171
- hitlimit({
172
- limit: 1000,
173
- window: '1h',
174
- group: (req) => req.headers.get('x-api-key') || 'anonymous'
175
- }, handler)
83
+ hitlimit({ key: (req) => req.headers.get('x-api-key') || 'anon' }, handler)
176
84
  ```
177
85
 
178
- ### Elysia Route-Specific Limits
179
-
180
- Apply different limits to different route groups in Elysia.
86
+ **Route-specific limits** (Elysia):
181
87
 
182
88
  ```typescript
183
89
  new Elysia()
184
- // Global limit
185
90
  .use(hitlimit({ limit: 100, window: '1m', name: 'global' }))
186
-
187
- // Stricter limit for auth
188
- .group('/auth', (app) =>
189
- app
190
- .use(hitlimit({ limit: 5, window: '15m', name: 'auth' }))
191
- .post('/login', handler)
192
- )
193
-
194
- // Higher limit for API
195
- .group('/api', (app) =>
196
- app
197
- .use(hitlimit({ limit: 1000, window: '1m', name: 'api' }))
198
- .get('/data', handler)
199
- )
91
+ .group('/auth', app => app.use(hitlimit({ limit: 5, window: '15m', name: 'auth' })))
200
92
  .listen(3000)
201
93
  ```
202
94
 
203
- ## Configuration Options
204
-
205
- ```typescript
206
- hitlimit({
207
- // Basic options
208
- limit: 100, // Max requests per window (default: 100)
209
- window: '1m', // Time window: 30s, 15m, 1h, 1d (default: '1m')
210
-
211
- // Custom key extraction
212
- key: (req) => req.headers.get('x-api-key') || 'anonymous',
213
-
214
- // Tiered rate limits
215
- tiers: {
216
- free: { limit: 100, window: '1h' },
217
- pro: { limit: 5000, window: '1h' },
218
- enterprise: { limit: Infinity }
219
- },
220
- tier: (req) => req.headers.get('x-tier') || 'free',
221
-
222
- // Custom 429 response
223
- response: {
224
- message: 'Too many requests',
225
- statusCode: 429
226
- },
227
- // Or function:
228
- response: (info) => ({
229
- error: 'RATE_LIMITED',
230
- retryIn: info.resetIn
231
- }),
232
-
233
- // Headers configuration
234
- headers: {
235
- standard: true, // RateLimit-* headers
236
- legacy: true, // X-RateLimit-* headers
237
- retryAfter: true // Retry-After header on 429
238
- },
239
-
240
- // Store (default: memory)
241
- store: sqliteStore({ path: './ratelimit.db' }),
242
-
243
- // Skip rate limiting
244
- skip: (req) => req.url.includes('/health'),
245
-
246
- // Error handling
247
- onStoreError: (error, req) => {
248
- console.error('Store error:', error)
249
- return 'allow' // or 'deny'
250
- },
251
-
252
- // Ban repeat offenders
253
- ban: {
254
- threshold: 5, // violations before ban
255
- duration: '1h' // ban duration
256
- },
257
-
258
- // Group/shared limits
259
- group: (req) => req.headers.get('x-api-key') || 'default'
260
- }, handler)
261
- ```
95
+ ---
262
96
 
263
- ## Storage Backends
97
+ ## 6 Storage Backends
264
98
 
265
- ### Memory Store (Default)
99
+ All built in. No extra packages to install.
266
100
 
267
- Fastest option, used by default. No persistence.
101
+ | Store | Best For | Peer Dependency |
102
+ |---|---|---|
103
+ | Memory | Development, single server | None |
104
+ | bun:sqlite | Single server + persistence | None (built-in) |
105
+ | Redis | Distributed, production | `ioredis` |
106
+ | **Valkey** | **Distributed, open-source Redis alternative** | `ioredis` |
107
+ | **DragonflyDB** | **High-throughput distributed** | `ioredis` |
108
+ | PostgreSQL | Shared database infrastructure | `pg` |
268
109
 
269
110
  ```typescript
270
111
  import { hitlimit } from '@joint-ops/hitlimit-bun'
271
112
 
272
- // Default - uses memory store (no config needed)
273
- Bun.serve({
274
- fetch: hitlimit({}, handler)
275
- })
276
- ```
113
+ // Memory (default) fastest, no config
114
+ Bun.serve({ fetch: hitlimit({}, handler) })
115
+
116
+ // bun:sqlite — persists across restarts, native performance
117
+ import { sqliteStore } from '@joint-ops/hitlimit-bun'
118
+ Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) }, handler) })
119
+
120
+ // Redis — distributed, atomic Lua scripts
121
+ import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
122
+ Bun.serve({ fetch: hitlimit({ store: redisStore({ url: 'redis://localhost:6379' }) }, handler) })
277
123
 
278
- ### SQLite Store
124
+ // Valkey — open-source Redis alternative
125
+ import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
126
+ Bun.serve({ fetch: hitlimit({ store: valkeyStore({ url: 'redis://localhost:6379' }) }, handler) })
279
127
 
280
- Uses Bun's native bun:sqlite for persistent rate limiting.
128
+ // DragonflyDB high-throughput Redis alternative
129
+ import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
130
+ Bun.serve({ fetch: hitlimit({ store: dragonflyStore({ url: 'redis://localhost:6379' }) }, handler) })
281
131
 
132
+ // Postgres — distributed, atomic upserts
133
+ import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'
134
+ Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:5432/mydb' }) }, handler) })
135
+ ```
136
+
137
+ | Store | Ops/sec | Latency | When to use |
138
+ |-------|---------|---------|-------------|
139
+ | Memory | 8,320,000 | 120ns | Single server, maximum speed |
140
+ | bun:sqlite | 325,000 | 3.1μs | Single server, need persistence |
141
+ | Redis | 6,700 | 148μs | Multi-server / distributed |
142
+ | Postgres | 3,700 | 273μs | Multi-server / already using Postgres |
143
+
144
+ ### Valkey (Redis Alternative)
282
145
  ```typescript
283
146
  import { hitlimit } from '@joint-ops/hitlimit-bun'
284
- import { sqliteStore } from '@joint-ops/hitlimit-bun'
147
+ import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
285
148
 
286
149
  Bun.serve({
287
150
  fetch: hitlimit({
288
- store: sqliteStore({ path: './ratelimit.db' })
151
+ store: valkeyStore({ url: 'redis://localhost:6379' }),
152
+ limit: 100,
153
+ window: '1m'
289
154
  }, handler)
290
155
  })
291
156
  ```
292
157
 
293
- ### Redis Store
294
-
295
- For distributed systems and multi-server deployments. Uses atomic Lua scripts — single-roundtrip with EVALSHA caching.
296
-
158
+ ### DragonflyDB
297
159
  ```typescript
298
160
  import { hitlimit } from '@joint-ops/hitlimit-bun'
299
- import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
161
+ import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
300
162
 
301
163
  Bun.serve({
302
164
  fetch: hitlimit({
303
- store: redisStore({ url: 'redis://localhost:6379' })
165
+ store: dragonflyStore({ url: 'redis://localhost:6379' }),
166
+ limit: 100,
167
+ window: '1m'
304
168
  }, handler)
305
169
  })
306
170
  ```
307
171
 
308
- ## Response Headers
309
-
310
- Every response includes rate limit information:
311
-
312
- ```
313
- RateLimit-Limit: 100
314
- RateLimit-Remaining: 99
315
- RateLimit-Reset: 1234567890
316
- X-RateLimit-Limit: 100
317
- X-RateLimit-Remaining: 99
318
- X-RateLimit-Reset: 1234567890
319
- ```
320
-
321
- When rate limited (429 Too Many Requests):
322
-
323
- ```
324
- Retry-After: 42
325
- ```
326
-
327
- ## Default 429 Response
328
-
329
- ```json
330
- {
331
- "hitlimit": true,
332
- "message": "Whoa there! Rate limit exceeded.",
333
- "limit": 100,
334
- "remaining": 0,
335
- "resetIn": 42
336
- }
337
- ```
172
+ ---
338
173
 
339
174
  ## Performance
340
175
 
341
- hitlimit-bun is optimized for Bun's runtime with native performance:
342
-
343
- ### Store Benchmarks
176
+ ### Bun vs Node.js Memory Store, 10K unique IPs
344
177
 
345
- | Store | Operations/sec | vs Node.js |
346
- |-------|----------------|------------|
347
- | **Memory** | 5,620,000+ | +68% faster |
348
- | **bun:sqlite** | 383,000+ | ~same |
349
- | **Redis** | 6,800+ | ~same |
350
-
351
- ### HTTP Throughput
352
-
353
- | Framework | With hitlimit-bun | Overhead |
354
- |-----------|-------------------|----------|
355
- | **Bun.serve** | 105,000 req/s | 12% |
356
- | **Elysia** | 115,000 req/s | 11% |
357
-
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.
359
-
360
- ### Why bun:sqlite is So Fast
361
-
362
- ```
363
- Node.js (better-sqlite3) Bun (bun:sqlite)
364
- ───────────────────────── ─────────────────
365
- JavaScript JavaScript
366
- ↓ ↓
367
- N-API Direct Call
368
- ↓ ↓
369
- C++ Binding Native SQLite
370
- ↓ (No overhead!)
371
- SQLite
372
- ```
178
+ | Runtime | Ops/sec | |
179
+ |---------|---------|---|
180
+ | **Bun** | **8,320,000** | ████████████████████ |
181
+ | Node.js | 3,160,000 | ████████ |
373
182
 
374
- better-sqlite3 uses N-API bindings with C++ overhead.
375
- bun:sqlite calls SQLite directly from Bun's native layer.
183
+ Bun leads at 10K IPs (8.32M vs 3.16M) and single-IP (12.38M vs 4.83M). Same library, same algorithm, **memory store**. For Redis, Postgres, and cross-store breakdowns, see the [full benchmark results](https://github.com/JointOps/hitlimit-monorepo/tree/main/benchmarks). Controlled-environment microbenchmarks with transparent methodology. Run them yourself.
376
184
 
377
- <details>
378
- <summary>Run benchmarks yourself</summary>
185
+ ### Why bun:sqlite is faster than better-sqlite3
379
186
 
380
- ```bash
381
- git clone https://github.com/JointOps/hitlimit-monorepo
382
- cd hitlimit-monorepo
383
- bun install
384
- bun run benchmark:bun
385
187
  ```
386
-
387
- </details>
388
-
389
- ## Elysia Plugin Options
390
-
391
- ```typescript
392
- import { Elysia } from 'elysia'
393
- import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
394
-
395
- new Elysia()
396
- .use(hitlimit({
397
- limit: 100,
398
- window: '1m',
399
- key: ({ request }) => request.headers.get('x-api-key') || 'anonymous',
400
- tiers: {
401
- free: { limit: 100, window: '1h' },
402
- pro: { limit: 5000, window: '1h' }
403
- },
404
- tier: ({ request }) => request.headers.get('x-tier') || 'free'
405
- }))
406
- .get('/', () => 'Hello!')
407
- .listen(3000)
188
+ Node.js: JS → N-API → C++ binding → SQLite
189
+ Bun: JS → Native call → SQLite (no overhead)
408
190
  ```
409
191
 
410
- ## Related Packages
411
-
412
- - [@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit) - Node.js rate limiting for Express, Fastify, Hono, NestJS
192
+ No N-API. No C++ bindings. No FFI. Bun calls SQLite directly.
413
193
 
414
- ## Why Not Use Node.js Rate Limiters in Bun?
194
+ ---
415
195
 
416
- 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.
196
+ ## Related
417
197
 
418
- **hitlimit-bun** is built specifically for Bun:
419
- - Uses native `bun:sqlite` (no N-API overhead)
420
- - Atomic Redis Lua scripts for distributed deployments
421
- - No FFI overhead or Node.js polyfills
422
- - First-class Bun.serve, Elysia, and Hono support
198
+ - **[@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit)** Node.js variant for Express, Fastify, Hono, NestJS
423
199
 
424
200
  ## License
425
201
 
426
- MIT - Use freely in personal and commercial projects.
427
-
202
+ MIT
@@ -0,0 +1,9 @@
1
+ import type { HitLimitStore } from '@joint-ops/hitlimit-types';
2
+ export interface DragonflyStoreOptions {
3
+ /** DragonflyDB connection URL. Default: 'redis://localhost:6379' */
4
+ url?: string;
5
+ /** Key prefix for all rate limit keys. Default: 'hitlimit:' */
6
+ keyPrefix?: string;
7
+ }
8
+ export declare function dragonflyStore(options?: DragonflyStoreOptions): HitLimitStore;
9
+ //# sourceMappingURL=dragonfly.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"dragonfly.d.ts","sourceRoot":"","sources":["../../src/stores/dragonfly.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAG9D,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,cAAc,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,aAAa,CAE7E"}
@@ -0,0 +1,133 @@
1
+ // @bun
2
+ // src/stores/redis.ts
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
+ `;
54
+
55
+ class RedisStore {
56
+ redis;
57
+ prefix;
58
+ banPrefix;
59
+ violationPrefix;
60
+ constructor(options = {}) {
61
+ this.redis = new Redis(options.url ?? "redis://localhost:6379");
62
+ this.prefix = options.keyPrefix ?? "hitlimit:";
63
+ this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
64
+ this.violationPrefix = (options.keyPrefix ?? "hitlimit:") + "violations:";
65
+ this.redis.defineCommand("hitlimitHit", {
66
+ numberOfKeys: 1,
67
+ lua: HIT_SCRIPT
68
+ });
69
+ this.redis.defineCommand("hitlimitHitWithBan", {
70
+ numberOfKeys: 3,
71
+ lua: HIT_WITH_BAN_SCRIPT
72
+ });
73
+ }
74
+ async hit(key, windowMs, _limit) {
75
+ const redisKey = this.prefix + key;
76
+ const result = await this.redis.hitlimitHit(redisKey, windowMs);
77
+ const count = result[0];
78
+ const ttl = result[1];
79
+ return { count, resetAt: Date.now() + ttl };
80
+ }
81
+ async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
82
+ const hitKey = this.prefix + key;
83
+ const banKey = this.banPrefix + key;
84
+ const violationKey = this.violationPrefix + key;
85
+ const result = await this.redis.hitlimitHitWithBan(hitKey, banKey, violationKey, windowMs, limit, banThreshold, banDurationMs);
86
+ const count = result[0];
87
+ const ttl = result[1];
88
+ const banned = result[2] === 1;
89
+ const violations = result[3];
90
+ if (count === -1) {
91
+ return { count: 0, resetAt: Date.now() + ttl, banned: true, violations: 0, banExpiresAt: Date.now() + ttl };
92
+ }
93
+ return {
94
+ count,
95
+ resetAt: Date.now() + ttl,
96
+ banned,
97
+ violations,
98
+ banExpiresAt: banned ? Date.now() + banDurationMs : 0
99
+ };
100
+ }
101
+ async isBanned(key) {
102
+ const result = await this.redis.exists(this.banPrefix + key);
103
+ return result === 1;
104
+ }
105
+ async ban(key, durationMs) {
106
+ await this.redis.set(this.banPrefix + key, "1", "PX", durationMs);
107
+ }
108
+ async recordViolation(key, windowMs) {
109
+ const redisKey = this.violationPrefix + key;
110
+ const count = await this.redis.incr(redisKey);
111
+ if (count === 1) {
112
+ await this.redis.pexpire(redisKey, windowMs);
113
+ }
114
+ return count;
115
+ }
116
+ async reset(key) {
117
+ await this.redis.del(this.prefix + key, this.banPrefix + key, this.violationPrefix + key);
118
+ }
119
+ async shutdown() {
120
+ await this.redis.quit();
121
+ }
122
+ }
123
+ function redisStore(options) {
124
+ return new RedisStore(options);
125
+ }
126
+
127
+ // src/stores/dragonfly.ts
128
+ function dragonflyStore(options) {
129
+ return new RedisStore(options);
130
+ }
131
+ export {
132
+ dragonflyStore
133
+ };
@@ -0,0 +1,9 @@
1
+ import type { HitLimitStore } from '@joint-ops/hitlimit-types';
2
+ export interface PostgresStoreOptions {
3
+ pool: any;
4
+ tablePrefix?: string;
5
+ cleanupInterval?: number;
6
+ skipTableCreation?: boolean;
7
+ }
8
+ export declare function postgresStore(options: PostgresStoreOptions): HitLimitStore;
9
+ //# sourceMappingURL=postgres.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../src/stores/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,2BAA2B,CAAA;AAE7F,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,GAAG,CAAA;IACT,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAqPD,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CAE1E"}
@@ -0,0 +1,220 @@
1
+ // @bun
2
+ // src/stores/postgres.ts
3
+ class PostgresStore {
4
+ pool;
5
+ cleanupTimer = null;
6
+ tablesReady = null;
7
+ ready;
8
+ hitQ;
9
+ banCheckQ;
10
+ hitUpsertQ;
11
+ violationQ;
12
+ banSetQ;
13
+ isBannedQ;
14
+ banQ;
15
+ recordViolationQ;
16
+ resetHitsQ;
17
+ resetBansQ;
18
+ resetViolationsQ;
19
+ cleanupHitsQ;
20
+ cleanupBansQ;
21
+ cleanupViolationsQ;
22
+ constructor(options) {
23
+ this.pool = options.pool;
24
+ const t = options.tablePrefix ?? "hitlimit";
25
+ const skipTableCreation = options.skipTableCreation ?? false;
26
+ if (skipTableCreation) {
27
+ this.ready = true;
28
+ } else {
29
+ this.ready = false;
30
+ this.tablesReady = this.createTables(t).then(() => {
31
+ this.ready = true;
32
+ });
33
+ }
34
+ this.hitQ = {
35
+ name: `hl_hit_${t}`,
36
+ text: `INSERT INTO ${t}_hits (key, count, reset_at) VALUES ($1, 1, $2) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN ${t}_hits.reset_at <= $3 THEN 1 ELSE ${t}_hits.count + 1 END, reset_at = CASE WHEN ${t}_hits.reset_at <= $3 THEN $2 ELSE ${t}_hits.reset_at END RETURNING count, reset_at`
37
+ };
38
+ this.banCheckQ = {
39
+ name: `hl_banchk_${t}`,
40
+ text: `SELECT expires_at FROM ${t}_bans WHERE key = $1 AND expires_at > $2`
41
+ };
42
+ this.hitUpsertQ = this.hitQ;
43
+ this.violationQ = {
44
+ name: `hl_viol_${t}`,
45
+ text: `INSERT INTO ${t}_violations (key, count, reset_at) VALUES ($1, 1, $2) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN ${t}_violations.reset_at <= $3 THEN 1 ELSE ${t}_violations.count + 1 END, reset_at = CASE WHEN ${t}_violations.reset_at <= $3 THEN $2 ELSE ${t}_violations.reset_at END RETURNING count`
46
+ };
47
+ this.banSetQ = {
48
+ name: `hl_banset_${t}`,
49
+ text: `INSERT INTO ${t}_bans (key, expires_at) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET expires_at = $2`
50
+ };
51
+ this.isBannedQ = {
52
+ name: `hl_isbanned_${t}`,
53
+ text: `SELECT 1 FROM ${t}_bans WHERE key = $1 AND expires_at > $2`
54
+ };
55
+ this.banQ = this.banSetQ;
56
+ this.recordViolationQ = this.violationQ;
57
+ this.resetHitsQ = `DELETE FROM ${t}_hits WHERE key = $1`;
58
+ this.resetBansQ = `DELETE FROM ${t}_bans WHERE key = $1`;
59
+ this.resetViolationsQ = `DELETE FROM ${t}_violations WHERE key = $1`;
60
+ this.cleanupHitsQ = `DELETE FROM ${t}_hits WHERE reset_at <= $1`;
61
+ this.cleanupBansQ = `DELETE FROM ${t}_bans WHERE expires_at <= $1`;
62
+ this.cleanupViolationsQ = `DELETE FROM ${t}_violations WHERE reset_at <= $1`;
63
+ const interval = options.cleanupInterval ?? 60000;
64
+ this.cleanupTimer = setInterval(() => this.cleanup(), interval);
65
+ if (typeof this.cleanupTimer.unref === "function") {
66
+ this.cleanupTimer.unref();
67
+ }
68
+ }
69
+ async createTables(t) {
70
+ await this.pool.query(`
71
+ CREATE TABLE IF NOT EXISTS ${t}_hits (
72
+ key TEXT PRIMARY KEY,
73
+ count INTEGER NOT NULL DEFAULT 1,
74
+ reset_at BIGINT NOT NULL
75
+ )
76
+ `);
77
+ await this.pool.query(`
78
+ CREATE TABLE IF NOT EXISTS ${t}_bans (
79
+ key TEXT PRIMARY KEY,
80
+ expires_at BIGINT NOT NULL
81
+ )
82
+ `);
83
+ await this.pool.query(`
84
+ CREATE TABLE IF NOT EXISTS ${t}_violations (
85
+ key TEXT PRIMARY KEY,
86
+ count INTEGER NOT NULL DEFAULT 1,
87
+ reset_at BIGINT NOT NULL
88
+ )
89
+ `);
90
+ }
91
+ async hit(key, windowMs, _limit) {
92
+ if (!this.ready)
93
+ await this.tablesReady;
94
+ const now = Date.now();
95
+ const result = await this.pool.query({
96
+ name: this.hitQ.name,
97
+ text: this.hitQ.text,
98
+ values: [key, now + windowMs, now]
99
+ });
100
+ return {
101
+ count: result.rows[0].count,
102
+ resetAt: Number(result.rows[0].reset_at)
103
+ };
104
+ }
105
+ async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
106
+ if (!this.ready)
107
+ await this.tablesReady;
108
+ const now = Date.now();
109
+ const resetAt = now + windowMs;
110
+ const banExpiresAt = now + banDurationMs;
111
+ const banResult = await this.pool.query({
112
+ name: this.banCheckQ.name,
113
+ text: this.banCheckQ.text,
114
+ values: [key, now]
115
+ });
116
+ if (banResult.rowCount > 0) {
117
+ return {
118
+ count: 0,
119
+ resetAt,
120
+ banned: true,
121
+ violations: 0,
122
+ banExpiresAt: Number(banResult.rows[0].expires_at)
123
+ };
124
+ }
125
+ const hitResult = await this.pool.query({
126
+ name: this.hitUpsertQ.name,
127
+ text: this.hitUpsertQ.text,
128
+ values: [key, resetAt, now]
129
+ });
130
+ const hitCount = hitResult.rows[0].count;
131
+ const hitResetAt = Number(hitResult.rows[0].reset_at);
132
+ if (hitCount > limit) {
133
+ const violationResult = await this.pool.query({
134
+ name: this.violationQ.name,
135
+ text: this.violationQ.text,
136
+ values: [key, banExpiresAt, now]
137
+ });
138
+ const violations = violationResult.rows[0].count;
139
+ const shouldBan = violations >= banThreshold;
140
+ if (shouldBan) {
141
+ await this.pool.query({
142
+ name: this.banSetQ.name,
143
+ text: this.banSetQ.text,
144
+ values: [key, banExpiresAt]
145
+ });
146
+ }
147
+ return {
148
+ count: hitCount,
149
+ resetAt: hitResetAt,
150
+ banned: shouldBan,
151
+ violations,
152
+ banExpiresAt: shouldBan ? banExpiresAt : 0
153
+ };
154
+ }
155
+ return {
156
+ count: hitCount,
157
+ resetAt: hitResetAt,
158
+ banned: false,
159
+ violations: 0,
160
+ banExpiresAt: 0
161
+ };
162
+ }
163
+ async isBanned(key) {
164
+ if (!this.ready)
165
+ await this.tablesReady;
166
+ const result = await this.pool.query({
167
+ name: this.isBannedQ.name,
168
+ text: this.isBannedQ.text,
169
+ values: [key, Date.now()]
170
+ });
171
+ return result.rowCount > 0;
172
+ }
173
+ async ban(key, durationMs) {
174
+ if (!this.ready)
175
+ await this.tablesReady;
176
+ await this.pool.query({
177
+ name: this.banQ.name,
178
+ text: this.banQ.text,
179
+ values: [key, Date.now() + durationMs]
180
+ });
181
+ }
182
+ async recordViolation(key, windowMs) {
183
+ if (!this.ready)
184
+ await this.tablesReady;
185
+ const now = Date.now();
186
+ const result = await this.pool.query({
187
+ name: this.recordViolationQ.name,
188
+ text: this.recordViolationQ.text,
189
+ values: [key, now + windowMs, now]
190
+ });
191
+ return result.rows[0].count;
192
+ }
193
+ async reset(key) {
194
+ if (!this.ready)
195
+ await this.tablesReady;
196
+ await this.pool.query(this.resetHitsQ, [key]);
197
+ await this.pool.query(this.resetBansQ, [key]);
198
+ await this.pool.query(this.resetViolationsQ, [key]);
199
+ }
200
+ async cleanup() {
201
+ try {
202
+ const now = Date.now();
203
+ await this.pool.query(this.cleanupHitsQ, [now]);
204
+ await this.pool.query(this.cleanupBansQ, [now]);
205
+ await this.pool.query(this.cleanupViolationsQ, [now]);
206
+ } catch {}
207
+ }
208
+ shutdown() {
209
+ if (this.cleanupTimer) {
210
+ clearInterval(this.cleanupTimer);
211
+ this.cleanupTimer = null;
212
+ }
213
+ }
214
+ }
215
+ function postgresStore(options) {
216
+ return new PostgresStore(options);
217
+ }
218
+ export {
219
+ postgresStore
220
+ };
@@ -1,7 +1,23 @@
1
- import type { HitLimitStore } from '@joint-ops/hitlimit-types';
1
+ import type { HitLimitStore, HitWithBanResult, StoreResult } from '@joint-ops/hitlimit-types';
2
2
  export interface RedisStoreOptions {
3
3
  url?: string;
4
4
  keyPrefix?: string;
5
5
  }
6
+ export declare const HIT_SCRIPT = "\nlocal key = KEYS[1]\nlocal windowMs = tonumber(ARGV[1])\nlocal count = redis.call('INCR', key)\nlocal ttl = redis.call('PTTL', key)\nif ttl < 0 then\n redis.call('PEXPIRE', key, windowMs)\n ttl = windowMs\nend\nreturn {count, ttl}\n";
7
+ export declare const HIT_WITH_BAN_SCRIPT = "\nlocal hitKey = KEYS[1]\nlocal banKey = KEYS[2]\nlocal violationKey = KEYS[3]\nlocal windowMs = tonumber(ARGV[1])\nlocal limit = tonumber(ARGV[2])\nlocal banThreshold = tonumber(ARGV[3])\nlocal banDurationMs = tonumber(ARGV[4])\n\n-- Check ban first\nlocal banTTL = redis.call('PTTL', banKey)\nif banTTL > 0 then\n return {-1, banTTL, 1, 0}\nend\n\n-- Hit counter\nlocal count = redis.call('INCR', hitKey)\nlocal ttl = redis.call('PTTL', hitKey)\nif ttl < 0 then\n redis.call('PEXPIRE', hitKey, windowMs)\n ttl = windowMs\nend\n\n-- Track violations if over limit\nlocal banned = 0\nlocal violations = 0\nif count > limit then\n violations = redis.call('INCR', violationKey)\n local vTTL = redis.call('PTTL', violationKey)\n if vTTL < 0 then\n redis.call('PEXPIRE', violationKey, banDurationMs)\n end\n if violations >= banThreshold then\n redis.call('SET', banKey, '1', 'PX', banDurationMs)\n banned = 1\n end\nend\nreturn {count, ttl, banned, violations}\n";
8
+ export declare class RedisStore implements HitLimitStore {
9
+ private redis;
10
+ private prefix;
11
+ private banPrefix;
12
+ private violationPrefix;
13
+ constructor(options?: RedisStoreOptions);
14
+ hit(key: string, windowMs: number, _limit: number): Promise<StoreResult>;
15
+ hitWithBan(key: string, windowMs: number, limit: number, banThreshold: number, banDurationMs: number): Promise<HitWithBanResult>;
16
+ isBanned(key: string): Promise<boolean>;
17
+ ban(key: string, durationMs: number): Promise<void>;
18
+ recordViolation(key: string, windowMs: number): Promise<number>;
19
+ reset(key: string): Promise<void>;
20
+ shutdown(): Promise<void>;
21
+ }
6
22
  export declare function redisStore(options?: RedisStoreOptions): HitLimitStore;
7
23
  //# sourceMappingURL=redis.d.ts.map
@@ -1 +1 @@
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
+ {"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAG7F,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAGD,eAAO,MAAM,UAAU,iPAUtB,CAAA;AAGD,eAAO,MAAM,mBAAmB,s9BAsC/B,CAAA;AAED,qBAAa,UAAW,YAAW,aAAa;IAC9C,OAAO,CAAC,KAAK,CAAO;IACpB,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,eAAe,CAAQ;gBAEnB,OAAO,GAAE,iBAAsB;IAiBrC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IASxE,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA4BhI,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKvC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAS/D,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQjC,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAGhC;AAED,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa,CAErE"}
@@ -57,51 +57,32 @@ class RedisStore {
57
57
  prefix;
58
58
  banPrefix;
59
59
  violationPrefix;
60
- hitSHA = null;
61
- hitWithBanSHA = null;
62
60
  constructor(options = {}) {
63
61
  this.redis = new Redis(options.url ?? "redis://localhost:6379");
64
62
  this.prefix = options.keyPrefix ?? "hitlimit:";
65
63
  this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
66
64
  this.violationPrefix = (options.keyPrefix ?? "hitlimit:") + "violations:";
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
- }
65
+ this.redis.defineCommand("hitlimitHit", {
66
+ numberOfKeys: 1,
67
+ lua: HIT_SCRIPT
68
+ });
69
+ this.redis.defineCommand("hitlimitHitWithBan", {
70
+ numberOfKeys: 3,
71
+ lua: HIT_WITH_BAN_SCRIPT
72
+ });
90
73
  }
91
74
  async hit(key, windowMs, _limit) {
92
- await this.loadScripts();
93
75
  const redisKey = this.prefix + key;
94
- const result = await this.evalScript(this.hitSHA, HIT_SCRIPT, [redisKey], [windowMs]);
76
+ const result = await this.redis.hitlimitHit(redisKey, windowMs);
95
77
  const count = result[0];
96
78
  const ttl = result[1];
97
79
  return { count, resetAt: Date.now() + ttl };
98
80
  }
99
81
  async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
100
- await this.loadScripts();
101
82
  const hitKey = this.prefix + key;
102
83
  const banKey = this.banPrefix + key;
103
84
  const violationKey = this.violationPrefix + key;
104
- const result = await this.evalScript(this.hitWithBanSHA, HIT_WITH_BAN_SCRIPT, [hitKey, banKey, violationKey], [windowMs, limit, banThreshold, banDurationMs]);
85
+ const result = await this.redis.hitlimitHitWithBan(hitKey, banKey, violationKey, windowMs, limit, banThreshold, banDurationMs);
105
86
  const count = result[0];
106
87
  const ttl = result[1];
107
88
  const banned = result[2] === 1;
@@ -143,5 +124,8 @@ function redisStore(options) {
143
124
  return new RedisStore(options);
144
125
  }
145
126
  export {
146
- redisStore
127
+ redisStore,
128
+ RedisStore,
129
+ HIT_WITH_BAN_SCRIPT,
130
+ HIT_SCRIPT
147
131
  };
@@ -0,0 +1,9 @@
1
+ import type { HitLimitStore } from '@joint-ops/hitlimit-types';
2
+ export interface ValkeyStoreOptions {
3
+ /** Valkey connection URL. Default: 'redis://localhost:6379' */
4
+ url?: string;
5
+ /** Key prefix for all rate limit keys. Default: 'hitlimit:' */
6
+ keyPrefix?: string;
7
+ }
8
+ export declare function valkeyStore(options?: ValkeyStoreOptions): HitLimitStore;
9
+ //# sourceMappingURL=valkey.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"valkey.d.ts","sourceRoot":"","sources":["../../src/stores/valkey.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAG9D,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
@@ -0,0 +1,133 @@
1
+ // @bun
2
+ // src/stores/redis.ts
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
+ `;
54
+
55
+ class RedisStore {
56
+ redis;
57
+ prefix;
58
+ banPrefix;
59
+ violationPrefix;
60
+ constructor(options = {}) {
61
+ this.redis = new Redis(options.url ?? "redis://localhost:6379");
62
+ this.prefix = options.keyPrefix ?? "hitlimit:";
63
+ this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
64
+ this.violationPrefix = (options.keyPrefix ?? "hitlimit:") + "violations:";
65
+ this.redis.defineCommand("hitlimitHit", {
66
+ numberOfKeys: 1,
67
+ lua: HIT_SCRIPT
68
+ });
69
+ this.redis.defineCommand("hitlimitHitWithBan", {
70
+ numberOfKeys: 3,
71
+ lua: HIT_WITH_BAN_SCRIPT
72
+ });
73
+ }
74
+ async hit(key, windowMs, _limit) {
75
+ const redisKey = this.prefix + key;
76
+ const result = await this.redis.hitlimitHit(redisKey, windowMs);
77
+ const count = result[0];
78
+ const ttl = result[1];
79
+ return { count, resetAt: Date.now() + ttl };
80
+ }
81
+ async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
82
+ const hitKey = this.prefix + key;
83
+ const banKey = this.banPrefix + key;
84
+ const violationKey = this.violationPrefix + key;
85
+ const result = await this.redis.hitlimitHitWithBan(hitKey, banKey, violationKey, windowMs, limit, banThreshold, banDurationMs);
86
+ const count = result[0];
87
+ const ttl = result[1];
88
+ const banned = result[2] === 1;
89
+ const violations = result[3];
90
+ if (count === -1) {
91
+ return { count: 0, resetAt: Date.now() + ttl, banned: true, violations: 0, banExpiresAt: Date.now() + ttl };
92
+ }
93
+ return {
94
+ count,
95
+ resetAt: Date.now() + ttl,
96
+ banned,
97
+ violations,
98
+ banExpiresAt: banned ? Date.now() + banDurationMs : 0
99
+ };
100
+ }
101
+ async isBanned(key) {
102
+ const result = await this.redis.exists(this.banPrefix + key);
103
+ return result === 1;
104
+ }
105
+ async ban(key, durationMs) {
106
+ await this.redis.set(this.banPrefix + key, "1", "PX", durationMs);
107
+ }
108
+ async recordViolation(key, windowMs) {
109
+ const redisKey = this.violationPrefix + key;
110
+ const count = await this.redis.incr(redisKey);
111
+ if (count === 1) {
112
+ await this.redis.pexpire(redisKey, windowMs);
113
+ }
114
+ return count;
115
+ }
116
+ async reset(key) {
117
+ await this.redis.del(this.prefix + key, this.banPrefix + key, this.violationPrefix + key);
118
+ }
119
+ async shutdown() {
120
+ await this.redis.quit();
121
+ }
122
+ }
123
+ function redisStore(options) {
124
+ return new RedisStore(options);
125
+ }
126
+
127
+ // src/stores/valkey.ts
128
+ function valkeyStore(options) {
129
+ return new RedisStore(options);
130
+ }
131
+ export {
132
+ valkeyStore
133
+ };
package/package.json CHANGED
@@ -1,12 +1,30 @@
1
1
  {
2
2
  "name": "@joint-ops/hitlimit-bun",
3
- "version": "1.1.3",
3
+ "version": "1.3.0",
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",
7
7
  "email": "shayanhussain48@gmail.com",
8
8
  "url": "https://github.com/ShayanHussainSB"
9
9
  },
10
+ "contributors": [
11
+ {
12
+ "name": "tanv33",
13
+ "url": "https://github.com/tanv33"
14
+ },
15
+ {
16
+ "name": "builtbyali",
17
+ "url": "https://github.com/builtbyali"
18
+ },
19
+ {
20
+ "name": "MuhammadRehanRasool",
21
+ "url": "https://github.com/MuhammadRehanRasool"
22
+ },
23
+ {
24
+ "name": "sultandilaram",
25
+ "url": "https://github.com/sultandilaram"
26
+ }
27
+ ],
10
28
  "license": "MIT",
11
29
  "homepage": "https://hitlimit.jointops.dev/docs/bun",
12
30
  "repository": {
@@ -68,7 +86,13 @@
68
86
  "bun-framework",
69
87
  "lightweight",
70
88
  "zero-dependency",
71
- "esm"
89
+ "esm",
90
+ "valkey",
91
+ "valkey-rate-limit",
92
+ "dragonfly",
93
+ "dragonflydb",
94
+ "dragonfly-rate-limit",
95
+ "redis-alternative"
72
96
  ],
73
97
  "type": "module",
74
98
  "main": "./dist/index.js",
@@ -104,6 +128,21 @@
104
128
  "types": "./dist/stores/sqlite.d.ts",
105
129
  "bun": "./dist/stores/sqlite.js",
106
130
  "import": "./dist/stores/sqlite.js"
131
+ },
132
+ "./stores/postgres": {
133
+ "types": "./dist/stores/postgres.d.ts",
134
+ "bun": "./dist/stores/postgres.js",
135
+ "import": "./dist/stores/postgres.js"
136
+ },
137
+ "./stores/valkey": {
138
+ "types": "./dist/stores/valkey.d.ts",
139
+ "bun": "./dist/stores/valkey.js",
140
+ "import": "./dist/stores/valkey.js"
141
+ },
142
+ "./stores/dragonfly": {
143
+ "types": "./dist/stores/dragonfly.d.ts",
144
+ "bun": "./dist/stores/dragonfly.js",
145
+ "import": "./dist/stores/dragonfly.js"
107
146
  }
108
147
  },
109
148
  "files": [
@@ -111,18 +150,19 @@
111
150
  ],
112
151
  "sideEffects": false,
113
152
  "scripts": {
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",
153
+ "build": "bun build ./src/index.ts ./src/elysia.ts ./src/hono.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts ./src/stores/postgres.ts ./src/stores/valkey.ts ./src/stores/dragonfly.ts --outdir=./dist --root=./src --target=bun --external=elysia --external=hono --external=ioredis --external=pg --external=@sinclair/typebox && tsc --emitDeclarationOnly",
115
154
  "clean": "rm -rf dist",
116
155
  "test": "bun test",
117
156
  "test:watch": "bun test --watch"
118
157
  },
119
158
  "dependencies": {
120
- "@joint-ops/hitlimit-types": "1.1.3"
159
+ "@joint-ops/hitlimit-types": "1.3.0"
121
160
  },
122
161
  "peerDependencies": {
123
162
  "elysia": ">=1.0.0",
124
163
  "hono": ">=4.0.0",
125
- "ioredis": ">=5.0.0"
164
+ "ioredis": ">=5.0.0",
165
+ "pg": ">=8.0.0"
126
166
  },
127
167
  "peerDependenciesMeta": {
128
168
  "elysia": {
@@ -133,14 +173,19 @@
133
173
  },
134
174
  "ioredis": {
135
175
  "optional": true
176
+ },
177
+ "pg": {
178
+ "optional": true
136
179
  }
137
180
  },
138
181
  "devDependencies": {
139
182
  "@sinclair/typebox": "^0.34.48",
140
183
  "@types/bun": "latest",
184
+ "@types/pg": "^8.11.0",
141
185
  "elysia": "^1.0.0",
142
186
  "hono": "^4.11.9",
143
187
  "ioredis": "^5.3.0",
188
+ "pg": "^8.13.0",
144
189
  "typescript": "^5.3.0"
145
190
  }
146
191
  }