@joint-ops/hitlimit-bun 1.2.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 +112 -18
- 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/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/redis.d.ts +17 -1
- package/dist/stores/redis.d.ts.map +1 -1
- package/dist/stores/redis.js +4 -1
- package/dist/stores/sqlite.d.ts.map +1 -1
- package/dist/stores/sqlite.js +1 -0
- 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 +55 -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,35 +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.
|
|
102
|
+
|
|
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
|
|
100
124
|
|
|
101
125
|
```typescript
|
|
102
126
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
127
|
+
import { ______Store } from '@joint-ops/hitlimit-bun/stores/______'
|
|
128
|
+
|
|
129
|
+
Bun.serve({ fetch: hitlimit({ store: ______Store({ /* config */ }) }, handler) })
|
|
130
|
+
```
|
|
103
131
|
|
|
104
|
-
|
|
105
|
-
|
|
132
|
+
<details>
|
|
133
|
+
<summary><b>Memory</b> — default, zero config</summary>
|
|
106
134
|
|
|
107
|
-
|
|
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
|
|
108
144
|
import { sqliteStore } from '@joint-ops/hitlimit-bun'
|
|
109
145
|
Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) }, handler) })
|
|
146
|
+
```
|
|
147
|
+
No peer dependency — `bun:sqlite` is built into Bun.
|
|
148
|
+
</details>
|
|
149
|
+
|
|
150
|
+
<details>
|
|
151
|
+
<summary><b>Redis</b> — distributed, atomic Lua scripts</summary>
|
|
110
152
|
|
|
111
|
-
|
|
153
|
+
```typescript
|
|
112
154
|
import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
|
|
113
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>
|
|
114
162
|
|
|
115
|
-
|
|
163
|
+
```typescript
|
|
164
|
+
import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
|
|
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>
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
|
|
175
|
+
Bun.serve({ fetch: hitlimit({ store: dragonflyStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
176
|
+
```
|
|
177
|
+
Peer dep: `ioredis`
|
|
178
|
+
</details>
|
|
179
|
+
|
|
180
|
+
<details>
|
|
181
|
+
<summary><b>PostgreSQL</b> — use your existing database</summary>
|
|
182
|
+
|
|
183
|
+
```typescript
|
|
116
184
|
import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'
|
|
117
185
|
Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:5432/mydb' }) }, handler) })
|
|
118
186
|
```
|
|
187
|
+
Peer dep: `pg`
|
|
188
|
+
</details>
|
|
119
189
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
190
|
+
<details>
|
|
191
|
+
<summary><b>MongoDB</b> — NoSQL, TTL indexes, MEAN/MERN stacks</summary>
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import { mongoStore } from '@joint-ops/hitlimit-bun/stores/mongodb'
|
|
195
|
+
import { MongoClient } from 'mongodb'
|
|
196
|
+
|
|
197
|
+
const client = new MongoClient('mongodb://localhost:27017')
|
|
198
|
+
const db = client.db('myapp')
|
|
199
|
+
Bun.serve({ fetch: hitlimit({ store: mongoStore({ db }) }, handler) })
|
|
200
|
+
```
|
|
201
|
+
Peer dep: `mongodb`
|
|
202
|
+
</details>
|
|
203
|
+
|
|
204
|
+
<details>
|
|
205
|
+
<summary><b>MySQL</b> — SQL distributed, LAMP stacks</summary>
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
import { mysqlStore } from '@joint-ops/hitlimit-bun/stores/mysql'
|
|
209
|
+
import mysql from 'mysql2/promise'
|
|
210
|
+
|
|
211
|
+
const pool = mysql.createPool('mysql://root@localhost:3306/mydb')
|
|
212
|
+
Bun.serve({ fetch: hitlimit({ store: mysqlStore({ pool }) }, handler) })
|
|
213
|
+
```
|
|
214
|
+
Peer dep: `mysql2`
|
|
215
|
+
</details>
|
|
126
216
|
|
|
127
217
|
---
|
|
128
218
|
|
|
@@ -130,14 +220,18 @@ Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:
|
|
|
130
220
|
|
|
131
221
|
### Bun vs Node.js — Memory Store, 10K unique IPs
|
|
132
222
|
|
|
223
|
+
<!-- BENCH:BUN_VS_NODE_TABLE -->
|
|
133
224
|
| Runtime | Ops/sec | |
|
|
134
225
|
|---------|---------|---|
|
|
135
|
-
| **Bun** | **
|
|
136
|
-
| Node.js |
|
|
226
|
+
| **Bun** | **5,574,103** | ████████████████████ |
|
|
227
|
+
| Node.js | 4,082,874 | ███████████████ |
|
|
228
|
+
<!-- /BENCH:BUN_VS_NODE_TABLE -->
|
|
137
229
|
|
|
138
|
-
|
|
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 -->
|
|
139
233
|
|
|
140
|
-
### Why bun:sqlite
|
|
234
|
+
### Why bun:sqlite doesn't need bindings
|
|
141
235
|
|
|
142
236
|
```
|
|
143
237
|
Node.js: JS → N-API → C++ binding → SQLite
|
|
@@ -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,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
|
+
};
|
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
|
@@ -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
|
@@ -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,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",
|
|
@@ -86,7 +94,20 @@
|
|
|
86
94
|
"bun-framework",
|
|
87
95
|
"lightweight",
|
|
88
96
|
"zero-dependency",
|
|
89
|
-
"esm"
|
|
97
|
+
"esm",
|
|
98
|
+
"valkey",
|
|
99
|
+
"valkey-rate-limit",
|
|
100
|
+
"dragonfly",
|
|
101
|
+
"dragonflydb",
|
|
102
|
+
"dragonfly-rate-limit",
|
|
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"
|
|
90
111
|
],
|
|
91
112
|
"type": "module",
|
|
92
113
|
"main": "./dist/index.js",
|
|
@@ -127,6 +148,26 @@
|
|
|
127
148
|
"types": "./dist/stores/postgres.d.ts",
|
|
128
149
|
"bun": "./dist/stores/postgres.js",
|
|
129
150
|
"import": "./dist/stores/postgres.js"
|
|
151
|
+
},
|
|
152
|
+
"./stores/valkey": {
|
|
153
|
+
"types": "./dist/stores/valkey.d.ts",
|
|
154
|
+
"bun": "./dist/stores/valkey.js",
|
|
155
|
+
"import": "./dist/stores/valkey.js"
|
|
156
|
+
},
|
|
157
|
+
"./stores/dragonfly": {
|
|
158
|
+
"types": "./dist/stores/dragonfly.d.ts",
|
|
159
|
+
"bun": "./dist/stores/dragonfly.js",
|
|
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"
|
|
130
171
|
}
|
|
131
172
|
},
|
|
132
173
|
"files": [
|
|
@@ -134,18 +175,20 @@
|
|
|
134
175
|
],
|
|
135
176
|
"sideEffects": false,
|
|
136
177
|
"scripts": {
|
|
137
|
-
"build": "bun build ./src/index.ts ./src/elysia.ts ./src/hono.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts ./src/stores/postgres.ts --outdir=./dist --target=bun --external=elysia --external=hono --external=ioredis --external=pg --external=@sinclair/typebox && tsc --emitDeclarationOnly",
|
|
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",
|
|
138
179
|
"clean": "rm -rf dist",
|
|
139
180
|
"test": "bun test",
|
|
140
181
|
"test:watch": "bun test --watch"
|
|
141
182
|
},
|
|
142
183
|
"dependencies": {
|
|
143
|
-
"@joint-ops/hitlimit-types": "1.
|
|
184
|
+
"@joint-ops/hitlimit-types": "1.4.0"
|
|
144
185
|
},
|
|
145
186
|
"peerDependencies": {
|
|
146
187
|
"elysia": ">=1.0.0",
|
|
147
188
|
"hono": ">=4.0.0",
|
|
148
189
|
"ioredis": ">=5.0.0",
|
|
190
|
+
"mongodb": ">=6.0.0",
|
|
191
|
+
"mysql2": ">=3.0.0",
|
|
149
192
|
"pg": ">=8.0.0"
|
|
150
193
|
},
|
|
151
194
|
"peerDependenciesMeta": {
|
|
@@ -158,6 +201,12 @@
|
|
|
158
201
|
"ioredis": {
|
|
159
202
|
"optional": true
|
|
160
203
|
},
|
|
204
|
+
"mongodb": {
|
|
205
|
+
"optional": true
|
|
206
|
+
},
|
|
207
|
+
"mysql2": {
|
|
208
|
+
"optional": true
|
|
209
|
+
},
|
|
161
210
|
"pg": {
|
|
162
211
|
"optional": true
|
|
163
212
|
}
|
|
@@ -169,6 +218,8 @@
|
|
|
169
218
|
"elysia": "^1.0.0",
|
|
170
219
|
"hono": "^4.11.9",
|
|
171
220
|
"ioredis": "^5.3.0",
|
|
221
|
+
"mongodb": "^7.1.0",
|
|
222
|
+
"mysql2": "^3.18.2",
|
|
172
223
|
"pg": "^8.13.0",
|
|
173
224
|
"typescript": "^5.3.0"
|
|
174
225
|
}
|