@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 +65 -335
- 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 +10 -29
- package/package.json +33 -4
package/README.md
CHANGED
|
@@ -1,49 +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
|
-
|
|
5
|
+
**12.38M ops/sec** on memory. **8.32M at 10K IPs**. Native bun:sqlite. Atomic Redis Lua. Postgres. Zero dependencies.
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
```bash
|
|
8
|
+
bun add @joint-ops/hitlimit-bun
|
|
9
|
+
```
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
```typescript
|
|
12
|
+
Bun.serve({
|
|
13
|
+
fetch: hitlimit({}, (req) => new Response('Hello!'))
|
|
14
|
+
})
|
|
15
|
+
```
|
|
16
16
|
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
31
|
-
bun add @joint-ops/hitlimit-bun
|
|
32
|
-
```
|
|
21
|
+
---
|
|
33
22
|
|
|
34
|
-
##
|
|
23
|
+
## 30 Seconds to Production
|
|
35
24
|
|
|
36
|
-
### Bun.serve
|
|
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) =>
|
|
31
|
+
fetch: hitlimit({ limit: 100, window: '1m' }, (req) => {
|
|
32
|
+
return new Response('Hello!')
|
|
33
|
+
})
|
|
43
34
|
})
|
|
44
35
|
```
|
|
45
36
|
|
|
46
|
-
### Elysia
|
|
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
|
|
45
|
+
.get('/', () => 'Hello!')
|
|
55
46
|
.listen(3000)
|
|
56
47
|
```
|
|
57
48
|
|
|
58
|
-
### Hono
|
|
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
|
|
68
|
-
|
|
57
|
+
app.get('/', (c) => c.text('Hello!'))
|
|
69
58
|
Bun.serve({ port: 3000, fetch: app.fetch })
|
|
70
59
|
```
|
|
71
60
|
|
|
72
|
-
|
|
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
|
-
|
|
63
|
+
## What You Get
|
|
93
64
|
|
|
94
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
273
|
-
Bun.serve({
|
|
274
|
-
fetch: hitlimit({}, handler)
|
|
275
|
-
})
|
|
276
|
-
```
|
|
104
|
+
// Memory (default) — fastest, no config
|
|
105
|
+
Bun.serve({ fetch: hitlimit({}, handler) })
|
|
277
106
|
|
|
278
|
-
|
|
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
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
+
### Bun vs Node.js — Memory Store, 10K unique IPs
|
|
342
132
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
149
|
+
---
|
|
415
150
|
|
|
416
|
-
|
|
151
|
+
## Related
|
|
417
152
|
|
|
418
|
-
**hitlimit-
|
|
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
|
|
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;
|
|
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
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
}
|