@joint-ops/hitlimit-bun 1.3.0 → 1.4.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 +97 -48
- package/dist/stores/mongodb.d.ts +11 -0
- package/dist/stores/mongodb.d.ts.map +1 -0
- package/dist/stores/mongodb.js +133 -0
- package/dist/stores/mysql.d.ts +13 -0
- package/dist/stores/mysql.d.ts.map +1 -0
- package/dist/stores/mysql.js +195 -0
- package/dist/stores/sqlite.d.ts.map +1 -1
- package/dist/stores/sqlite.js +1 -0
- package/package.json +39 -4
package/README.md
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> Rate limiting built for Bun. Not ported — built.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
<!-- BENCH:BUN_HERO -->
|
|
6
|
+
**7.73M ops/sec** on memory. **5.57M at 10K IPs**. Native bun:sqlite. Atomic Redis Lua. Postgres. Zero dependencies.
|
|
7
|
+
<!-- /BENCH:BUN_HERO -->
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
10
|
bun add @joint-ops/hitlimit-bun
|
|
@@ -94,80 +96,123 @@ new Elysia()
|
|
|
94
96
|
|
|
95
97
|
---
|
|
96
98
|
|
|
97
|
-
##
|
|
99
|
+
## Pick Your Store
|
|
98
100
|
|
|
99
|
-
|
|
101
|
+
Every store is built in. Swap one line — your rate limiting code stays the same.
|
|
100
102
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
```
|
|
104
|
+
Single Server Multi-Server
|
|
105
|
+
┌──────────────────────┐ ┌──────────────────────────┐
|
|
106
|
+
│ Memory │ SQLite │ │ Redis │ Postgres │
|
|
107
|
+
│ (default) (bun:sqlite) │ Valkey │ MongoDB │
|
|
108
|
+
│ │ │ Dragonfly MySQL │
|
|
109
|
+
└──────────────────────┘ └──────────────────────────┘
|
|
110
|
+
No dependencies at all Your existing infra, zero lock-in
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
<!-- BENCH:BUN_STORE_TABLE -->
|
|
114
|
+
| Store | Ops/sec | Latency | When to use |
|
|
115
|
+
|-------|---------|---------|-------------|
|
|
116
|
+
| Memory | 5,574,103 | 179ns | Single server, maximum speed |
|
|
117
|
+
| bun:sqlite | 372,247 | 2.7μs | Single server, need persistence |
|
|
118
|
+
| MongoDB | 2,132 | 469μs | Multi-server / NoSQL infrastructure |
|
|
119
|
+
<!-- /BENCH:BUN_STORE_TABLE -->
|
|
120
|
+
|
|
121
|
+
> Redis, Valkey, DragonflyDB, Postgres, and MySQL are network-bound (~200–3,500 ops/sec). Benchmarks at [hitlimit.jointops.dev/docs/benchmarks](https://hitlimit.jointops.dev/docs/benchmarks).
|
|
122
|
+
|
|
123
|
+
### The pattern is always the same
|
|
109
124
|
|
|
110
125
|
```typescript
|
|
111
126
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
127
|
+
import { ______Store } from '@joint-ops/hitlimit-bun/stores/______'
|
|
112
128
|
|
|
113
|
-
|
|
114
|
-
|
|
129
|
+
Bun.serve({ fetch: hitlimit({ store: ______Store({ /* config */ }) }, handler) })
|
|
130
|
+
```
|
|
115
131
|
|
|
116
|
-
|
|
132
|
+
<details>
|
|
133
|
+
<summary><b>Memory</b> — default, zero config</summary>
|
|
134
|
+
|
|
135
|
+
```typescript
|
|
136
|
+
Bun.serve({ fetch: hitlimit({}, handler) }) // that's it
|
|
137
|
+
```
|
|
138
|
+
</details>
|
|
139
|
+
|
|
140
|
+
<details>
|
|
141
|
+
<summary><b>bun:sqlite</b> — native, no N-API, no FFI, survives restarts</summary>
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
117
144
|
import { sqliteStore } from '@joint-ops/hitlimit-bun'
|
|
118
145
|
Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) }, handler) })
|
|
146
|
+
```
|
|
147
|
+
No peer dependency — `bun:sqlite` is built into Bun.
|
|
148
|
+
</details>
|
|
119
149
|
|
|
120
|
-
|
|
150
|
+
<details>
|
|
151
|
+
<summary><b>Redis</b> — distributed, atomic Lua scripts</summary>
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
121
154
|
import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
|
|
122
155
|
Bun.serve({ fetch: hitlimit({ store: redisStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
156
|
+
```
|
|
157
|
+
Peer dep: `ioredis`
|
|
158
|
+
</details>
|
|
159
|
+
|
|
160
|
+
<details>
|
|
161
|
+
<summary><b>Valkey</b> — open-source Redis fork, drop-in replacement</summary>
|
|
123
162
|
|
|
124
|
-
|
|
163
|
+
```typescript
|
|
125
164
|
import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
|
|
126
165
|
Bun.serve({ fetch: hitlimit({ store: valkeyStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
166
|
+
```
|
|
167
|
+
Peer dep: `ioredis`
|
|
168
|
+
</details>
|
|
169
|
+
|
|
170
|
+
<details>
|
|
171
|
+
<summary><b>DragonflyDB</b> — Redis-compatible, higher throughput</summary>
|
|
127
172
|
|
|
128
|
-
|
|
173
|
+
```typescript
|
|
129
174
|
import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
|
|
130
175
|
Bun.serve({ fetch: hitlimit({ store: dragonflyStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
176
|
+
```
|
|
177
|
+
Peer dep: `ioredis`
|
|
178
|
+
</details>
|
|
131
179
|
|
|
132
|
-
|
|
180
|
+
<details>
|
|
181
|
+
<summary><b>PostgreSQL</b> — use your existing database</summary>
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
133
184
|
import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'
|
|
134
185
|
Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:5432/mydb' }) }, handler) })
|
|
135
186
|
```
|
|
187
|
+
Peer dep: `pg`
|
|
188
|
+
</details>
|
|
136
189
|
|
|
137
|
-
|
|
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 |
|
|
190
|
+
<details>
|
|
191
|
+
<summary><b>MongoDB</b> — NoSQL, TTL indexes, MEAN/MERN stacks</summary>
|
|
143
192
|
|
|
144
|
-
### Valkey (Redis Alternative)
|
|
145
193
|
```typescript
|
|
146
|
-
import {
|
|
147
|
-
import {
|
|
194
|
+
import { mongoStore } from '@joint-ops/hitlimit-bun/stores/mongodb'
|
|
195
|
+
import { MongoClient } from 'mongodb'
|
|
148
196
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
limit: 100,
|
|
153
|
-
window: '1m'
|
|
154
|
-
}, handler)
|
|
155
|
-
})
|
|
197
|
+
const client = new MongoClient('mongodb://localhost:27017')
|
|
198
|
+
const db = client.db('myapp')
|
|
199
|
+
Bun.serve({ fetch: hitlimit({ store: mongoStore({ db }) }, handler) })
|
|
156
200
|
```
|
|
201
|
+
Peer dep: `mongodb`
|
|
202
|
+
</details>
|
|
203
|
+
|
|
204
|
+
<details>
|
|
205
|
+
<summary><b>MySQL</b> — SQL distributed, LAMP stacks</summary>
|
|
157
206
|
|
|
158
|
-
### DragonflyDB
|
|
159
207
|
```typescript
|
|
160
|
-
import {
|
|
161
|
-
import
|
|
208
|
+
import { mysqlStore } from '@joint-ops/hitlimit-bun/stores/mysql'
|
|
209
|
+
import mysql from 'mysql2/promise'
|
|
162
210
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
store: dragonflyStore({ url: 'redis://localhost:6379' }),
|
|
166
|
-
limit: 100,
|
|
167
|
-
window: '1m'
|
|
168
|
-
}, handler)
|
|
169
|
-
})
|
|
211
|
+
const pool = mysql.createPool('mysql://root@localhost:3306/mydb')
|
|
212
|
+
Bun.serve({ fetch: hitlimit({ store: mysqlStore({ pool }) }, handler) })
|
|
170
213
|
```
|
|
214
|
+
Peer dep: `mysql2`
|
|
215
|
+
</details>
|
|
171
216
|
|
|
172
217
|
---
|
|
173
218
|
|
|
@@ -175,14 +220,18 @@ Bun.serve({
|
|
|
175
220
|
|
|
176
221
|
### Bun vs Node.js — Memory Store, 10K unique IPs
|
|
177
222
|
|
|
223
|
+
<!-- BENCH:BUN_VS_NODE_TABLE -->
|
|
178
224
|
| Runtime | Ops/sec | |
|
|
179
225
|
|---------|---------|---|
|
|
180
|
-
| **Bun** | **
|
|
181
|
-
| Node.js |
|
|
226
|
+
| **Bun** | **5,574,103** | ████████████████████ |
|
|
227
|
+
| Node.js | 4,082,874 | ███████████████ |
|
|
228
|
+
<!-- /BENCH:BUN_VS_NODE_TABLE -->
|
|
182
229
|
|
|
183
|
-
|
|
230
|
+
<!-- BENCH:BUN_VS_NODE_TEXT -->
|
|
231
|
+
Bun leads at 10K IPs (5.57M vs 4.08M) and single-IP (7.73M vs 5.96M). 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.
|
|
232
|
+
<!-- /BENCH:BUN_VS_NODE_TEXT -->
|
|
184
233
|
|
|
185
|
-
### Why bun:sqlite
|
|
234
|
+
### Why bun:sqlite doesn't need bindings
|
|
186
235
|
|
|
187
236
|
```
|
|
188
237
|
Node.js: JS → N-API → C++ binding → SQLite
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { HitLimitStore } from '@joint-ops/hitlimit-types';
|
|
2
|
+
export interface MongoStoreOptions {
|
|
3
|
+
/** MongoDB Db instance (from MongoClient.db()) */
|
|
4
|
+
db: any;
|
|
5
|
+
/** Collection name prefix. Default: 'hitlimit' */
|
|
6
|
+
collectionPrefix?: string;
|
|
7
|
+
/** Skip TTL index creation (if you manage indexes yourself). Default: false */
|
|
8
|
+
skipIndexCreation?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function mongoStore(options: MongoStoreOptions): HitLimitStore;
|
|
11
|
+
//# sourceMappingURL=mongodb.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mongodb.d.ts","sourceRoot":"","sources":["../../src/stores/mongodb.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,2BAA2B,CAAA;AAE7F,MAAM,WAAW,iBAAiB;IAChC,kDAAkD;IAClD,EAAE,EAAE,GAAG,CAAA;IACP,kDAAkD;IAClD,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,+EAA+E;IAC/E,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AA+KD,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,aAAa,CAEpE"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/stores/mongodb.ts
|
|
3
|
+
class MongoStore {
|
|
4
|
+
db;
|
|
5
|
+
prefix;
|
|
6
|
+
indexesReady = null;
|
|
7
|
+
ready;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
this.db = options.db;
|
|
10
|
+
this.prefix = options.collectionPrefix ?? "hitlimit";
|
|
11
|
+
if (options.skipIndexCreation) {
|
|
12
|
+
this.ready = true;
|
|
13
|
+
} else {
|
|
14
|
+
this.ready = false;
|
|
15
|
+
this.indexesReady = this.createIndexes().then(() => {
|
|
16
|
+
this.ready = true;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
col(name) {
|
|
21
|
+
return this.db.collection(`${this.prefix}_${name}`);
|
|
22
|
+
}
|
|
23
|
+
async createIndexes() {
|
|
24
|
+
await this.col("hits").createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
|
25
|
+
await this.col("bans").createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
|
26
|
+
await this.col("violations").createIndex({ expireAt: 1 }, { expireAfterSeconds: 0 });
|
|
27
|
+
await this.col("hits").createIndex({ key: 1 }, { unique: true });
|
|
28
|
+
await this.col("bans").createIndex({ key: 1 }, { unique: true });
|
|
29
|
+
await this.col("violations").createIndex({ key: 1 }, { unique: true });
|
|
30
|
+
}
|
|
31
|
+
async atomicIncrement(collection, key, windowMs) {
|
|
32
|
+
const now = Date.now();
|
|
33
|
+
const resetAt = now + windowMs;
|
|
34
|
+
const expireAt = new Date(resetAt);
|
|
35
|
+
try {
|
|
36
|
+
const result = await collection.findOneAndUpdate({ key, resetAt: { $gt: now } }, {
|
|
37
|
+
$inc: { count: 1 },
|
|
38
|
+
$setOnInsert: { key, resetAt, expireAt }
|
|
39
|
+
}, { upsert: true, returnDocument: "after" });
|
|
40
|
+
return { count: result.count, resetAt: result.resetAt };
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (err?.code === 11000) {
|
|
43
|
+
const replaced = await collection.findOneAndUpdate({ key, resetAt: { $lte: now } }, { $set: { count: 1, resetAt, expireAt } }, { returnDocument: "after" });
|
|
44
|
+
if (replaced) {
|
|
45
|
+
return { count: replaced.count, resetAt: replaced.resetAt };
|
|
46
|
+
}
|
|
47
|
+
return this.atomicIncrement(collection, key, windowMs);
|
|
48
|
+
}
|
|
49
|
+
throw err;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async hit(key, windowMs, _limit) {
|
|
53
|
+
if (!this.ready)
|
|
54
|
+
await this.indexesReady;
|
|
55
|
+
return this.atomicIncrement(this.col("hits"), key, windowMs);
|
|
56
|
+
}
|
|
57
|
+
async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
|
|
58
|
+
if (!this.ready)
|
|
59
|
+
await this.indexesReady;
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
const resetAt = now + windowMs;
|
|
62
|
+
const banExpiresAt = now + banDurationMs;
|
|
63
|
+
const ban = await this.col("bans").findOne({ key, expiresAt: { $gt: now } });
|
|
64
|
+
if (ban) {
|
|
65
|
+
return {
|
|
66
|
+
count: 0,
|
|
67
|
+
resetAt,
|
|
68
|
+
banned: true,
|
|
69
|
+
violations: 0,
|
|
70
|
+
banExpiresAt: ban.expiresAt
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const hitResult = await this.atomicIncrement(this.col("hits"), key, windowMs);
|
|
74
|
+
const hitCount = hitResult.count;
|
|
75
|
+
const hitResetAt = hitResult.resetAt;
|
|
76
|
+
if (hitCount > limit) {
|
|
77
|
+
const violationResult = await this.atomicIncrement(this.col("violations"), key, banDurationMs);
|
|
78
|
+
const violations = violationResult.count;
|
|
79
|
+
const shouldBan = violations >= banThreshold;
|
|
80
|
+
if (shouldBan) {
|
|
81
|
+
await this.col("bans").updateOne({ key }, { $set: { expiresAt: banExpiresAt, expireAt: new Date(banExpiresAt) } }, { upsert: true });
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
count: hitCount,
|
|
85
|
+
resetAt: hitResetAt,
|
|
86
|
+
banned: shouldBan,
|
|
87
|
+
violations,
|
|
88
|
+
banExpiresAt: shouldBan ? banExpiresAt : 0
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
count: hitCount,
|
|
93
|
+
resetAt: hitResetAt,
|
|
94
|
+
banned: false,
|
|
95
|
+
violations: 0,
|
|
96
|
+
banExpiresAt: 0
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async isBanned(key) {
|
|
100
|
+
if (!this.ready)
|
|
101
|
+
await this.indexesReady;
|
|
102
|
+
const ban = await this.col("bans").findOne({ key, expiresAt: { $gt: Date.now() } });
|
|
103
|
+
return ban !== null;
|
|
104
|
+
}
|
|
105
|
+
async ban(key, durationMs) {
|
|
106
|
+
if (!this.ready)
|
|
107
|
+
await this.indexesReady;
|
|
108
|
+
const expiresAt = Date.now() + durationMs;
|
|
109
|
+
await this.col("bans").updateOne({ key }, { $set: { expiresAt, expireAt: new Date(expiresAt) } }, { upsert: true });
|
|
110
|
+
}
|
|
111
|
+
async recordViolation(key, windowMs) {
|
|
112
|
+
if (!this.ready)
|
|
113
|
+
await this.indexesReady;
|
|
114
|
+
const result = await this.atomicIncrement(this.col("violations"), key, windowMs);
|
|
115
|
+
return result.count;
|
|
116
|
+
}
|
|
117
|
+
async reset(key) {
|
|
118
|
+
if (!this.ready)
|
|
119
|
+
await this.indexesReady;
|
|
120
|
+
await Promise.all([
|
|
121
|
+
this.col("hits").deleteOne({ key }),
|
|
122
|
+
this.col("bans").deleteOne({ key }),
|
|
123
|
+
this.col("violations").deleteOne({ key })
|
|
124
|
+
]);
|
|
125
|
+
}
|
|
126
|
+
shutdown() {}
|
|
127
|
+
}
|
|
128
|
+
function mongoStore(options) {
|
|
129
|
+
return new MongoStore(options);
|
|
130
|
+
}
|
|
131
|
+
export {
|
|
132
|
+
mongoStore
|
|
133
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HitLimitStore } from '@joint-ops/hitlimit-types';
|
|
2
|
+
export interface MySQLStoreOptions {
|
|
3
|
+
/** mysql2/promise Pool instance */
|
|
4
|
+
pool: any;
|
|
5
|
+
/** Table name prefix. Default: 'hitlimit' */
|
|
6
|
+
tablePrefix?: string;
|
|
7
|
+
/** Cleanup interval in ms. Default: 60000 (1 minute) */
|
|
8
|
+
cleanupInterval?: number;
|
|
9
|
+
/** Skip table creation (if you manage schema yourself). Default: false */
|
|
10
|
+
skipTableCreation?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare function mysqlStore(options: MySQLStoreOptions): HitLimitStore;
|
|
13
|
+
//# sourceMappingURL=mysql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mysql.d.ts","sourceRoot":"","sources":["../../src/stores/mysql.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,2BAA2B,CAAA;AAE7F,MAAM,WAAW,iBAAiB;IAChC,mCAAmC;IACnC,IAAI,EAAE,GAAG,CAAA;IACT,6CAA6C;IAC7C,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,wDAAwD;IACxD,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AA2ND,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,aAAa,CAEpE"}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/stores/mysql.ts
|
|
3
|
+
class MySQLStore {
|
|
4
|
+
pool;
|
|
5
|
+
prefix;
|
|
6
|
+
cleanupTimer = null;
|
|
7
|
+
tablesReady = null;
|
|
8
|
+
ready;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.pool = options.pool;
|
|
11
|
+
this.prefix = options.tablePrefix ?? "hitlimit";
|
|
12
|
+
if (options.skipTableCreation) {
|
|
13
|
+
this.ready = true;
|
|
14
|
+
} else {
|
|
15
|
+
this.ready = false;
|
|
16
|
+
this.tablesReady = this.createTables().then(() => {
|
|
17
|
+
this.ready = true;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const interval = options.cleanupInterval ?? 60000;
|
|
21
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), interval);
|
|
22
|
+
if (typeof this.cleanupTimer.unref === "function") {
|
|
23
|
+
this.cleanupTimer.unref();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async createTables() {
|
|
27
|
+
await this.pool.execute(`
|
|
28
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_hits (
|
|
29
|
+
\`key\` VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
30
|
+
count INT NOT NULL DEFAULT 1,
|
|
31
|
+
reset_at BIGINT NOT NULL
|
|
32
|
+
) ENGINE=InnoDB
|
|
33
|
+
`);
|
|
34
|
+
await this.pool.execute(`
|
|
35
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_bans (
|
|
36
|
+
\`key\` VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
37
|
+
expires_at BIGINT NOT NULL
|
|
38
|
+
) ENGINE=InnoDB
|
|
39
|
+
`);
|
|
40
|
+
await this.pool.execute(`
|
|
41
|
+
CREATE TABLE IF NOT EXISTS ${this.prefix}_violations (
|
|
42
|
+
\`key\` VARCHAR(255) NOT NULL PRIMARY KEY,
|
|
43
|
+
count INT NOT NULL DEFAULT 1,
|
|
44
|
+
reset_at BIGINT NOT NULL
|
|
45
|
+
) ENGINE=InnoDB
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
async hit(key, windowMs, _limit) {
|
|
49
|
+
if (!this.ready)
|
|
50
|
+
await this.tablesReady;
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const resetAt = now + windowMs;
|
|
53
|
+
const conn = await this.pool.getConnection();
|
|
54
|
+
try {
|
|
55
|
+
await conn.execute(`
|
|
56
|
+
INSERT INTO ${this.prefix}_hits (\`key\`, count, reset_at)
|
|
57
|
+
VALUES (?, LAST_INSERT_ID(1), ?)
|
|
58
|
+
ON DUPLICATE KEY UPDATE
|
|
59
|
+
count = LAST_INSERT_ID(IF(reset_at <= ?, 1, count + 1)),
|
|
60
|
+
reset_at = IF(reset_at <= ?, ?, reset_at)
|
|
61
|
+
`, [key, resetAt, now, now, resetAt]);
|
|
62
|
+
const [rows] = await conn.execute(`SELECT LAST_INSERT_ID() AS count, reset_at FROM ${this.prefix}_hits WHERE \`key\` = ?`, [key]);
|
|
63
|
+
return { count: Number(rows[0].count), resetAt: Number(rows[0].reset_at) };
|
|
64
|
+
} finally {
|
|
65
|
+
conn.release();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async hitWithBan(key, windowMs, limit, banThreshold, banDurationMs) {
|
|
69
|
+
if (!this.ready)
|
|
70
|
+
await this.tablesReady;
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
const resetAt = now + windowMs;
|
|
73
|
+
const banExpiresAt = now + banDurationMs;
|
|
74
|
+
const [banRows] = await this.pool.execute(`SELECT expires_at FROM ${this.prefix}_bans WHERE \`key\` = ? AND expires_at > ?`, [key, now]);
|
|
75
|
+
if (banRows.length > 0) {
|
|
76
|
+
return {
|
|
77
|
+
count: 0,
|
|
78
|
+
resetAt,
|
|
79
|
+
banned: true,
|
|
80
|
+
violations: 0,
|
|
81
|
+
banExpiresAt: Number(banRows[0].expires_at)
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const conn = await this.pool.getConnection();
|
|
85
|
+
try {
|
|
86
|
+
await conn.execute(`
|
|
87
|
+
INSERT INTO ${this.prefix}_hits (\`key\`, count, reset_at)
|
|
88
|
+
VALUES (?, LAST_INSERT_ID(1), ?)
|
|
89
|
+
ON DUPLICATE KEY UPDATE
|
|
90
|
+
count = LAST_INSERT_ID(IF(reset_at <= ?, 1, count + 1)),
|
|
91
|
+
reset_at = IF(reset_at <= ?, ?, reset_at)
|
|
92
|
+
`, [key, resetAt, now, now, resetAt]);
|
|
93
|
+
const [hitRows] = await conn.execute(`SELECT LAST_INSERT_ID() AS count, reset_at FROM ${this.prefix}_hits WHERE \`key\` = ?`, [key]);
|
|
94
|
+
const hitCount = Number(hitRows[0].count);
|
|
95
|
+
const hitResetAt = Number(hitRows[0].reset_at);
|
|
96
|
+
if (hitCount > limit) {
|
|
97
|
+
await conn.execute(`
|
|
98
|
+
INSERT INTO ${this.prefix}_violations (\`key\`, count, reset_at)
|
|
99
|
+
VALUES (?, LAST_INSERT_ID(1), ?)
|
|
100
|
+
ON DUPLICATE KEY UPDATE
|
|
101
|
+
count = LAST_INSERT_ID(IF(reset_at <= ?, 1, count + 1)),
|
|
102
|
+
reset_at = IF(reset_at <= ?, ?, reset_at)
|
|
103
|
+
`, [key, banExpiresAt, now, now, banExpiresAt]);
|
|
104
|
+
const [violRows] = await conn.execute(`SELECT LAST_INSERT_ID() AS count FROM ${this.prefix}_violations WHERE \`key\` = ?`, [key]);
|
|
105
|
+
const violations = Number(violRows[0].count);
|
|
106
|
+
const shouldBan = violations >= banThreshold;
|
|
107
|
+
if (shouldBan) {
|
|
108
|
+
await conn.execute(`
|
|
109
|
+
INSERT INTO ${this.prefix}_bans (\`key\`, expires_at) VALUES (?, ?)
|
|
110
|
+
ON DUPLICATE KEY UPDATE expires_at = ?
|
|
111
|
+
`, [key, banExpiresAt, banExpiresAt]);
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
count: hitCount,
|
|
115
|
+
resetAt: hitResetAt,
|
|
116
|
+
banned: shouldBan,
|
|
117
|
+
violations,
|
|
118
|
+
banExpiresAt: shouldBan ? banExpiresAt : 0
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
count: hitCount,
|
|
123
|
+
resetAt: hitResetAt,
|
|
124
|
+
banned: false,
|
|
125
|
+
violations: 0,
|
|
126
|
+
banExpiresAt: 0
|
|
127
|
+
};
|
|
128
|
+
} finally {
|
|
129
|
+
conn.release();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async isBanned(key) {
|
|
133
|
+
if (!this.ready)
|
|
134
|
+
await this.tablesReady;
|
|
135
|
+
const [rows] = await this.pool.execute(`SELECT 1 FROM ${this.prefix}_bans WHERE \`key\` = ? AND expires_at > ?`, [key, Date.now()]);
|
|
136
|
+
return rows.length > 0;
|
|
137
|
+
}
|
|
138
|
+
async ban(key, durationMs) {
|
|
139
|
+
if (!this.ready)
|
|
140
|
+
await this.tablesReady;
|
|
141
|
+
const expiresAt = Date.now() + durationMs;
|
|
142
|
+
await this.pool.execute(`
|
|
143
|
+
INSERT INTO ${this.prefix}_bans (\`key\`, expires_at) VALUES (?, ?)
|
|
144
|
+
ON DUPLICATE KEY UPDATE expires_at = ?
|
|
145
|
+
`, [key, expiresAt, expiresAt]);
|
|
146
|
+
}
|
|
147
|
+
async recordViolation(key, windowMs) {
|
|
148
|
+
if (!this.ready)
|
|
149
|
+
await this.tablesReady;
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
const resetAt = now + windowMs;
|
|
152
|
+
const conn = await this.pool.getConnection();
|
|
153
|
+
try {
|
|
154
|
+
await conn.execute(`
|
|
155
|
+
INSERT INTO ${this.prefix}_violations (\`key\`, count, reset_at) VALUES (?, LAST_INSERT_ID(1), ?)
|
|
156
|
+
ON DUPLICATE KEY UPDATE
|
|
157
|
+
count = LAST_INSERT_ID(IF(reset_at <= ?, 1, count + 1)),
|
|
158
|
+
reset_at = IF(reset_at <= ?, ?, reset_at)
|
|
159
|
+
`, [key, resetAt, now, now, resetAt]);
|
|
160
|
+
const [rows] = await conn.execute(`SELECT LAST_INSERT_ID() AS count FROM ${this.prefix}_violations WHERE \`key\` = ?`, [key]);
|
|
161
|
+
return Number(rows[0].count);
|
|
162
|
+
} finally {
|
|
163
|
+
conn.release();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async reset(key) {
|
|
167
|
+
if (!this.ready)
|
|
168
|
+
await this.tablesReady;
|
|
169
|
+
await Promise.all([
|
|
170
|
+
this.pool.execute(`DELETE FROM ${this.prefix}_hits WHERE \`key\` = ?`, [key]),
|
|
171
|
+
this.pool.execute(`DELETE FROM ${this.prefix}_bans WHERE \`key\` = ?`, [key]),
|
|
172
|
+
this.pool.execute(`DELETE FROM ${this.prefix}_violations WHERE \`key\` = ?`, [key])
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
async cleanup() {
|
|
176
|
+
try {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
await this.pool.execute(`DELETE FROM ${this.prefix}_hits WHERE reset_at <= ?`, [now]);
|
|
179
|
+
await this.pool.execute(`DELETE FROM ${this.prefix}_bans WHERE expires_at <= ?`, [now]);
|
|
180
|
+
await this.pool.execute(`DELETE FROM ${this.prefix}_violations WHERE reset_at <= ?`, [now]);
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
shutdown() {
|
|
184
|
+
if (this.cleanupTimer) {
|
|
185
|
+
clearInterval(this.cleanupTimer);
|
|
186
|
+
this.cleanupTimer = null;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function mysqlStore(options) {
|
|
191
|
+
return new MySQLStore(options);
|
|
192
|
+
}
|
|
193
|
+
export {
|
|
194
|
+
mysqlStore
|
|
195
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/stores/sqlite.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAE3E,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;
|
|
1
|
+
{"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/stores/sqlite.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAE3E,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAiHD,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
|
package/dist/stores/sqlite.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joint-ops/hitlimit-bun",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -23,6 +23,14 @@
|
|
|
23
23
|
{
|
|
24
24
|
"name": "sultandilaram",
|
|
25
25
|
"url": "https://github.com/sultandilaram"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "MoizYousuf",
|
|
29
|
+
"url": "https://github.com/MoizYousuf"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"name": "shoaib-tumi",
|
|
33
|
+
"url": "https://github.com/shoaib-tumi"
|
|
26
34
|
}
|
|
27
35
|
],
|
|
28
36
|
"license": "MIT",
|
|
@@ -92,7 +100,14 @@
|
|
|
92
100
|
"dragonfly",
|
|
93
101
|
"dragonflydb",
|
|
94
102
|
"dragonfly-rate-limit",
|
|
95
|
-
"redis-alternative"
|
|
103
|
+
"redis-alternative",
|
|
104
|
+
"mongodb",
|
|
105
|
+
"mongodb-rate-limit",
|
|
106
|
+
"mongoose-rate-limit",
|
|
107
|
+
"mysql",
|
|
108
|
+
"mysql-rate-limit",
|
|
109
|
+
"mysql2-rate-limit",
|
|
110
|
+
"database-rate-limit"
|
|
96
111
|
],
|
|
97
112
|
"type": "module",
|
|
98
113
|
"main": "./dist/index.js",
|
|
@@ -143,6 +158,16 @@
|
|
|
143
158
|
"types": "./dist/stores/dragonfly.d.ts",
|
|
144
159
|
"bun": "./dist/stores/dragonfly.js",
|
|
145
160
|
"import": "./dist/stores/dragonfly.js"
|
|
161
|
+
},
|
|
162
|
+
"./stores/mongodb": {
|
|
163
|
+
"types": "./dist/stores/mongodb.d.ts",
|
|
164
|
+
"bun": "./dist/stores/mongodb.js",
|
|
165
|
+
"import": "./dist/stores/mongodb.js"
|
|
166
|
+
},
|
|
167
|
+
"./stores/mysql": {
|
|
168
|
+
"types": "./dist/stores/mysql.d.ts",
|
|
169
|
+
"bun": "./dist/stores/mysql.js",
|
|
170
|
+
"import": "./dist/stores/mysql.js"
|
|
146
171
|
}
|
|
147
172
|
},
|
|
148
173
|
"files": [
|
|
@@ -150,18 +175,20 @@
|
|
|
150
175
|
],
|
|
151
176
|
"sideEffects": false,
|
|
152
177
|
"scripts": {
|
|
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",
|
|
178
|
+
"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 ./src/stores/mongodb.ts ./src/stores/mysql.ts --outdir=./dist --root=./src --target=bun --external=elysia --external=hono --external=ioredis --external=pg --external=mongodb --external=mysql2 --external=@sinclair/typebox && tsc --emitDeclarationOnly",
|
|
154
179
|
"clean": "rm -rf dist",
|
|
155
180
|
"test": "bun test",
|
|
156
181
|
"test:watch": "bun test --watch"
|
|
157
182
|
},
|
|
158
183
|
"dependencies": {
|
|
159
|
-
"@joint-ops/hitlimit-types": "1.
|
|
184
|
+
"@joint-ops/hitlimit-types": "1.4.0"
|
|
160
185
|
},
|
|
161
186
|
"peerDependencies": {
|
|
162
187
|
"elysia": ">=1.0.0",
|
|
163
188
|
"hono": ">=4.0.0",
|
|
164
189
|
"ioredis": ">=5.0.0",
|
|
190
|
+
"mongodb": ">=6.0.0",
|
|
191
|
+
"mysql2": ">=3.0.0",
|
|
165
192
|
"pg": ">=8.0.0"
|
|
166
193
|
},
|
|
167
194
|
"peerDependenciesMeta": {
|
|
@@ -174,6 +201,12 @@
|
|
|
174
201
|
"ioredis": {
|
|
175
202
|
"optional": true
|
|
176
203
|
},
|
|
204
|
+
"mongodb": {
|
|
205
|
+
"optional": true
|
|
206
|
+
},
|
|
207
|
+
"mysql2": {
|
|
208
|
+
"optional": true
|
|
209
|
+
},
|
|
177
210
|
"pg": {
|
|
178
211
|
"optional": true
|
|
179
212
|
}
|
|
@@ -185,6 +218,8 @@
|
|
|
185
218
|
"elysia": "^1.0.0",
|
|
186
219
|
"hono": "^4.11.9",
|
|
187
220
|
"ioredis": "^5.3.0",
|
|
221
|
+
"mongodb": "^7.1.0",
|
|
222
|
+
"mysql2": "^3.18.2",
|
|
188
223
|
"pg": "^8.13.0",
|
|
189
224
|
"typescript": "^5.3.0"
|
|
190
225
|
}
|