@joint-ops/hitlimit-bun 1.1.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -321
- package/dist/stores/dragonfly.d.ts +9 -0
- package/dist/stores/dragonfly.d.ts.map +1 -0
- package/dist/stores/dragonfly.js +133 -0
- 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 +17 -1
- package/dist/stores/redis.d.ts.map +1 -1
- package/dist/stores/redis.js +14 -30
- package/dist/stores/valkey.d.ts +9 -0
- package/dist/stores/valkey.d.ts.map +1 -0
- package/dist/stores/valkey.js +133 -0
- package/package.json +50 -5
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,161 @@ import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
|
|
|
51
42
|
|
|
52
43
|
new Elysia()
|
|
53
44
|
.use(hitlimit({ limit: 100, window: '1m' }))
|
|
54
|
-
.get('/', () => 'Hello
|
|
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
|
+
## 6 Storage Backends
|
|
264
98
|
|
|
265
|
-
|
|
99
|
+
All built in. No extra packages to install.
|
|
266
100
|
|
|
267
|
-
|
|
101
|
+
| Store | Best For | Peer Dependency |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Memory | Development, single server | None |
|
|
104
|
+
| bun:sqlite | Single server + persistence | None (built-in) |
|
|
105
|
+
| Redis | Distributed, production | `ioredis` |
|
|
106
|
+
| **Valkey** | **Distributed, open-source Redis alternative** | `ioredis` |
|
|
107
|
+
| **DragonflyDB** | **High-throughput distributed** | `ioredis` |
|
|
108
|
+
| PostgreSQL | Shared database infrastructure | `pg` |
|
|
268
109
|
|
|
269
110
|
```typescript
|
|
270
111
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
271
112
|
|
|
272
|
-
//
|
|
273
|
-
Bun.serve({
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
113
|
+
// Memory (default) — fastest, no config
|
|
114
|
+
Bun.serve({ fetch: hitlimit({}, handler) })
|
|
115
|
+
|
|
116
|
+
// bun:sqlite — persists across restarts, native performance
|
|
117
|
+
import { sqliteStore } from '@joint-ops/hitlimit-bun'
|
|
118
|
+
Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) }, handler) })
|
|
119
|
+
|
|
120
|
+
// Redis — distributed, atomic Lua scripts
|
|
121
|
+
import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
|
|
122
|
+
Bun.serve({ fetch: hitlimit({ store: redisStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
277
123
|
|
|
278
|
-
|
|
124
|
+
// Valkey — open-source Redis alternative
|
|
125
|
+
import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
|
|
126
|
+
Bun.serve({ fetch: hitlimit({ store: valkeyStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
279
127
|
|
|
280
|
-
|
|
128
|
+
// DragonflyDB — high-throughput Redis alternative
|
|
129
|
+
import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
|
|
130
|
+
Bun.serve({ fetch: hitlimit({ store: dragonflyStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
281
131
|
|
|
132
|
+
// Postgres — distributed, atomic upserts
|
|
133
|
+
import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'
|
|
134
|
+
Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:5432/mydb' }) }, handler) })
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| Store | Ops/sec | Latency | When to use |
|
|
138
|
+
|-------|---------|---------|-------------|
|
|
139
|
+
| Memory | 8,320,000 | 120ns | Single server, maximum speed |
|
|
140
|
+
| bun:sqlite | 325,000 | 3.1μs | Single server, need persistence |
|
|
141
|
+
| Redis | 6,700 | 148μs | Multi-server / distributed |
|
|
142
|
+
| Postgres | 3,700 | 273μs | Multi-server / already using Postgres |
|
|
143
|
+
|
|
144
|
+
### Valkey (Redis Alternative)
|
|
282
145
|
```typescript
|
|
283
146
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
284
|
-
import {
|
|
147
|
+
import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
|
|
285
148
|
|
|
286
149
|
Bun.serve({
|
|
287
150
|
fetch: hitlimit({
|
|
288
|
-
store:
|
|
151
|
+
store: valkeyStore({ url: 'redis://localhost:6379' }),
|
|
152
|
+
limit: 100,
|
|
153
|
+
window: '1m'
|
|
289
154
|
}, handler)
|
|
290
155
|
})
|
|
291
156
|
```
|
|
292
157
|
|
|
293
|
-
###
|
|
294
|
-
|
|
295
|
-
For distributed systems and multi-server deployments. Uses atomic Lua scripts — single-roundtrip with EVALSHA caching.
|
|
296
|
-
|
|
158
|
+
### DragonflyDB
|
|
297
159
|
```typescript
|
|
298
160
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
299
|
-
import {
|
|
161
|
+
import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
|
|
300
162
|
|
|
301
163
|
Bun.serve({
|
|
302
164
|
fetch: hitlimit({
|
|
303
|
-
store:
|
|
165
|
+
store: dragonflyStore({ url: 'redis://localhost:6379' }),
|
|
166
|
+
limit: 100,
|
|
167
|
+
window: '1m'
|
|
304
168
|
}, handler)
|
|
305
169
|
})
|
|
306
170
|
```
|
|
307
171
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
Every response includes rate limit information:
|
|
311
|
-
|
|
312
|
-
```
|
|
313
|
-
RateLimit-Limit: 100
|
|
314
|
-
RateLimit-Remaining: 99
|
|
315
|
-
RateLimit-Reset: 1234567890
|
|
316
|
-
X-RateLimit-Limit: 100
|
|
317
|
-
X-RateLimit-Remaining: 99
|
|
318
|
-
X-RateLimit-Reset: 1234567890
|
|
319
|
-
```
|
|
320
|
-
|
|
321
|
-
When rate limited (429 Too Many Requests):
|
|
322
|
-
|
|
323
|
-
```
|
|
324
|
-
Retry-After: 42
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
## Default 429 Response
|
|
328
|
-
|
|
329
|
-
```json
|
|
330
|
-
{
|
|
331
|
-
"hitlimit": true,
|
|
332
|
-
"message": "Whoa there! Rate limit exceeded.",
|
|
333
|
-
"limit": 100,
|
|
334
|
-
"remaining": 0,
|
|
335
|
-
"resetIn": 42
|
|
336
|
-
}
|
|
337
|
-
```
|
|
172
|
+
---
|
|
338
173
|
|
|
339
174
|
## Performance
|
|
340
175
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
### Store Benchmarks
|
|
176
|
+
### Bun vs Node.js — Memory Store, 10K unique IPs
|
|
344
177
|
|
|
345
|
-
|
|
|
346
|
-
|
|
347
|
-
| **
|
|
348
|
-
|
|
|
349
|
-
| **Redis** | 6,800+ | ~same |
|
|
350
|
-
|
|
351
|
-
### HTTP Throughput
|
|
352
|
-
|
|
353
|
-
| Framework | With hitlimit-bun | Overhead |
|
|
354
|
-
|-----------|-------------------|----------|
|
|
355
|
-
| **Bun.serve** | 105,000 req/s | 12% |
|
|
356
|
-
| **Elysia** | 115,000 req/s | 11% |
|
|
357
|
-
|
|
358
|
-
> **Note:** These are our benchmarks and we've done our best to keep them fair and reproducible. Results vary by hardware and environment — clone the repo and run them yourself. They're not set in stone — if you find issues or have suggestions for improvement, please open an issue or PR.
|
|
359
|
-
|
|
360
|
-
### Why bun:sqlite is So Fast
|
|
361
|
-
|
|
362
|
-
```
|
|
363
|
-
Node.js (better-sqlite3) Bun (bun:sqlite)
|
|
364
|
-
───────────────────────── ─────────────────
|
|
365
|
-
JavaScript JavaScript
|
|
366
|
-
↓ ↓
|
|
367
|
-
N-API Direct Call
|
|
368
|
-
↓ ↓
|
|
369
|
-
C++ Binding Native SQLite
|
|
370
|
-
↓ (No overhead!)
|
|
371
|
-
SQLite
|
|
372
|
-
```
|
|
178
|
+
| Runtime | Ops/sec | |
|
|
179
|
+
|---------|---------|---|
|
|
180
|
+
| **Bun** | **8,320,000** | ████████████████████ |
|
|
181
|
+
| Node.js | 3,160,000 | ████████ |
|
|
373
182
|
|
|
374
|
-
|
|
375
|
-
bun:sqlite calls SQLite directly from Bun's native layer.
|
|
183
|
+
Bun leads at 10K IPs (8.32M vs 3.16M) and single-IP (12.38M vs 4.83M). Same library, same algorithm, **memory store**. For Redis, Postgres, and cross-store breakdowns, see the [full benchmark results](https://github.com/JointOps/hitlimit-monorepo/tree/main/benchmarks). Controlled-environment microbenchmarks with transparent methodology. Run them yourself.
|
|
376
184
|
|
|
377
|
-
|
|
378
|
-
<summary>Run benchmarks yourself</summary>
|
|
185
|
+
### Why bun:sqlite is faster than better-sqlite3
|
|
379
186
|
|
|
380
|
-
```bash
|
|
381
|
-
git clone https://github.com/JointOps/hitlimit-monorepo
|
|
382
|
-
cd hitlimit-monorepo
|
|
383
|
-
bun install
|
|
384
|
-
bun run benchmark:bun
|
|
385
187
|
```
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
## Elysia Plugin Options
|
|
390
|
-
|
|
391
|
-
```typescript
|
|
392
|
-
import { Elysia } from 'elysia'
|
|
393
|
-
import { hitlimit } from '@joint-ops/hitlimit-bun/elysia'
|
|
394
|
-
|
|
395
|
-
new Elysia()
|
|
396
|
-
.use(hitlimit({
|
|
397
|
-
limit: 100,
|
|
398
|
-
window: '1m',
|
|
399
|
-
key: ({ request }) => request.headers.get('x-api-key') || 'anonymous',
|
|
400
|
-
tiers: {
|
|
401
|
-
free: { limit: 100, window: '1h' },
|
|
402
|
-
pro: { limit: 5000, window: '1h' }
|
|
403
|
-
},
|
|
404
|
-
tier: ({ request }) => request.headers.get('x-tier') || 'free'
|
|
405
|
-
}))
|
|
406
|
-
.get('/', () => 'Hello!')
|
|
407
|
-
.listen(3000)
|
|
188
|
+
Node.js: JS → N-API → C++ binding → SQLite
|
|
189
|
+
Bun: JS → Native call → SQLite (no overhead)
|
|
408
190
|
```
|
|
409
191
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
- [@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit) - Node.js rate limiting for Express, Fastify, Hono, NestJS
|
|
192
|
+
No N-API. No C++ bindings. No FFI. Bun calls SQLite directly.
|
|
413
193
|
|
|
414
|
-
|
|
194
|
+
---
|
|
415
195
|
|
|
416
|
-
|
|
196
|
+
## Related
|
|
417
197
|
|
|
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
|
|
198
|
+
- **[@joint-ops/hitlimit](https://www.npmjs.com/package/@joint-ops/hitlimit)** — Node.js variant for Express, Fastify, Hono, NestJS
|
|
423
199
|
|
|
424
200
|
## License
|
|
425
201
|
|
|
426
|
-
MIT
|
|
427
|
-
|
|
202
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HitLimitStore } from '@joint-ops/hitlimit-types';
|
|
2
|
+
export interface DragonflyStoreOptions {
|
|
3
|
+
/** DragonflyDB connection URL. Default: 'redis://localhost:6379' */
|
|
4
|
+
url?: string;
|
|
5
|
+
/** Key prefix for all rate limit keys. Default: 'hitlimit:' */
|
|
6
|
+
keyPrefix?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function dragonflyStore(options?: DragonflyStoreOptions): HitLimitStore;
|
|
9
|
+
//# sourceMappingURL=dragonfly.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"dragonfly.d.ts","sourceRoot":"","sources":["../../src/stores/dragonfly.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAG9D,MAAM,WAAW,qBAAqB;IACpC,oEAAoE;IACpE,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,cAAc,CAAC,OAAO,CAAC,EAAE,qBAAqB,GAAG,aAAa,CAE7E"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/stores/redis.ts
|
|
3
|
+
import Redis from "ioredis";
|
|
4
|
+
var HIT_SCRIPT = `
|
|
5
|
+
local key = KEYS[1]
|
|
6
|
+
local windowMs = tonumber(ARGV[1])
|
|
7
|
+
local count = redis.call('INCR', key)
|
|
8
|
+
local ttl = redis.call('PTTL', key)
|
|
9
|
+
if ttl < 0 then
|
|
10
|
+
redis.call('PEXPIRE', key, windowMs)
|
|
11
|
+
ttl = windowMs
|
|
12
|
+
end
|
|
13
|
+
return {count, ttl}
|
|
14
|
+
`;
|
|
15
|
+
var HIT_WITH_BAN_SCRIPT = `
|
|
16
|
+
local hitKey = KEYS[1]
|
|
17
|
+
local banKey = KEYS[2]
|
|
18
|
+
local violationKey = KEYS[3]
|
|
19
|
+
local windowMs = tonumber(ARGV[1])
|
|
20
|
+
local limit = tonumber(ARGV[2])
|
|
21
|
+
local banThreshold = tonumber(ARGV[3])
|
|
22
|
+
local banDurationMs = tonumber(ARGV[4])
|
|
23
|
+
|
|
24
|
+
-- Check ban first
|
|
25
|
+
local banTTL = redis.call('PTTL', banKey)
|
|
26
|
+
if banTTL > 0 then
|
|
27
|
+
return {-1, banTTL, 1, 0}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
-- Hit counter
|
|
31
|
+
local count = redis.call('INCR', hitKey)
|
|
32
|
+
local ttl = redis.call('PTTL', hitKey)
|
|
33
|
+
if ttl < 0 then
|
|
34
|
+
redis.call('PEXPIRE', hitKey, windowMs)
|
|
35
|
+
ttl = windowMs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
-- Track violations if over limit
|
|
39
|
+
local banned = 0
|
|
40
|
+
local violations = 0
|
|
41
|
+
if count > limit then
|
|
42
|
+
violations = redis.call('INCR', violationKey)
|
|
43
|
+
local vTTL = redis.call('PTTL', violationKey)
|
|
44
|
+
if vTTL < 0 then
|
|
45
|
+
redis.call('PEXPIRE', violationKey, banDurationMs)
|
|
46
|
+
end
|
|
47
|
+
if violations >= banThreshold then
|
|
48
|
+
redis.call('SET', banKey, '1', 'PX', banDurationMs)
|
|
49
|
+
banned = 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
return {count, ttl, banned, violations}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
class RedisStore {
|
|
56
|
+
redis;
|
|
57
|
+
prefix;
|
|
58
|
+
banPrefix;
|
|
59
|
+
violationPrefix;
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
this.redis = new Redis(options.url ?? "redis://localhost:6379");
|
|
62
|
+
this.prefix = options.keyPrefix ?? "hitlimit:";
|
|
63
|
+
this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
|
|
64
|
+
this.violationPrefix = (options.keyPrefix ?? "hitlimit:") + "violations:";
|
|
65
|
+
this.redis.defineCommand("hitlimitHit", {
|
|
66
|
+
numberOfKeys: 1,
|
|
67
|
+
lua: HIT_SCRIPT
|
|
68
|
+
});
|
|
69
|
+
this.redis.defineCommand("hitlimitHitWithBan", {
|
|
70
|
+
numberOfKeys: 3,
|
|
71
|
+
lua: HIT_WITH_BAN_SCRIPT
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async hit(key, windowMs, _limit) {
|
|
75
|
+
const redisKey = this.prefix + key;
|
|
76
|
+
const result = await this.redis.hitlimitHit(redisKey, windowMs);
|
|
77
|
+
const count = result[0];
|
|
78
|
+
const ttl = result[1];
|
|
79
|
+
return { count, resetAt: Date.now() + ttl };
|
|
80
|
+
}
|
|
81
|
+
async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
|
|
82
|
+
const hitKey = this.prefix + key;
|
|
83
|
+
const banKey = this.banPrefix + key;
|
|
84
|
+
const violationKey = this.violationPrefix + key;
|
|
85
|
+
const result = await this.redis.hitlimitHitWithBan(hitKey, banKey, violationKey, windowMs, limit, banThreshold, banDurationMs);
|
|
86
|
+
const count = result[0];
|
|
87
|
+
const ttl = result[1];
|
|
88
|
+
const banned = result[2] === 1;
|
|
89
|
+
const violations = result[3];
|
|
90
|
+
if (count === -1) {
|
|
91
|
+
return { count: 0, resetAt: Date.now() + ttl, banned: true, violations: 0, banExpiresAt: Date.now() + ttl };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
count,
|
|
95
|
+
resetAt: Date.now() + ttl,
|
|
96
|
+
banned,
|
|
97
|
+
violations,
|
|
98
|
+
banExpiresAt: banned ? Date.now() + banDurationMs : 0
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async isBanned(key) {
|
|
102
|
+
const result = await this.redis.exists(this.banPrefix + key);
|
|
103
|
+
return result === 1;
|
|
104
|
+
}
|
|
105
|
+
async ban(key, durationMs) {
|
|
106
|
+
await this.redis.set(this.banPrefix + key, "1", "PX", durationMs);
|
|
107
|
+
}
|
|
108
|
+
async recordViolation(key, windowMs) {
|
|
109
|
+
const redisKey = this.violationPrefix + key;
|
|
110
|
+
const count = await this.redis.incr(redisKey);
|
|
111
|
+
if (count === 1) {
|
|
112
|
+
await this.redis.pexpire(redisKey, windowMs);
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
116
|
+
async reset(key) {
|
|
117
|
+
await this.redis.del(this.prefix + key, this.banPrefix + key, this.violationPrefix + key);
|
|
118
|
+
}
|
|
119
|
+
async shutdown() {
|
|
120
|
+
await this.redis.quit();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function redisStore(options) {
|
|
124
|
+
return new RedisStore(options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/stores/dragonfly.ts
|
|
128
|
+
function dragonflyStore(options) {
|
|
129
|
+
return new RedisStore(options);
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
dragonflyStore
|
|
133
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HitLimitStore } from '@joint-ops/hitlimit-types';
|
|
2
|
+
export interface PostgresStoreOptions {
|
|
3
|
+
pool: any;
|
|
4
|
+
tablePrefix?: string;
|
|
5
|
+
cleanupInterval?: number;
|
|
6
|
+
skipTableCreation?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function postgresStore(options: PostgresStoreOptions): HitLimitStore;
|
|
9
|
+
//# sourceMappingURL=postgres.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"postgres.d.ts","sourceRoot":"","sources":["../../src/stores/postgres.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,2BAA2B,CAAA;AAE7F,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE,GAAG,CAAA;IACT,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAqPD,wBAAgB,aAAa,CAAC,OAAO,EAAE,oBAAoB,GAAG,aAAa,CAE1E"}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/stores/postgres.ts
|
|
3
|
+
class PostgresStore {
|
|
4
|
+
pool;
|
|
5
|
+
cleanupTimer = null;
|
|
6
|
+
tablesReady = null;
|
|
7
|
+
ready;
|
|
8
|
+
hitQ;
|
|
9
|
+
banCheckQ;
|
|
10
|
+
hitUpsertQ;
|
|
11
|
+
violationQ;
|
|
12
|
+
banSetQ;
|
|
13
|
+
isBannedQ;
|
|
14
|
+
banQ;
|
|
15
|
+
recordViolationQ;
|
|
16
|
+
resetHitsQ;
|
|
17
|
+
resetBansQ;
|
|
18
|
+
resetViolationsQ;
|
|
19
|
+
cleanupHitsQ;
|
|
20
|
+
cleanupBansQ;
|
|
21
|
+
cleanupViolationsQ;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.pool = options.pool;
|
|
24
|
+
const t = options.tablePrefix ?? "hitlimit";
|
|
25
|
+
const skipTableCreation = options.skipTableCreation ?? false;
|
|
26
|
+
if (skipTableCreation) {
|
|
27
|
+
this.ready = true;
|
|
28
|
+
} else {
|
|
29
|
+
this.ready = false;
|
|
30
|
+
this.tablesReady = this.createTables(t).then(() => {
|
|
31
|
+
this.ready = true;
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
this.hitQ = {
|
|
35
|
+
name: `hl_hit_${t}`,
|
|
36
|
+
text: `INSERT INTO ${t}_hits (key, count, reset_at) VALUES ($1, 1, $2) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN ${t}_hits.reset_at <= $3 THEN 1 ELSE ${t}_hits.count + 1 END, reset_at = CASE WHEN ${t}_hits.reset_at <= $3 THEN $2 ELSE ${t}_hits.reset_at END RETURNING count, reset_at`
|
|
37
|
+
};
|
|
38
|
+
this.banCheckQ = {
|
|
39
|
+
name: `hl_banchk_${t}`,
|
|
40
|
+
text: `SELECT expires_at FROM ${t}_bans WHERE key = $1 AND expires_at > $2`
|
|
41
|
+
};
|
|
42
|
+
this.hitUpsertQ = this.hitQ;
|
|
43
|
+
this.violationQ = {
|
|
44
|
+
name: `hl_viol_${t}`,
|
|
45
|
+
text: `INSERT INTO ${t}_violations (key, count, reset_at) VALUES ($1, 1, $2) ON CONFLICT (key) DO UPDATE SET count = CASE WHEN ${t}_violations.reset_at <= $3 THEN 1 ELSE ${t}_violations.count + 1 END, reset_at = CASE WHEN ${t}_violations.reset_at <= $3 THEN $2 ELSE ${t}_violations.reset_at END RETURNING count`
|
|
46
|
+
};
|
|
47
|
+
this.banSetQ = {
|
|
48
|
+
name: `hl_banset_${t}`,
|
|
49
|
+
text: `INSERT INTO ${t}_bans (key, expires_at) VALUES ($1, $2) ON CONFLICT (key) DO UPDATE SET expires_at = $2`
|
|
50
|
+
};
|
|
51
|
+
this.isBannedQ = {
|
|
52
|
+
name: `hl_isbanned_${t}`,
|
|
53
|
+
text: `SELECT 1 FROM ${t}_bans WHERE key = $1 AND expires_at > $2`
|
|
54
|
+
};
|
|
55
|
+
this.banQ = this.banSetQ;
|
|
56
|
+
this.recordViolationQ = this.violationQ;
|
|
57
|
+
this.resetHitsQ = `DELETE FROM ${t}_hits WHERE key = $1`;
|
|
58
|
+
this.resetBansQ = `DELETE FROM ${t}_bans WHERE key = $1`;
|
|
59
|
+
this.resetViolationsQ = `DELETE FROM ${t}_violations WHERE key = $1`;
|
|
60
|
+
this.cleanupHitsQ = `DELETE FROM ${t}_hits WHERE reset_at <= $1`;
|
|
61
|
+
this.cleanupBansQ = `DELETE FROM ${t}_bans WHERE expires_at <= $1`;
|
|
62
|
+
this.cleanupViolationsQ = `DELETE FROM ${t}_violations WHERE reset_at <= $1`;
|
|
63
|
+
const interval = options.cleanupInterval ?? 60000;
|
|
64
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
|
|
65
|
+
if (typeof this.cleanupTimer.unref === "function") {
|
|
66
|
+
this.cleanupTimer.unref();
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async createTables(t) {
|
|
70
|
+
await this.pool.query(`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS ${t}_hits (
|
|
72
|
+
key TEXT PRIMARY KEY,
|
|
73
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
74
|
+
reset_at BIGINT NOT NULL
|
|
75
|
+
)
|
|
76
|
+
`);
|
|
77
|
+
await this.pool.query(`
|
|
78
|
+
CREATE TABLE IF NOT EXISTS ${t}_bans (
|
|
79
|
+
key TEXT PRIMARY KEY,
|
|
80
|
+
expires_at BIGINT NOT NULL
|
|
81
|
+
)
|
|
82
|
+
`);
|
|
83
|
+
await this.pool.query(`
|
|
84
|
+
CREATE TABLE IF NOT EXISTS ${t}_violations (
|
|
85
|
+
key TEXT PRIMARY KEY,
|
|
86
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
87
|
+
reset_at BIGINT NOT NULL
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
}
|
|
91
|
+
async hit(key, windowMs, _limit) {
|
|
92
|
+
if (!this.ready)
|
|
93
|
+
await this.tablesReady;
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
const result = await this.pool.query({
|
|
96
|
+
name: this.hitQ.name,
|
|
97
|
+
text: this.hitQ.text,
|
|
98
|
+
values: [key, now + windowMs, now]
|
|
99
|
+
});
|
|
100
|
+
return {
|
|
101
|
+
count: result.rows[0].count,
|
|
102
|
+
resetAt: Number(result.rows[0].reset_at)
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
|
|
106
|
+
if (!this.ready)
|
|
107
|
+
await this.tablesReady;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const resetAt = now + windowMs;
|
|
110
|
+
const banExpiresAt = now + banDurationMs;
|
|
111
|
+
const banResult = await this.pool.query({
|
|
112
|
+
name: this.banCheckQ.name,
|
|
113
|
+
text: this.banCheckQ.text,
|
|
114
|
+
values: [key, now]
|
|
115
|
+
});
|
|
116
|
+
if (banResult.rowCount > 0) {
|
|
117
|
+
return {
|
|
118
|
+
count: 0,
|
|
119
|
+
resetAt,
|
|
120
|
+
banned: true,
|
|
121
|
+
violations: 0,
|
|
122
|
+
banExpiresAt: Number(banResult.rows[0].expires_at)
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
const hitResult = await this.pool.query({
|
|
126
|
+
name: this.hitUpsertQ.name,
|
|
127
|
+
text: this.hitUpsertQ.text,
|
|
128
|
+
values: [key, resetAt, now]
|
|
129
|
+
});
|
|
130
|
+
const hitCount = hitResult.rows[0].count;
|
|
131
|
+
const hitResetAt = Number(hitResult.rows[0].reset_at);
|
|
132
|
+
if (hitCount > limit) {
|
|
133
|
+
const violationResult = await this.pool.query({
|
|
134
|
+
name: this.violationQ.name,
|
|
135
|
+
text: this.violationQ.text,
|
|
136
|
+
values: [key, banExpiresAt, now]
|
|
137
|
+
});
|
|
138
|
+
const violations = violationResult.rows[0].count;
|
|
139
|
+
const shouldBan = violations >= banThreshold;
|
|
140
|
+
if (shouldBan) {
|
|
141
|
+
await this.pool.query({
|
|
142
|
+
name: this.banSetQ.name,
|
|
143
|
+
text: this.banSetQ.text,
|
|
144
|
+
values: [key, banExpiresAt]
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
count: hitCount,
|
|
149
|
+
resetAt: hitResetAt,
|
|
150
|
+
banned: shouldBan,
|
|
151
|
+
violations,
|
|
152
|
+
banExpiresAt: shouldBan ? banExpiresAt : 0
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
count: hitCount,
|
|
157
|
+
resetAt: hitResetAt,
|
|
158
|
+
banned: false,
|
|
159
|
+
violations: 0,
|
|
160
|
+
banExpiresAt: 0
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async isBanned(key) {
|
|
164
|
+
if (!this.ready)
|
|
165
|
+
await this.tablesReady;
|
|
166
|
+
const result = await this.pool.query({
|
|
167
|
+
name: this.isBannedQ.name,
|
|
168
|
+
text: this.isBannedQ.text,
|
|
169
|
+
values: [key, Date.now()]
|
|
170
|
+
});
|
|
171
|
+
return result.rowCount > 0;
|
|
172
|
+
}
|
|
173
|
+
async ban(key, durationMs) {
|
|
174
|
+
if (!this.ready)
|
|
175
|
+
await this.tablesReady;
|
|
176
|
+
await this.pool.query({
|
|
177
|
+
name: this.banQ.name,
|
|
178
|
+
text: this.banQ.text,
|
|
179
|
+
values: [key, Date.now() + durationMs]
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
async recordViolation(key, windowMs) {
|
|
183
|
+
if (!this.ready)
|
|
184
|
+
await this.tablesReady;
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const result = await this.pool.query({
|
|
187
|
+
name: this.recordViolationQ.name,
|
|
188
|
+
text: this.recordViolationQ.text,
|
|
189
|
+
values: [key, now + windowMs, now]
|
|
190
|
+
});
|
|
191
|
+
return result.rows[0].count;
|
|
192
|
+
}
|
|
193
|
+
async reset(key) {
|
|
194
|
+
if (!this.ready)
|
|
195
|
+
await this.tablesReady;
|
|
196
|
+
await this.pool.query(this.resetHitsQ, [key]);
|
|
197
|
+
await this.pool.query(this.resetBansQ, [key]);
|
|
198
|
+
await this.pool.query(this.resetViolationsQ, [key]);
|
|
199
|
+
}
|
|
200
|
+
async cleanup() {
|
|
201
|
+
try {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
await this.pool.query(this.cleanupHitsQ, [now]);
|
|
204
|
+
await this.pool.query(this.cleanupBansQ, [now]);
|
|
205
|
+
await this.pool.query(this.cleanupViolationsQ, [now]);
|
|
206
|
+
} catch {}
|
|
207
|
+
}
|
|
208
|
+
shutdown() {
|
|
209
|
+
if (this.cleanupTimer) {
|
|
210
|
+
clearInterval(this.cleanupTimer);
|
|
211
|
+
this.cleanupTimer = null;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function postgresStore(options) {
|
|
216
|
+
return new PostgresStore(options);
|
|
217
|
+
}
|
|
218
|
+
export {
|
|
219
|
+
postgresStore
|
|
220
|
+
};
|
package/dist/stores/redis.d.ts
CHANGED
|
@@ -1,7 +1,23 @@
|
|
|
1
|
-
import type { HitLimitStore } from '@joint-ops/hitlimit-types';
|
|
1
|
+
import type { HitLimitStore, HitWithBanResult, StoreResult } from '@joint-ops/hitlimit-types';
|
|
2
2
|
export interface RedisStoreOptions {
|
|
3
3
|
url?: string;
|
|
4
4
|
keyPrefix?: string;
|
|
5
5
|
}
|
|
6
|
+
export declare const HIT_SCRIPT = "\nlocal key = KEYS[1]\nlocal windowMs = tonumber(ARGV[1])\nlocal count = redis.call('INCR', key)\nlocal ttl = redis.call('PTTL', key)\nif ttl < 0 then\n redis.call('PEXPIRE', key, windowMs)\n ttl = windowMs\nend\nreturn {count, ttl}\n";
|
|
7
|
+
export declare const HIT_WITH_BAN_SCRIPT = "\nlocal hitKey = KEYS[1]\nlocal banKey = KEYS[2]\nlocal violationKey = KEYS[3]\nlocal windowMs = tonumber(ARGV[1])\nlocal limit = tonumber(ARGV[2])\nlocal banThreshold = tonumber(ARGV[3])\nlocal banDurationMs = tonumber(ARGV[4])\n\n-- Check ban first\nlocal banTTL = redis.call('PTTL', banKey)\nif banTTL > 0 then\n return {-1, banTTL, 1, 0}\nend\n\n-- Hit counter\nlocal count = redis.call('INCR', hitKey)\nlocal ttl = redis.call('PTTL', hitKey)\nif ttl < 0 then\n redis.call('PEXPIRE', hitKey, windowMs)\n ttl = windowMs\nend\n\n-- Track violations if over limit\nlocal banned = 0\nlocal violations = 0\nif count > limit then\n violations = redis.call('INCR', violationKey)\n local vTTL = redis.call('PTTL', violationKey)\n if vTTL < 0 then\n redis.call('PEXPIRE', violationKey, banDurationMs)\n end\n if violations >= banThreshold then\n redis.call('SET', banKey, '1', 'PX', banDurationMs)\n banned = 1\n end\nend\nreturn {count, ttl, banned, violations}\n";
|
|
8
|
+
export declare class RedisStore implements HitLimitStore {
|
|
9
|
+
private redis;
|
|
10
|
+
private prefix;
|
|
11
|
+
private banPrefix;
|
|
12
|
+
private violationPrefix;
|
|
13
|
+
constructor(options?: RedisStoreOptions);
|
|
14
|
+
hit(key: string, windowMs: number, _limit: number): Promise<StoreResult>;
|
|
15
|
+
hitWithBan(key: string, windowMs: number, limit: number, banThreshold: number, banDurationMs: number): Promise<HitWithBanResult>;
|
|
16
|
+
isBanned(key: string): Promise<boolean>;
|
|
17
|
+
ban(key: string, durationMs: number): Promise<void>;
|
|
18
|
+
recordViolation(key: string, windowMs: number): Promise<number>;
|
|
19
|
+
reset(key: string): Promise<void>;
|
|
20
|
+
shutdown(): Promise<void>;
|
|
21
|
+
}
|
|
6
22
|
export declare function redisStore(options?: RedisStoreOptions): HitLimitStore;
|
|
7
23
|
//# sourceMappingURL=redis.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,
|
|
1
|
+
{"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,gBAAgB,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAA;AAG7F,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAGD,eAAO,MAAM,UAAU,iPAUtB,CAAA;AAGD,eAAO,MAAM,mBAAmB,s9BAsC/B,CAAA;AAED,qBAAa,UAAW,YAAW,aAAa;IAC9C,OAAO,CAAC,KAAK,CAAO;IACpB,OAAO,CAAC,MAAM,CAAQ;IACtB,OAAO,CAAC,SAAS,CAAQ;IACzB,OAAO,CAAC,eAAe,CAAQ;gBAEnB,OAAO,GAAE,iBAAsB;IAiBrC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IASxE,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IA4BhI,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAKvC,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAInD,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAS/D,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQjC,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;CAGhC;AAED,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa,CAErE"}
|
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;
|
|
@@ -143,5 +124,8 @@ function redisStore(options) {
|
|
|
143
124
|
return new RedisStore(options);
|
|
144
125
|
}
|
|
145
126
|
export {
|
|
146
|
-
redisStore
|
|
127
|
+
redisStore,
|
|
128
|
+
RedisStore,
|
|
129
|
+
HIT_WITH_BAN_SCRIPT,
|
|
130
|
+
HIT_SCRIPT
|
|
147
131
|
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { HitLimitStore } from '@joint-ops/hitlimit-types';
|
|
2
|
+
export interface ValkeyStoreOptions {
|
|
3
|
+
/** Valkey connection URL. Default: 'redis://localhost:6379' */
|
|
4
|
+
url?: string;
|
|
5
|
+
/** Key prefix for all rate limit keys. Default: 'hitlimit:' */
|
|
6
|
+
keyPrefix?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function valkeyStore(options?: ValkeyStoreOptions): HitLimitStore;
|
|
9
|
+
//# sourceMappingURL=valkey.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"valkey.d.ts","sourceRoot":"","sources":["../../src/stores/valkey.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAG9D,MAAM,WAAW,kBAAkB;IACjC,+DAA+D;IAC/D,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/stores/redis.ts
|
|
3
|
+
import Redis from "ioredis";
|
|
4
|
+
var HIT_SCRIPT = `
|
|
5
|
+
local key = KEYS[1]
|
|
6
|
+
local windowMs = tonumber(ARGV[1])
|
|
7
|
+
local count = redis.call('INCR', key)
|
|
8
|
+
local ttl = redis.call('PTTL', key)
|
|
9
|
+
if ttl < 0 then
|
|
10
|
+
redis.call('PEXPIRE', key, windowMs)
|
|
11
|
+
ttl = windowMs
|
|
12
|
+
end
|
|
13
|
+
return {count, ttl}
|
|
14
|
+
`;
|
|
15
|
+
var HIT_WITH_BAN_SCRIPT = `
|
|
16
|
+
local hitKey = KEYS[1]
|
|
17
|
+
local banKey = KEYS[2]
|
|
18
|
+
local violationKey = KEYS[3]
|
|
19
|
+
local windowMs = tonumber(ARGV[1])
|
|
20
|
+
local limit = tonumber(ARGV[2])
|
|
21
|
+
local banThreshold = tonumber(ARGV[3])
|
|
22
|
+
local banDurationMs = tonumber(ARGV[4])
|
|
23
|
+
|
|
24
|
+
-- Check ban first
|
|
25
|
+
local banTTL = redis.call('PTTL', banKey)
|
|
26
|
+
if banTTL > 0 then
|
|
27
|
+
return {-1, banTTL, 1, 0}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
-- Hit counter
|
|
31
|
+
local count = redis.call('INCR', hitKey)
|
|
32
|
+
local ttl = redis.call('PTTL', hitKey)
|
|
33
|
+
if ttl < 0 then
|
|
34
|
+
redis.call('PEXPIRE', hitKey, windowMs)
|
|
35
|
+
ttl = windowMs
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
-- Track violations if over limit
|
|
39
|
+
local banned = 0
|
|
40
|
+
local violations = 0
|
|
41
|
+
if count > limit then
|
|
42
|
+
violations = redis.call('INCR', violationKey)
|
|
43
|
+
local vTTL = redis.call('PTTL', violationKey)
|
|
44
|
+
if vTTL < 0 then
|
|
45
|
+
redis.call('PEXPIRE', violationKey, banDurationMs)
|
|
46
|
+
end
|
|
47
|
+
if violations >= banThreshold then
|
|
48
|
+
redis.call('SET', banKey, '1', 'PX', banDurationMs)
|
|
49
|
+
banned = 1
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
return {count, ttl, banned, violations}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
class RedisStore {
|
|
56
|
+
redis;
|
|
57
|
+
prefix;
|
|
58
|
+
banPrefix;
|
|
59
|
+
violationPrefix;
|
|
60
|
+
constructor(options = {}) {
|
|
61
|
+
this.redis = new Redis(options.url ?? "redis://localhost:6379");
|
|
62
|
+
this.prefix = options.keyPrefix ?? "hitlimit:";
|
|
63
|
+
this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
|
|
64
|
+
this.violationPrefix = (options.keyPrefix ?? "hitlimit:") + "violations:";
|
|
65
|
+
this.redis.defineCommand("hitlimitHit", {
|
|
66
|
+
numberOfKeys: 1,
|
|
67
|
+
lua: HIT_SCRIPT
|
|
68
|
+
});
|
|
69
|
+
this.redis.defineCommand("hitlimitHitWithBan", {
|
|
70
|
+
numberOfKeys: 3,
|
|
71
|
+
lua: HIT_WITH_BAN_SCRIPT
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async hit(key, windowMs, _limit) {
|
|
75
|
+
const redisKey = this.prefix + key;
|
|
76
|
+
const result = await this.redis.hitlimitHit(redisKey, windowMs);
|
|
77
|
+
const count = result[0];
|
|
78
|
+
const ttl = result[1];
|
|
79
|
+
return { count, resetAt: Date.now() + ttl };
|
|
80
|
+
}
|
|
81
|
+
async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
|
|
82
|
+
const hitKey = this.prefix + key;
|
|
83
|
+
const banKey = this.banPrefix + key;
|
|
84
|
+
const violationKey = this.violationPrefix + key;
|
|
85
|
+
const result = await this.redis.hitlimitHitWithBan(hitKey, banKey, violationKey, windowMs, limit, banThreshold, banDurationMs);
|
|
86
|
+
const count = result[0];
|
|
87
|
+
const ttl = result[1];
|
|
88
|
+
const banned = result[2] === 1;
|
|
89
|
+
const violations = result[3];
|
|
90
|
+
if (count === -1) {
|
|
91
|
+
return { count: 0, resetAt: Date.now() + ttl, banned: true, violations: 0, banExpiresAt: Date.now() + ttl };
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
count,
|
|
95
|
+
resetAt: Date.now() + ttl,
|
|
96
|
+
banned,
|
|
97
|
+
violations,
|
|
98
|
+
banExpiresAt: banned ? Date.now() + banDurationMs : 0
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async isBanned(key) {
|
|
102
|
+
const result = await this.redis.exists(this.banPrefix + key);
|
|
103
|
+
return result === 1;
|
|
104
|
+
}
|
|
105
|
+
async ban(key, durationMs) {
|
|
106
|
+
await this.redis.set(this.banPrefix + key, "1", "PX", durationMs);
|
|
107
|
+
}
|
|
108
|
+
async recordViolation(key, windowMs) {
|
|
109
|
+
const redisKey = this.violationPrefix + key;
|
|
110
|
+
const count = await this.redis.incr(redisKey);
|
|
111
|
+
if (count === 1) {
|
|
112
|
+
await this.redis.pexpire(redisKey, windowMs);
|
|
113
|
+
}
|
|
114
|
+
return count;
|
|
115
|
+
}
|
|
116
|
+
async reset(key) {
|
|
117
|
+
await this.redis.del(this.prefix + key, this.banPrefix + key, this.violationPrefix + key);
|
|
118
|
+
}
|
|
119
|
+
async shutdown() {
|
|
120
|
+
await this.redis.quit();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function redisStore(options) {
|
|
124
|
+
return new RedisStore(options);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// src/stores/valkey.ts
|
|
128
|
+
function valkeyStore(options) {
|
|
129
|
+
return new RedisStore(options);
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
valkeyStore
|
|
133
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joint-ops/hitlimit-bun",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Ultra-fast Bun-native rate limiting - Memory-first with 6M+ ops/sec for Bun.serve, Elysia & Hono",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Shayan M Hussain",
|
|
7
7
|
"email": "shayanhussain48@gmail.com",
|
|
8
8
|
"url": "https://github.com/ShayanHussainSB"
|
|
9
9
|
},
|
|
10
|
+
"contributors": [
|
|
11
|
+
{
|
|
12
|
+
"name": "tanv33",
|
|
13
|
+
"url": "https://github.com/tanv33"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "builtbyali",
|
|
17
|
+
"url": "https://github.com/builtbyali"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"name": "MuhammadRehanRasool",
|
|
21
|
+
"url": "https://github.com/MuhammadRehanRasool"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "sultandilaram",
|
|
25
|
+
"url": "https://github.com/sultandilaram"
|
|
26
|
+
}
|
|
27
|
+
],
|
|
10
28
|
"license": "MIT",
|
|
11
29
|
"homepage": "https://hitlimit.jointops.dev/docs/bun",
|
|
12
30
|
"repository": {
|
|
@@ -68,7 +86,13 @@
|
|
|
68
86
|
"bun-framework",
|
|
69
87
|
"lightweight",
|
|
70
88
|
"zero-dependency",
|
|
71
|
-
"esm"
|
|
89
|
+
"esm",
|
|
90
|
+
"valkey",
|
|
91
|
+
"valkey-rate-limit",
|
|
92
|
+
"dragonfly",
|
|
93
|
+
"dragonflydb",
|
|
94
|
+
"dragonfly-rate-limit",
|
|
95
|
+
"redis-alternative"
|
|
72
96
|
],
|
|
73
97
|
"type": "module",
|
|
74
98
|
"main": "./dist/index.js",
|
|
@@ -104,6 +128,21 @@
|
|
|
104
128
|
"types": "./dist/stores/sqlite.d.ts",
|
|
105
129
|
"bun": "./dist/stores/sqlite.js",
|
|
106
130
|
"import": "./dist/stores/sqlite.js"
|
|
131
|
+
},
|
|
132
|
+
"./stores/postgres": {
|
|
133
|
+
"types": "./dist/stores/postgres.d.ts",
|
|
134
|
+
"bun": "./dist/stores/postgres.js",
|
|
135
|
+
"import": "./dist/stores/postgres.js"
|
|
136
|
+
},
|
|
137
|
+
"./stores/valkey": {
|
|
138
|
+
"types": "./dist/stores/valkey.d.ts",
|
|
139
|
+
"bun": "./dist/stores/valkey.js",
|
|
140
|
+
"import": "./dist/stores/valkey.js"
|
|
141
|
+
},
|
|
142
|
+
"./stores/dragonfly": {
|
|
143
|
+
"types": "./dist/stores/dragonfly.d.ts",
|
|
144
|
+
"bun": "./dist/stores/dragonfly.js",
|
|
145
|
+
"import": "./dist/stores/dragonfly.js"
|
|
107
146
|
}
|
|
108
147
|
},
|
|
109
148
|
"files": [
|
|
@@ -111,18 +150,19 @@
|
|
|
111
150
|
],
|
|
112
151
|
"sideEffects": false,
|
|
113
152
|
"scripts": {
|
|
114
|
-
"build": "bun build ./src/index.ts ./src/elysia.ts ./src/hono.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts --outdir=./dist --target=bun --external=elysia --external=hono --external=ioredis --external=@sinclair/typebox && tsc --emitDeclarationOnly",
|
|
153
|
+
"build": "bun build ./src/index.ts ./src/elysia.ts ./src/hono.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts ./src/stores/postgres.ts ./src/stores/valkey.ts ./src/stores/dragonfly.ts --outdir=./dist --root=./src --target=bun --external=elysia --external=hono --external=ioredis --external=pg --external=@sinclair/typebox && tsc --emitDeclarationOnly",
|
|
115
154
|
"clean": "rm -rf dist",
|
|
116
155
|
"test": "bun test",
|
|
117
156
|
"test:watch": "bun test --watch"
|
|
118
157
|
},
|
|
119
158
|
"dependencies": {
|
|
120
|
-
"@joint-ops/hitlimit-types": "1.
|
|
159
|
+
"@joint-ops/hitlimit-types": "1.3.0"
|
|
121
160
|
},
|
|
122
161
|
"peerDependencies": {
|
|
123
162
|
"elysia": ">=1.0.0",
|
|
124
163
|
"hono": ">=4.0.0",
|
|
125
|
-
"ioredis": ">=5.0.0"
|
|
164
|
+
"ioredis": ">=5.0.0",
|
|
165
|
+
"pg": ">=8.0.0"
|
|
126
166
|
},
|
|
127
167
|
"peerDependenciesMeta": {
|
|
128
168
|
"elysia": {
|
|
@@ -133,14 +173,19 @@
|
|
|
133
173
|
},
|
|
134
174
|
"ioredis": {
|
|
135
175
|
"optional": true
|
|
176
|
+
},
|
|
177
|
+
"pg": {
|
|
178
|
+
"optional": true
|
|
136
179
|
}
|
|
137
180
|
},
|
|
138
181
|
"devDependencies": {
|
|
139
182
|
"@sinclair/typebox": "^0.34.48",
|
|
140
183
|
"@types/bun": "latest",
|
|
184
|
+
"@types/pg": "^8.11.0",
|
|
141
185
|
"elysia": "^1.0.0",
|
|
142
186
|
"hono": "^4.11.9",
|
|
143
187
|
"ioredis": "^5.3.0",
|
|
188
|
+
"pg": "^8.13.0",
|
|
144
189
|
"typescript": "^5.3.0"
|
|
145
190
|
}
|
|
146
191
|
}
|