@joint-ops/hitlimit-bun 1.1.3 → 1.2.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,116 @@ 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
+ ## 4 Storage Backends
264
98
 
265
- ### Memory Store (Default)
266
-
267
- Fastest option, used by default. No persistence.
99
+ All built in. No extra packages to install.
268
100
 
269
101
  ```typescript
270
102
  import { hitlimit } from '@joint-ops/hitlimit-bun'
271
103
 
272
- // Default - uses memory store (no config needed)
273
- Bun.serve({
274
- fetch: hitlimit({}, handler)
275
- })
276
- ```
104
+ // Memory (default) fastest, no config
105
+ Bun.serve({ fetch: hitlimit({}, handler) })
277
106
 
278
- ### SQLite Store
279
-
280
- Uses Bun's native bun:sqlite for persistent rate limiting.
281
-
282
- ```typescript
283
- import { hitlimit } from '@joint-ops/hitlimit-bun'
107
+ // bun:sqlite — persists across restarts, native performance
284
108
  import { sqliteStore } from '@joint-ops/hitlimit-bun'
109
+ Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) }, handler) })
285
110
 
286
- Bun.serve({
287
- fetch: hitlimit({
288
- store: sqliteStore({ path: './ratelimit.db' })
289
- }, handler)
290
- })
291
- ```
292
-
293
- ### Redis Store
294
-
295
- For distributed systems and multi-server deployments. Uses atomic Lua scripts — single-roundtrip with EVALSHA caching.
296
-
297
- ```typescript
298
- import { hitlimit } from '@joint-ops/hitlimit-bun'
111
+ // Redis — distributed, atomic Lua scripts
299
112
  import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
113
+ Bun.serve({ fetch: hitlimit({ store: redisStore({ url: 'redis://localhost:6379' }) }, handler) })
300
114
 
301
- Bun.serve({
302
- fetch: hitlimit({
303
- store: redisStore({ url: 'redis://localhost:6379' })
304
- }, handler)
305
- })
115
+ // Postgres — distributed, atomic upserts
116
+ import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'
117
+ Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:5432/mydb' }) }, handler) })
306
118
  ```
307
119
 
308
- ## Response Headers
120
+ | Store | Ops/sec | Latency | When to use |
121
+ |-------|---------|---------|-------------|
122
+ | Memory | 8,320,000 | 120ns | Single server, maximum speed |
123
+ | bun:sqlite | 325,000 | 3.1μs | Single server, need persistence |
124
+ | Redis | 6,700 | 148μs | Multi-server / distributed |
125
+ | Postgres | 3,700 | 273μs | Multi-server / already using Postgres |
309
126
 
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
- ```
127
+ ---
338
128
 
339
129
  ## Performance
340
130
 
341
- hitlimit-bun is optimized for Bun's runtime with native performance:
131
+ ### Bun vs Node.js Memory Store, 10K unique IPs
342
132
 
343
- ### Store Benchmarks
344
-
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
- ```
133
+ | Runtime | Ops/sec | |
134
+ |---------|---------|---|
135
+ | **Bun** | **8,320,000** | ████████████████████ |
136
+ | Node.js | 3,160,000 | ████████ |
373
137
 
374
- better-sqlite3 uses N-API bindings with C++ overhead.
375
- bun:sqlite calls SQLite directly from Bun's native layer.
138
+ 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
139
 
377
- <details>
378
- <summary>Run benchmarks yourself</summary>
140
+ ### Why bun:sqlite is faster than better-sqlite3
379
141
 
380
- ```bash
381
- git clone https://github.com/JointOps/hitlimit-monorepo
382
- cd hitlimit-monorepo
383
- bun install
384
- bun run benchmark:bun
385
142
  ```
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)
143
+ Node.js: JS → N-API → C++ binding → SQLite
144
+ Bun: JS → Native call → SQLite (no overhead)
408
145
  ```
409
146
 
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
147
+ No N-API. No C++ bindings. No FFI. Bun calls SQLite directly.
413
148
 
414
- ## Why Not Use Node.js Rate Limiters in Bun?
149
+ ---
415
150
 
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.
151
+ ## Related
417
152
 
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
153
+ - **[@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit)** Node.js variant for Express, Fastify, Hono, NestJS
423
154
 
424
155
  ## License
425
156
 
426
- MIT - Use freely in personal and commercial projects.
427
-
157
+ MIT
@@ -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 +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,EAAiC,MAAM,2BAA2B,CAAA;AAG7F,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAmJD,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;
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.2.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": {
@@ -104,6 +122,11 @@
104
122
  "types": "./dist/stores/sqlite.d.ts",
105
123
  "bun": "./dist/stores/sqlite.js",
106
124
  "import": "./dist/stores/sqlite.js"
125
+ },
126
+ "./stores/postgres": {
127
+ "types": "./dist/stores/postgres.d.ts",
128
+ "bun": "./dist/stores/postgres.js",
129
+ "import": "./dist/stores/postgres.js"
107
130
  }
108
131
  },
109
132
  "files": [
@@ -111,18 +134,19 @@
111
134
  ],
112
135
  "sideEffects": false,
113
136
  "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",
137
+ "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 --outdir=./dist --target=bun --external=elysia --external=hono --external=ioredis --external=pg --external=@sinclair/typebox && tsc --emitDeclarationOnly",
115
138
  "clean": "rm -rf dist",
116
139
  "test": "bun test",
117
140
  "test:watch": "bun test --watch"
118
141
  },
119
142
  "dependencies": {
120
- "@joint-ops/hitlimit-types": "1.1.3"
143
+ "@joint-ops/hitlimit-types": "1.2.0"
121
144
  },
122
145
  "peerDependencies": {
123
146
  "elysia": ">=1.0.0",
124
147
  "hono": ">=4.0.0",
125
- "ioredis": ">=5.0.0"
148
+ "ioredis": ">=5.0.0",
149
+ "pg": ">=8.0.0"
126
150
  },
127
151
  "peerDependenciesMeta": {
128
152
  "elysia": {
@@ -133,14 +157,19 @@
133
157
  },
134
158
  "ioredis": {
135
159
  "optional": true
160
+ },
161
+ "pg": {
162
+ "optional": true
136
163
  }
137
164
  },
138
165
  "devDependencies": {
139
166
  "@sinclair/typebox": "^0.34.48",
140
167
  "@types/bun": "latest",
168
+ "@types/pg": "^8.11.0",
141
169
  "elysia": "^1.0.0",
142
170
  "hono": "^4.11.9",
143
171
  "ioredis": "^5.3.0",
172
+ "pg": "^8.13.0",
144
173
  "typescript": "^5.3.0"
145
174
  }
146
175
  }