@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 CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  > Rate limiting built for Bun. Not ported — built.
4
4
 
5
- **12.38M ops/sec** on memory. **8.32M at 10K IPs**. Native bun:sqlite. Atomic Redis Lua. Postgres. Zero dependencies.
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
- ## 6 Storage Backends
99
+ ## Pick Your Store
98
100
 
99
- All built in. No extra packages to install.
101
+ Every store is built in. Swap one line your rate limiting code stays the same.
100
102
 
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` |
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
- // Memory (default) — fastest, no config
114
- Bun.serve({ fetch: hitlimit({}, handler) })
129
+ Bun.serve({ fetch: hitlimit({ store: ______Store({ /* config */ }) }, handler) })
130
+ ```
115
131
 
116
- // bun:sqlite — persists across restarts, native performance
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
- // Redis — distributed, atomic Lua scripts
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
- // Valkey — open-source Redis alternative
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
- // DragonflyDB — high-throughput Redis alternative
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
- // Postgres — distributed, atomic upserts
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
- | 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 |
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 { hitlimit } from '@joint-ops/hitlimit-bun'
147
- import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
194
+ import { mongoStore } from '@joint-ops/hitlimit-bun/stores/mongodb'
195
+ import { MongoClient } from 'mongodb'
148
196
 
149
- Bun.serve({
150
- fetch: hitlimit({
151
- store: valkeyStore({ url: 'redis://localhost:6379' }),
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 { hitlimit } from '@joint-ops/hitlimit-bun'
161
- import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
208
+ import { mysqlStore } from '@joint-ops/hitlimit-bun/stores/mysql'
209
+ import mysql from 'mysql2/promise'
162
210
 
163
- Bun.serve({
164
- fetch: hitlimit({
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** | **8,320,000** | ████████████████████ |
181
- | Node.js | 3,160,000 | ████████ |
226
+ | **Bun** | **5,574,103** | ████████████████████ |
227
+ | Node.js | 4,082,874 | ███████████████ |
228
+ <!-- /BENCH:BUN_VS_NODE_TABLE -->
182
229
 
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.
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 is faster than better-sqlite3
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;AAgHD,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
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"}
@@ -17,6 +17,7 @@ class BunSqliteStore {
17
17
  cleanupTimer;
18
18
  constructor(options = {}) {
19
19
  this.db = new Database(options.path ?? ":memory:");
20
+ this.db.exec("PRAGMA journal_mode = WAL");
20
21
  this.db.exec(`
21
22
  CREATE TABLE IF NOT EXISTS hitlimit (
22
23
  key TEXT PRIMARY KEY,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joint-ops/hitlimit-bun",
3
- "version": "1.3.0",
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.3.0"
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
  }