@joint-ops/hitlimit-bun 1.1.2 → 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 +64 -349
- package/dist/core/limiter.d.ts.map +1 -1
- package/dist/elysia.js +43 -8
- package/dist/hono.js +43 -8
- package/dist/index.js +43 -8
- package/dist/stores/postgres.d.ts +9 -0
- package/dist/stores/postgres.d.ts.map +1 -0
- package/dist/stores/postgres.js +220 -0
- package/dist/stores/redis.d.ts.map +1 -1
- package/dist/stores/redis.js +81 -9
- package/package.json +33 -4
package/README.md
CHANGED
|
@@ -1,61 +1,40 @@
|
|
|
1
1
|
# @joint-ops/hitlimit-bun
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[](https://www.npmjs.com/package/@joint-ops/hitlimit-bun)
|
|
5
|
-
[](https://github.com/JointOps/hitlimit-monorepo)
|
|
6
|
-
[](https://www.typescriptlang.org/)
|
|
7
|
-
[](https://bun.sh)
|
|
3
|
+
> Rate limiting built for Bun. Not ported — built.
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
**hitlimit-bun** is a blazing-fast, Bun-native rate limiting library for Bun.serve, Elysia, and Hono applications. **Memory-first by default** with 5.62M ops/sec under real-world load (~15x faster than SQLite). Optional persistence with native bun:sqlite or Redis when you need it.
|
|
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)**
|
|
14
|
-
|
|
15
|
-
## ⚡ Why hitlimit-bun?
|
|
16
|
-
|
|
17
|
-
**Memory-first for maximum performance.** ~15x faster than SQLite.
|
|
5
|
+
**12.38M ops/sec** on memory. **8.32M at 10K IPs**. Native bun:sqlite. Atomic Redis Lua. Postgres. Zero dependencies.
|
|
18
6
|
|
|
7
|
+
```bash
|
|
8
|
+
bun add @joint-ops/hitlimit-bun
|
|
19
9
|
```
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
│ ~15x performance improvement with memory default (10K IPs) │
|
|
26
|
-
│ │
|
|
27
|
-
└─────────────────────────────────────────────────────────────────┘
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
Bun.serve({
|
|
13
|
+
fetch: hitlimit({}, (req) => new Response('Hello!'))
|
|
14
|
+
})
|
|
28
15
|
```
|
|
29
16
|
|
|
30
|
-
|
|
31
|
-
- **Bun Native** - Built specifically for Bun's runtime, not a Node.js port
|
|
32
|
-
- **Zero Config** - Works out of the box with sensible defaults
|
|
33
|
-
- **Framework Support** - First-class Elysia and Hono integration
|
|
34
|
-
- **Optional Persistence** - SQLite (383K ops/sec) or Redis (6.6K ops/sec) when needed
|
|
35
|
-
- **TypeScript First** - Full type safety and IntelliSense support
|
|
36
|
-
- **Auto-Ban** - Automatically ban repeat offenders after threshold violations
|
|
37
|
-
- **Shared Limits** - Group rate limits via groupId for teams/tenants
|
|
38
|
-
- **Tiny Footprint** - ~18KB core bundle, zero runtime dependencies
|
|
17
|
+
One line. Done. Works with **Bun.serve**, **Elysia**, and **Hono** out of the box.
|
|
39
18
|
|
|
40
|
-
|
|
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)**
|
|
41
20
|
|
|
42
|
-
|
|
43
|
-
bun add @joint-ops/hitlimit-bun
|
|
44
|
-
```
|
|
21
|
+
---
|
|
45
22
|
|
|
46
|
-
##
|
|
23
|
+
## 30 Seconds to Production
|
|
47
24
|
|
|
48
|
-
### Bun.serve
|
|
25
|
+
### Bun.serve
|
|
49
26
|
|
|
50
27
|
```typescript
|
|
51
28
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
52
29
|
|
|
53
30
|
Bun.serve({
|
|
54
|
-
fetch: hitlimit({}, (req) =>
|
|
31
|
+
fetch: hitlimit({ limit: 100, window: '1m' }, (req) => {
|
|
32
|
+
return new Response('Hello!')
|
|
33
|
+
})
|
|
55
34
|
})
|
|
56
35
|
```
|
|
57
36
|
|
|
58
|
-
### Elysia
|
|
37
|
+
### Elysia
|
|
59
38
|
|
|
60
39
|
```typescript
|
|
61
40
|
import { Elysia } from 'elysia'
|
|
@@ -63,380 +42,116 @@ import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
|
|
|
63
42
|
|
|
64
43
|
new Elysia()
|
|
65
44
|
.use(hitlimit({ limit: 100, window: '1m' }))
|
|
66
|
-
.get('/', () => 'Hello
|
|
45
|
+
.get('/', () => 'Hello!')
|
|
67
46
|
.listen(3000)
|
|
68
47
|
```
|
|
69
48
|
|
|
70
|
-
### Hono
|
|
49
|
+
### Hono
|
|
71
50
|
|
|
72
51
|
```typescript
|
|
73
52
|
import { Hono } from 'hono'
|
|
74
53
|
import { hitlimit } from '@joint-ops/hitlimit-bun/hono'
|
|
75
54
|
|
|
76
55
|
const app = new Hono()
|
|
77
|
-
|
|
78
56
|
app.use(hitlimit({ limit: 100, window: '1m' }))
|
|
79
|
-
app.get('/', (c) => c.text('Hello
|
|
80
|
-
|
|
57
|
+
app.get('/', (c) => c.text('Hello!'))
|
|
81
58
|
Bun.serve({ port: 3000, fetch: app.fetch })
|
|
82
59
|
```
|
|
83
60
|
|
|
84
|
-
|
|
61
|
+
---
|
|
85
62
|
|
|
86
|
-
|
|
87
|
-
import { createHitLimit } from '@joint-ops/hitlimit-bun'
|
|
88
|
-
|
|
89
|
-
const limiter = createHitLimit({ limit: 100, window: '1m' })
|
|
63
|
+
## What You Get
|
|
90
64
|
|
|
91
|
-
|
|
92
|
-
async fetch(req, server) {
|
|
93
|
-
// Returns a 429 Response if blocked, or null if allowed
|
|
94
|
-
const blocked = await limiter.check(req, server)
|
|
95
|
-
if (blocked) return blocked
|
|
96
|
-
|
|
97
|
-
return new Response('Hello!')
|
|
98
|
-
}
|
|
99
|
-
})
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
## Features
|
|
103
|
-
|
|
104
|
-
### API Rate Limiting
|
|
105
|
-
|
|
106
|
-
Protect your Bun APIs from abuse with high-performance rate limiting.
|
|
107
|
-
|
|
108
|
-
```typescript
|
|
109
|
-
Bun.serve({
|
|
110
|
-
fetch: hitlimit({ limit: 1000, window: '1h' }, handler)
|
|
111
|
-
})
|
|
112
|
-
```
|
|
113
|
-
|
|
114
|
-
### Login & Authentication Protection
|
|
115
|
-
|
|
116
|
-
Prevent brute force attacks on login endpoints.
|
|
117
|
-
|
|
118
|
-
```typescript
|
|
119
|
-
const authLimiter = createHitLimit({ limit: 5, window: '15m' })
|
|
120
|
-
|
|
121
|
-
Bun.serve({
|
|
122
|
-
async fetch(req, server) {
|
|
123
|
-
const url = new URL(req.url)
|
|
124
|
-
|
|
125
|
-
if (url.pathname.startsWith('/auth')) {
|
|
126
|
-
const blocked = await authLimiter.check(req, server)
|
|
127
|
-
if (blocked) return blocked
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return handler(req, server)
|
|
131
|
-
}
|
|
132
|
-
})
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
### Tiered Rate Limits
|
|
136
|
-
|
|
137
|
-
Different limits for different user tiers (free, pro, enterprise).
|
|
65
|
+
**Tiered limits** — Free, Pro, Enterprise:
|
|
138
66
|
|
|
139
67
|
```typescript
|
|
140
68
|
hitlimit({
|
|
141
|
-
tiers: {
|
|
142
|
-
free: { limit: 100, window: '1h' },
|
|
143
|
-
pro: { limit: 5000, window: '1h' },
|
|
144
|
-
enterprise: { limit: Infinity }
|
|
145
|
-
},
|
|
69
|
+
tiers: { free: { limit: 100, window: '1h' }, pro: { limit: 5000, window: '1h' } },
|
|
146
70
|
tier: (req) => req.headers.get('x-tier') || 'free'
|
|
147
71
|
}, handler)
|
|
148
72
|
```
|
|
149
73
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
Rate limit by IP address, user ID, API key, or any custom identifier.
|
|
74
|
+
**Auto-ban** — Repeat offenders get blocked:
|
|
153
75
|
|
|
154
76
|
```typescript
|
|
155
|
-
hitlimit({
|
|
156
|
-
key: (req) => req.headers.get('x-api-key') || 'anonymous'
|
|
157
|
-
}, handler)
|
|
77
|
+
hitlimit({ limit: 10, window: '1m', ban: { threshold: 5, duration: '1h' } }, handler)
|
|
158
78
|
```
|
|
159
79
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
Automatically ban clients that repeatedly exceed rate limits.
|
|
80
|
+
**Custom keys** — Rate limit by anything:
|
|
163
81
|
|
|
164
82
|
```typescript
|
|
165
|
-
hitlimit({
|
|
166
|
-
limit: 10,
|
|
167
|
-
window: '1m',
|
|
168
|
-
ban: {
|
|
169
|
-
threshold: 5, // Ban after 5 violations
|
|
170
|
-
duration: '1h' // Ban lasts 1 hour
|
|
171
|
-
}
|
|
172
|
-
}, handler)
|
|
83
|
+
hitlimit({ key: (req) => req.headers.get('x-api-key') || 'anon' }, handler)
|
|
173
84
|
```
|
|
174
85
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
### Grouped / Shared Limits
|
|
178
|
-
|
|
179
|
-
Rate limit by organization, API key, or any shared identifier.
|
|
180
|
-
|
|
181
|
-
```typescript
|
|
182
|
-
// Per-API-key rate limiting
|
|
183
|
-
hitlimit({
|
|
184
|
-
limit: 1000,
|
|
185
|
-
window: '1h',
|
|
186
|
-
group: (req) => req.headers.get('x-api-key') || 'anonymous'
|
|
187
|
-
}, handler)
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
### Elysia Route-Specific Limits
|
|
191
|
-
|
|
192
|
-
Apply different limits to different route groups in Elysia.
|
|
86
|
+
**Route-specific limits** (Elysia):
|
|
193
87
|
|
|
194
88
|
```typescript
|
|
195
89
|
new Elysia()
|
|
196
|
-
// Global limit
|
|
197
90
|
.use(hitlimit({ limit: 100, window: '1m', name: 'global' }))
|
|
198
|
-
|
|
199
|
-
// Stricter limit for auth
|
|
200
|
-
.group('/auth', (app) =>
|
|
201
|
-
app
|
|
202
|
-
.use(hitlimit({ limit: 5, window: '15m', name: 'auth' }))
|
|
203
|
-
.post('/login', handler)
|
|
204
|
-
)
|
|
205
|
-
|
|
206
|
-
// Higher limit for API
|
|
207
|
-
.group('/api', (app) =>
|
|
208
|
-
app
|
|
209
|
-
.use(hitlimit({ limit: 1000, window: '1m', name: 'api' }))
|
|
210
|
-
.get('/data', handler)
|
|
211
|
-
)
|
|
91
|
+
.group('/auth', app => app.use(hitlimit({ limit: 5, window: '15m', name: 'auth' })))
|
|
212
92
|
.listen(3000)
|
|
213
93
|
```
|
|
214
94
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
```typescript
|
|
218
|
-
hitlimit({
|
|
219
|
-
// Basic options
|
|
220
|
-
limit: 100, // Max requests per window (default: 100)
|
|
221
|
-
window: '1m', // Time window: 30s, 15m, 1h, 1d (default: '1m')
|
|
222
|
-
|
|
223
|
-
// Custom key extraction
|
|
224
|
-
key: (req) => req.headers.get('x-api-key') || 'anonymous',
|
|
225
|
-
|
|
226
|
-
// Tiered rate limits
|
|
227
|
-
tiers: {
|
|
228
|
-
free: { limit: 100, window: '1h' },
|
|
229
|
-
pro: { limit: 5000, window: '1h' },
|
|
230
|
-
enterprise: { limit: Infinity }
|
|
231
|
-
},
|
|
232
|
-
tier: (req) => req.headers.get('x-tier') || 'free',
|
|
233
|
-
|
|
234
|
-
// Custom 429 response
|
|
235
|
-
response: {
|
|
236
|
-
message: 'Too many requests',
|
|
237
|
-
statusCode: 429
|
|
238
|
-
},
|
|
239
|
-
// Or function:
|
|
240
|
-
response: (info) => ({
|
|
241
|
-
error: 'RATE_LIMITED',
|
|
242
|
-
retryIn: info.resetIn
|
|
243
|
-
}),
|
|
244
|
-
|
|
245
|
-
// Headers configuration
|
|
246
|
-
headers: {
|
|
247
|
-
standard: true, // RateLimit-* headers
|
|
248
|
-
legacy: true, // X-RateLimit-* headers
|
|
249
|
-
retryAfter: true // Retry-After header on 429
|
|
250
|
-
},
|
|
251
|
-
|
|
252
|
-
// Store (default: memory)
|
|
253
|
-
store: sqliteStore({ path: './ratelimit.db' }),
|
|
254
|
-
|
|
255
|
-
// Skip rate limiting
|
|
256
|
-
skip: (req) => req.url.includes('/health'),
|
|
257
|
-
|
|
258
|
-
// Error handling
|
|
259
|
-
onStoreError: (error, req) => {
|
|
260
|
-
console.error('Store error:', error)
|
|
261
|
-
return 'allow' // or 'deny'
|
|
262
|
-
},
|
|
263
|
-
|
|
264
|
-
// Ban repeat offenders
|
|
265
|
-
ban: {
|
|
266
|
-
threshold: 5, // violations before ban
|
|
267
|
-
duration: '1h' // ban duration
|
|
268
|
-
},
|
|
269
|
-
|
|
270
|
-
// Group/shared limits
|
|
271
|
-
group: (req) => req.headers.get('x-api-key') || 'default'
|
|
272
|
-
}, handler)
|
|
273
|
-
```
|
|
95
|
+
---
|
|
274
96
|
|
|
275
|
-
## Storage Backends
|
|
97
|
+
## 4 Storage Backends
|
|
276
98
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
Fastest option, used by default. No persistence.
|
|
99
|
+
All built in. No extra packages to install.
|
|
280
100
|
|
|
281
101
|
```typescript
|
|
282
102
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
283
103
|
|
|
284
|
-
//
|
|
285
|
-
Bun.serve({
|
|
286
|
-
fetch: hitlimit({}, handler)
|
|
287
|
-
})
|
|
288
|
-
```
|
|
104
|
+
// Memory (default) — fastest, no config
|
|
105
|
+
Bun.serve({ fetch: hitlimit({}, handler) })
|
|
289
106
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
Uses Bun's native bun:sqlite for persistent rate limiting.
|
|
293
|
-
|
|
294
|
-
```typescript
|
|
295
|
-
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
107
|
+
// bun:sqlite — persists across restarts, native performance
|
|
296
108
|
import { sqliteStore } from '@joint-ops/hitlimit-bun'
|
|
109
|
+
Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) }, handler) })
|
|
297
110
|
|
|
298
|
-
|
|
299
|
-
fetch: hitlimit({
|
|
300
|
-
store: sqliteStore({ path: './ratelimit.db' })
|
|
301
|
-
}, handler)
|
|
302
|
-
})
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
### Redis Store
|
|
306
|
-
|
|
307
|
-
For distributed systems and multi-server deployments.
|
|
308
|
-
|
|
309
|
-
```typescript
|
|
310
|
-
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
111
|
+
// Redis — distributed, atomic Lua scripts
|
|
311
112
|
import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
|
|
113
|
+
Bun.serve({ fetch: hitlimit({ store: redisStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
312
114
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}, handler)
|
|
317
|
-
})
|
|
318
|
-
```
|
|
319
|
-
|
|
320
|
-
## Response Headers
|
|
321
|
-
|
|
322
|
-
Every response includes rate limit information:
|
|
323
|
-
|
|
324
|
-
```
|
|
325
|
-
RateLimit-Limit: 100
|
|
326
|
-
RateLimit-Remaining: 99
|
|
327
|
-
RateLimit-Reset: 1234567890
|
|
328
|
-
X-RateLimit-Limit: 100
|
|
329
|
-
X-RateLimit-Remaining: 99
|
|
330
|
-
X-RateLimit-Reset: 1234567890
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
When rate limited (429 Too Many Requests):
|
|
334
|
-
|
|
335
|
-
```
|
|
336
|
-
Retry-After: 42
|
|
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) })
|
|
337
118
|
```
|
|
338
119
|
|
|
339
|
-
|
|
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 |
|
|
340
126
|
|
|
341
|
-
|
|
342
|
-
{
|
|
343
|
-
"hitlimit": true,
|
|
344
|
-
"message": "Whoa there! Rate limit exceeded.",
|
|
345
|
-
"limit": 100,
|
|
346
|
-
"remaining": 0,
|
|
347
|
-
"resetIn": 42
|
|
348
|
-
}
|
|
349
|
-
```
|
|
127
|
+
---
|
|
350
128
|
|
|
351
129
|
## Performance
|
|
352
130
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
### Store Benchmarks (Bun 1.3)
|
|
131
|
+
### Bun vs Node.js — Memory Store, 10K unique IPs
|
|
356
132
|
|
|
357
|
-
|
|
|
358
|
-
|
|
359
|
-
| **
|
|
360
|
-
|
|
|
361
|
-
| **Redis** | 6,600+ | ~same |
|
|
133
|
+
| Runtime | Ops/sec | |
|
|
134
|
+
|---------|---------|---|
|
|
135
|
+
| **Bun** | **8,320,000** | ████████████████████ |
|
|
136
|
+
| Node.js | 3,160,000 | ████████ |
|
|
362
137
|
|
|
363
|
-
|
|
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.
|
|
364
139
|
|
|
365
|
-
|
|
366
|
-
|-----------|-------------------|----------|
|
|
367
|
-
| **Bun.serve** | 105,000 req/s | 12% |
|
|
368
|
-
| **Elysia** | 115,000 req/s | 11% |
|
|
369
|
-
|
|
370
|
-
> **Note:** Benchmark results vary by hardware and environment. Run your own benchmarks to see results on your specific setup.
|
|
371
|
-
|
|
372
|
-
### Why bun:sqlite is So Fast
|
|
140
|
+
### Why bun:sqlite is faster than better-sqlite3
|
|
373
141
|
|
|
374
142
|
```
|
|
375
|
-
Node.js
|
|
376
|
-
|
|
377
|
-
JavaScript JavaScript
|
|
378
|
-
↓ ↓
|
|
379
|
-
N-API Direct Call
|
|
380
|
-
↓ ↓
|
|
381
|
-
C++ Binding Native SQLite
|
|
382
|
-
↓ (No overhead!)
|
|
383
|
-
SQLite
|
|
143
|
+
Node.js: JS → N-API → C++ binding → SQLite
|
|
144
|
+
Bun: JS → Native call → SQLite (no overhead)
|
|
384
145
|
```
|
|
385
146
|
|
|
386
|
-
|
|
387
|
-
bun:sqlite calls SQLite directly from Bun's native layer.
|
|
147
|
+
No N-API. No C++ bindings. No FFI. Bun calls SQLite directly.
|
|
388
148
|
|
|
389
|
-
|
|
390
|
-
<summary>Run benchmarks yourself</summary>
|
|
149
|
+
---
|
|
391
150
|
|
|
392
|
-
|
|
393
|
-
git clone https://github.com/JointOps/hitlimit-monorepo
|
|
394
|
-
cd hitlimit-monorepo
|
|
395
|
-
bun install
|
|
396
|
-
bun run benchmark:bun
|
|
397
|
-
```
|
|
151
|
+
## Related
|
|
398
152
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
## Elysia Plugin Options
|
|
402
|
-
|
|
403
|
-
```typescript
|
|
404
|
-
import { Elysia } from 'elysia'
|
|
405
|
-
import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
|
|
406
|
-
|
|
407
|
-
new Elysia()
|
|
408
|
-
.use(hitlimit({
|
|
409
|
-
limit: 100,
|
|
410
|
-
window: '1m',
|
|
411
|
-
key: ({ request }) => request.headers.get('x-api-key') || 'anonymous',
|
|
412
|
-
tiers: {
|
|
413
|
-
free: { limit: 100, window: '1h' },
|
|
414
|
-
pro: { limit: 5000, window: '1h' }
|
|
415
|
-
},
|
|
416
|
-
tier: ({ request }) => request.headers.get('x-tier') || 'free'
|
|
417
|
-
}))
|
|
418
|
-
.get('/', () => 'Hello!')
|
|
419
|
-
.listen(3000)
|
|
420
|
-
```
|
|
421
|
-
|
|
422
|
-
## Related Packages
|
|
423
|
-
|
|
424
|
-
- [@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit) - Node.js rate limiting for Express, NestJS
|
|
425
|
-
|
|
426
|
-
## Why Not Use Node.js Rate Limiters in Bun?
|
|
427
|
-
|
|
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.
|
|
429
|
-
|
|
430
|
-
**hitlimit-bun** is built specifically for Bun:
|
|
431
|
-
- Uses native `bun:sqlite` (no N-API overhead)
|
|
432
|
-
- No FFI overhead or Node.js polyfills
|
|
433
|
-
- First-class Elysia framework support
|
|
434
|
-
- Optimized for Bun.serve's request handling
|
|
153
|
+
- **[@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit)** — Node.js variant for Express, Fastify, Hono, NestJS
|
|
435
154
|
|
|
436
155
|
## License
|
|
437
156
|
|
|
438
|
-
MIT
|
|
439
|
-
|
|
440
|
-
## Keywords
|
|
441
|
-
|
|
442
|
-
bun rate limit, bun rate limiter, bun middleware, bun api, bun server, bun serve, bun framework, bun native, bun sqlite, elysia rate limit, elysia plugin, elysia middleware, elysia throttle, elysia framework, api rate limiting, throttle requests, request throttling, bun api protection, ddos protection, brute force protection, login protection, redis rate limit, high performance rate limit, fast rate limiter, sliding window, fixed window, rate-limiter-flexible bun, express-rate-limit bun, bun http, bun backend, bun rest api
|
|
157
|
+
MIT
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"limiter.d.ts","sourceRoot":"","sources":["../../src/core/limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAM7F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB;AAsCD,wBAAsB,cAAc,CAAC,QAAQ,EAC3C,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,UAAU,CAAC,
|
|
1
|
+
{"version":3,"file":"limiter.d.ts","sourceRoot":"","sources":["../../src/core/limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAM7F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB;AAsCD,wBAAsB,cAAc,CAAC,QAAQ,EAC3C,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,UAAU,CAAC,CAgErB;AAGD,wBAAsB,UAAU,CAAC,QAAQ,EACvC,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,cAAc,CAAC,CA2GzB"}
|
package/dist/elysia.js
CHANGED
|
@@ -202,6 +202,49 @@ async function checkLimit(config, req) {
|
|
|
202
202
|
tierName = await config.tier(req);
|
|
203
203
|
}
|
|
204
204
|
const { limit, windowMs } = resolveTier(config, tierName);
|
|
205
|
+
if (limit === Infinity) {
|
|
206
|
+
return {
|
|
207
|
+
allowed: true,
|
|
208
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
209
|
+
headers: {},
|
|
210
|
+
body: {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (config.ban && config.store.hitWithBan) {
|
|
214
|
+
const r = await config.store.hitWithBan(key, windowMs, limit, config.ban.threshold, config.ban.durationMs);
|
|
215
|
+
if (r.banned && r.count === 0) {
|
|
216
|
+
const info3 = {
|
|
217
|
+
limit,
|
|
218
|
+
remaining: 0,
|
|
219
|
+
resetIn: Math.ceil((r.banExpiresAt - Date.now()) / 1000),
|
|
220
|
+
resetAt: r.banExpiresAt,
|
|
221
|
+
key,
|
|
222
|
+
tier: tierName,
|
|
223
|
+
banned: true,
|
|
224
|
+
banExpiresAt: r.banExpiresAt,
|
|
225
|
+
group: groupId
|
|
226
|
+
};
|
|
227
|
+
return { allowed: false, info: info3, headers: buildHeaders(info3, config.headers, false), body: buildBody(config.response, info3) };
|
|
228
|
+
}
|
|
229
|
+
const now2 = Date.now();
|
|
230
|
+
const allowed2 = r.count <= limit;
|
|
231
|
+
const info2 = {
|
|
232
|
+
limit,
|
|
233
|
+
remaining: Math.max(0, limit - r.count),
|
|
234
|
+
resetIn: Math.max(0, Math.ceil((r.resetAt - now2) / 1000)),
|
|
235
|
+
resetAt: r.resetAt,
|
|
236
|
+
key,
|
|
237
|
+
tier: tierName,
|
|
238
|
+
group: groupId
|
|
239
|
+
};
|
|
240
|
+
if (r.violations > 0)
|
|
241
|
+
info2.violations = r.violations;
|
|
242
|
+
if (r.banned) {
|
|
243
|
+
info2.banned = true;
|
|
244
|
+
info2.banExpiresAt = r.banExpiresAt;
|
|
245
|
+
}
|
|
246
|
+
return { allowed: allowed2, info: info2, headers: buildHeaders(info2, config.headers, allowed2), body: allowed2 ? {} : buildBody(config.response, info2) };
|
|
247
|
+
}
|
|
205
248
|
if (config.ban && config.store.isBanned) {
|
|
206
249
|
const banned = await config.store.isBanned(key);
|
|
207
250
|
if (banned) {
|
|
@@ -225,14 +268,6 @@ async function checkLimit(config, req) {
|
|
|
225
268
|
};
|
|
226
269
|
}
|
|
227
270
|
}
|
|
228
|
-
if (limit === Infinity) {
|
|
229
|
-
return {
|
|
230
|
-
allowed: true,
|
|
231
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
232
|
-
headers: {},
|
|
233
|
-
body: {}
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
271
|
const result = await config.store.hit(key, windowMs, limit);
|
|
237
272
|
const now = Date.now();
|
|
238
273
|
const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
|
package/dist/hono.js
CHANGED
|
@@ -202,6 +202,49 @@ async function checkLimit(config, req) {
|
|
|
202
202
|
tierName = await config.tier(req);
|
|
203
203
|
}
|
|
204
204
|
const { limit, windowMs } = resolveTier(config, tierName);
|
|
205
|
+
if (limit === Infinity) {
|
|
206
|
+
return {
|
|
207
|
+
allowed: true,
|
|
208
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
209
|
+
headers: {},
|
|
210
|
+
body: {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (config.ban && config.store.hitWithBan) {
|
|
214
|
+
const r = await config.store.hitWithBan(key, windowMs, limit, config.ban.threshold, config.ban.durationMs);
|
|
215
|
+
if (r.banned && r.count === 0) {
|
|
216
|
+
const info3 = {
|
|
217
|
+
limit,
|
|
218
|
+
remaining: 0,
|
|
219
|
+
resetIn: Math.ceil((r.banExpiresAt - Date.now()) / 1000),
|
|
220
|
+
resetAt: r.banExpiresAt,
|
|
221
|
+
key,
|
|
222
|
+
tier: tierName,
|
|
223
|
+
banned: true,
|
|
224
|
+
banExpiresAt: r.banExpiresAt,
|
|
225
|
+
group: groupId
|
|
226
|
+
};
|
|
227
|
+
return { allowed: false, info: info3, headers: buildHeaders(info3, config.headers, false), body: buildBody(config.response, info3) };
|
|
228
|
+
}
|
|
229
|
+
const now2 = Date.now();
|
|
230
|
+
const allowed2 = r.count <= limit;
|
|
231
|
+
const info2 = {
|
|
232
|
+
limit,
|
|
233
|
+
remaining: Math.max(0, limit - r.count),
|
|
234
|
+
resetIn: Math.max(0, Math.ceil((r.resetAt - now2) / 1000)),
|
|
235
|
+
resetAt: r.resetAt,
|
|
236
|
+
key,
|
|
237
|
+
tier: tierName,
|
|
238
|
+
group: groupId
|
|
239
|
+
};
|
|
240
|
+
if (r.violations > 0)
|
|
241
|
+
info2.violations = r.violations;
|
|
242
|
+
if (r.banned) {
|
|
243
|
+
info2.banned = true;
|
|
244
|
+
info2.banExpiresAt = r.banExpiresAt;
|
|
245
|
+
}
|
|
246
|
+
return { allowed: allowed2, info: info2, headers: buildHeaders(info2, config.headers, allowed2), body: allowed2 ? {} : buildBody(config.response, info2) };
|
|
247
|
+
}
|
|
205
248
|
if (config.ban && config.store.isBanned) {
|
|
206
249
|
const banned = await config.store.isBanned(key);
|
|
207
250
|
if (banned) {
|
|
@@ -225,14 +268,6 @@ async function checkLimit(config, req) {
|
|
|
225
268
|
};
|
|
226
269
|
}
|
|
227
270
|
}
|
|
228
|
-
if (limit === Infinity) {
|
|
229
|
-
return {
|
|
230
|
-
allowed: true,
|
|
231
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
232
|
-
headers: {},
|
|
233
|
-
body: {}
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
271
|
const result = await config.store.hit(key, windowMs, limit);
|
|
237
272
|
const now = Date.now();
|
|
238
273
|
const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
|
package/dist/index.js
CHANGED
|
@@ -199,6 +199,49 @@ async function checkLimit(config, req) {
|
|
|
199
199
|
tierName = await config.tier(req);
|
|
200
200
|
}
|
|
201
201
|
const { limit, windowMs } = resolveTier(config, tierName);
|
|
202
|
+
if (limit === Infinity) {
|
|
203
|
+
return {
|
|
204
|
+
allowed: true,
|
|
205
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
206
|
+
headers: {},
|
|
207
|
+
body: {}
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
if (config.ban && config.store.hitWithBan) {
|
|
211
|
+
const r = await config.store.hitWithBan(key, windowMs, limit, config.ban.threshold, config.ban.durationMs);
|
|
212
|
+
if (r.banned && r.count === 0) {
|
|
213
|
+
const info3 = {
|
|
214
|
+
limit,
|
|
215
|
+
remaining: 0,
|
|
216
|
+
resetIn: Math.ceil((r.banExpiresAt - Date.now()) / 1000),
|
|
217
|
+
resetAt: r.banExpiresAt,
|
|
218
|
+
key,
|
|
219
|
+
tier: tierName,
|
|
220
|
+
banned: true,
|
|
221
|
+
banExpiresAt: r.banExpiresAt,
|
|
222
|
+
group: groupId
|
|
223
|
+
};
|
|
224
|
+
return { allowed: false, info: info3, headers: buildHeaders(info3, config.headers, false), body: buildBody(config.response, info3) };
|
|
225
|
+
}
|
|
226
|
+
const now2 = Date.now();
|
|
227
|
+
const allowed2 = r.count <= limit;
|
|
228
|
+
const info2 = {
|
|
229
|
+
limit,
|
|
230
|
+
remaining: Math.max(0, limit - r.count),
|
|
231
|
+
resetIn: Math.max(0, Math.ceil((r.resetAt - now2) / 1000)),
|
|
232
|
+
resetAt: r.resetAt,
|
|
233
|
+
key,
|
|
234
|
+
tier: tierName,
|
|
235
|
+
group: groupId
|
|
236
|
+
};
|
|
237
|
+
if (r.violations > 0)
|
|
238
|
+
info2.violations = r.violations;
|
|
239
|
+
if (r.banned) {
|
|
240
|
+
info2.banned = true;
|
|
241
|
+
info2.banExpiresAt = r.banExpiresAt;
|
|
242
|
+
}
|
|
243
|
+
return { allowed: allowed2, info: info2, headers: buildHeaders(info2, config.headers, allowed2), body: allowed2 ? {} : buildBody(config.response, info2) };
|
|
244
|
+
}
|
|
202
245
|
if (config.ban && config.store.isBanned) {
|
|
203
246
|
const banned = await config.store.isBanned(key);
|
|
204
247
|
if (banned) {
|
|
@@ -222,14 +265,6 @@ async function checkLimit(config, req) {
|
|
|
222
265
|
};
|
|
223
266
|
}
|
|
224
267
|
}
|
|
225
|
-
if (limit === Infinity) {
|
|
226
|
-
return {
|
|
227
|
-
allowed: true,
|
|
228
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
229
|
-
headers: {},
|
|
230
|
-
body: {}
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
268
|
const result = await config.store.hit(key, windowMs, limit);
|
|
234
269
|
const now = Date.now();
|
|
235
270
|
const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
|
|
@@ -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,
|
|
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"}
|
package/dist/stores/redis.js
CHANGED
|
@@ -1,6 +1,56 @@
|
|
|
1
1
|
// @bun
|
|
2
2
|
// src/stores/redis.ts
|
|
3
3
|
import Redis from "ioredis";
|
|
4
|
+
var HIT_SCRIPT = `
|
|
5
|
+
local key = KEYS[1]
|
|
6
|
+
local windowMs = tonumber(ARGV[1])
|
|
7
|
+
local count = redis.call('INCR', key)
|
|
8
|
+
local ttl = redis.call('PTTL', key)
|
|
9
|
+
if ttl < 0 then
|
|
10
|
+
redis.call('PEXPIRE', key, windowMs)
|
|
11
|
+
ttl = windowMs
|
|
12
|
+
end
|
|
13
|
+
return {count, ttl}
|
|
14
|
+
`;
|
|
15
|
+
var HIT_WITH_BAN_SCRIPT = `
|
|
16
|
+
local hitKey = KEYS[1]
|
|
17
|
+
local banKey = KEYS[2]
|
|
18
|
+
local violationKey = KEYS[3]
|
|
19
|
+
local windowMs = tonumber(ARGV[1])
|
|
20
|
+
local limit = tonumber(ARGV[2])
|
|
21
|
+
local banThreshold = tonumber(ARGV[3])
|
|
22
|
+
local banDurationMs = tonumber(ARGV[4])
|
|
23
|
+
|
|
24
|
+
-- Check ban first
|
|
25
|
+
local banTTL = redis.call('PTTL', banKey)
|
|
26
|
+
if banTTL > 0 then
|
|
27
|
+
return {-1, banTTL, 1, 0}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
-- Hit counter
|
|
31
|
+
local count = redis.call('INCR', hitKey)
|
|
32
|
+
local ttl = redis.call('PTTL', hitKey)
|
|
33
|
+
if ttl < 0 then
|
|
34
|
+
redis.call('PEXPIRE', hitKey, windowMs)
|
|
35
|
+
ttl = windowMs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
-- Track violations if over limit
|
|
39
|
+
local banned = 0
|
|
40
|
+
local violations = 0
|
|
41
|
+
if count > limit then
|
|
42
|
+
violations = redis.call('INCR', violationKey)
|
|
43
|
+
local vTTL = redis.call('PTTL', violationKey)
|
|
44
|
+
if vTTL < 0 then
|
|
45
|
+
redis.call('PEXPIRE', violationKey, banDurationMs)
|
|
46
|
+
end
|
|
47
|
+
if violations >= banThreshold then
|
|
48
|
+
redis.call('SET', banKey, '1', 'PX', banDurationMs)
|
|
49
|
+
banned = 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
return {count, ttl, banned, violations}
|
|
53
|
+
`;
|
|
4
54
|
|
|
5
55
|
class RedisStore {
|
|
6
56
|
redis;
|
|
@@ -12,19 +62,41 @@ class RedisStore {
|
|
|
12
62
|
this.prefix = options.keyPrefix ?? "hitlimit:";
|
|
13
63
|
this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
|
|
14
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
|
+
});
|
|
15
73
|
}
|
|
16
74
|
async hit(key, windowMs, _limit) {
|
|
17
75
|
const redisKey = this.prefix + key;
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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 };
|
|
25
92
|
}
|
|
26
|
-
|
|
27
|
-
|
|
93
|
+
return {
|
|
94
|
+
count,
|
|
95
|
+
resetAt: Date.now() + ttl,
|
|
96
|
+
banned,
|
|
97
|
+
violations,
|
|
98
|
+
banExpiresAt: banned ? Date.now() + banDurationMs : 0
|
|
99
|
+
};
|
|
28
100
|
}
|
|
29
101
|
async isBanned(key) {
|
|
30
102
|
const result = await this.redis.exists(this.banPrefix + key);
|
package/package.json
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joint-ops/hitlimit-bun",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
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
|
}
|