@joint-ops/hitlimit-bun 1.0.4 → 1.0.6
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 +47 -6
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/headers.d.ts.map +1 -1
- package/dist/core/limiter.d.ts +8 -0
- package/dist/core/limiter.d.ts.map +1 -1
- package/dist/core/response.d.ts.map +1 -1
- package/dist/elysia.js +122 -14
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +159 -138
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +62 -0
- package/dist/stores/redis.d.ts.map +1 -1
- package/dist/stores/redis.js +20 -1
- package/dist/stores/sqlite.d.ts.map +1 -1
- package/dist/stores/sqlite.js +49 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -19,20 +19,22 @@
|
|
|
19
19
|
```
|
|
20
20
|
┌─────────────────────────────────────────────────────────────────┐
|
|
21
21
|
│ │
|
|
22
|
-
│ bun:sqlite
|
|
23
|
-
│ better-sqlite3
|
|
22
|
+
│ bun:sqlite ████████████████████████░░░ 386,000 ops/s │
|
|
23
|
+
│ better-sqlite3 ████████████████████████░░░ 400,000 ops/s* │
|
|
24
24
|
│ │
|
|
25
|
-
│ bun:sqlite
|
|
25
|
+
│ *estimated - bun:sqlite has zero FFI overhead │
|
|
26
26
|
│ │
|
|
27
27
|
└─────────────────────────────────────────────────────────────────┘
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
- **Bun Native** - Built specifically for Bun's runtime, not a Node.js port
|
|
31
|
-
- **
|
|
32
|
-
- **
|
|
31
|
+
- **6.1M ops/sec** - Memory store (multi-IP scenarios)
|
|
32
|
+
- **386K ops/sec** - With bun:sqlite (multi-IP scenarios)
|
|
33
33
|
- **Zero Config** - Works out of the box with sensible defaults
|
|
34
34
|
- **Elysia Plugin** - First-class Elysia framework integration
|
|
35
35
|
- **TypeScript First** - Full type safety and IntelliSense support
|
|
36
|
+
- **Auto-Ban** - Automatically ban repeat offenders after threshold violations
|
|
37
|
+
- **Shared Limits** - Group rate limits via groupId for teams/tenants
|
|
36
38
|
- **Tiny Footprint** - ~23KB total, zero runtime dependencies
|
|
37
39
|
|
|
38
40
|
## Installation
|
|
@@ -141,6 +143,36 @@ hitlimit({
|
|
|
141
143
|
}, handler)
|
|
142
144
|
```
|
|
143
145
|
|
|
146
|
+
### Auto-Ban Repeat Offenders
|
|
147
|
+
|
|
148
|
+
Automatically ban clients that repeatedly exceed rate limits.
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
hitlimit({
|
|
152
|
+
limit: 10,
|
|
153
|
+
window: '1m',
|
|
154
|
+
ban: {
|
|
155
|
+
threshold: 5, // Ban after 5 violations
|
|
156
|
+
duration: '1h' // Ban lasts 1 hour
|
|
157
|
+
}
|
|
158
|
+
}, handler)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Banned clients receive `X-RateLimit-Ban: true` header and `banned: true` in the response body.
|
|
162
|
+
|
|
163
|
+
### Grouped / Shared Limits
|
|
164
|
+
|
|
165
|
+
Rate limit by organization, API key, or any shared identifier.
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
// Per-API-key rate limiting
|
|
169
|
+
hitlimit({
|
|
170
|
+
limit: 1000,
|
|
171
|
+
window: '1h',
|
|
172
|
+
group: (req) => req.headers.get('x-api-key') || 'anonymous'
|
|
173
|
+
}, handler)
|
|
174
|
+
```
|
|
175
|
+
|
|
144
176
|
### Elysia Route-Specific Limits
|
|
145
177
|
|
|
146
178
|
Apply different limits to different route groups in Elysia.
|
|
@@ -213,7 +245,16 @@ hitlimit({
|
|
|
213
245
|
onStoreError: (error, req) => {
|
|
214
246
|
console.error('Store error:', error)
|
|
215
247
|
return 'allow' // or 'deny'
|
|
216
|
-
}
|
|
248
|
+
},
|
|
249
|
+
|
|
250
|
+
// Ban repeat offenders
|
|
251
|
+
ban: {
|
|
252
|
+
threshold: 5, // violations before ban
|
|
253
|
+
duration: '1h' // ban duration
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
// Group/shared limits
|
|
257
|
+
group: (req) => req.headers.get('x-api-key') || 'default'
|
|
217
258
|
}, handler)
|
|
218
259
|
```
|
|
219
260
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,YAAY,EACZ,cAAc,EACf,MAAM,2BAA2B,CAAA;AAGlC,wBAAgB,aAAa,CAAC,QAAQ,EACpC,OAAO,EAAE,eAAe,CAAC,QAAQ,CAAC,EAClC,YAAY,EAAE,aAAa,EAC3B,UAAU,EAAE,YAAY,CAAC,QAAQ,CAAC,GACjC,cAAc,CAAC,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,YAAY,EACZ,cAAc,EACf,MAAM,2BAA2B,CAAA;AAGlC,wBAAgB,aAAa,CAAC,QAAQ,EACpC,OAAO,EAAE,eAAe,CAAC,QAAQ,CAAC,EAClC,YAAY,EAAE,aAAa,EAC3B,UAAU,EAAE,YAAY,CAAC,QAAQ,CAAC,GACjC,cAAc,CAAC,QAAQ,CAAC,CAqB1B"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../../src/core/headers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAE5E,wBAAgB,YAAY,CAC1B,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,QAAQ,CAAC,aAAa,CAAC,EAC/B,OAAO,EAAE,OAAO,GACf,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,
|
|
1
|
+
{"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../../src/core/headers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAE5E,wBAAgB,YAAY,CAC1B,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,QAAQ,CAAC,aAAa,CAAC,EAC/B,OAAO,EAAE,OAAO,GACf,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CA4BxB"}
|
package/dist/core/limiter.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
import type { HitLimitResult, ResolvedConfig } from '@joint-ops/hitlimit-types';
|
|
2
|
+
export interface FastResult {
|
|
3
|
+
allowed: boolean;
|
|
4
|
+
limit: number;
|
|
5
|
+
remaining: number;
|
|
6
|
+
resetIn: number;
|
|
7
|
+
resetAt: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function checkLimitFast<TRequest>(config: ResolvedConfig<TRequest>, req: TRequest): Promise<FastResult>;
|
|
2
10
|
export declare function checkLimit<TRequest>(config: ResolvedConfig<TRequest>, req: TRequest): Promise<HitLimitResult>;
|
|
3
11
|
//# sourceMappingURL=limiter.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"limiter.d.ts","sourceRoot":"","sources":["../../src/core/limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;
|
|
1
|
+
{"version":3,"file":"limiter.d.ts","sourceRoot":"","sources":["../../src/core/limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAM7F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,OAAO,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,SAAS,EAAE,MAAM,CAAA;IACjB,OAAO,EAAE,MAAM,CAAA;IACf,OAAO,EAAE,MAAM,CAAA;CAChB;AAsCD,wBAAsB,cAAc,CAAC,QAAQ,EAC3C,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,UAAU,CAAC,CA8CrB;AAGD,wBAAsB,UAAU,CAAC,QAAQ,EACvC,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,cAAc,CAAC,CA6EzB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/core/response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAEhG,wBAAgB,SAAS,CACvB,QAAQ,EAAE,cAAc,GAAG,iBAAiB,EAC5C,IAAI,EAAE,YAAY,GACjB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,
|
|
1
|
+
{"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/core/response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAEhG,wBAAgB,SAAS,CACvB,QAAQ,EAAE,cAAc,GAAG,iBAAiB,EAC5C,IAAI,EAAE,YAAY,GACjB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAmBrB"}
|
package/dist/elysia.js
CHANGED
|
@@ -7,6 +7,12 @@ class BunSqliteStore {
|
|
|
7
7
|
hitStmt;
|
|
8
8
|
getStmt;
|
|
9
9
|
resetStmt;
|
|
10
|
+
isBannedStmt;
|
|
11
|
+
banStmt;
|
|
12
|
+
recordViolationStmt;
|
|
13
|
+
getViolationStmt;
|
|
14
|
+
resetBanStmt;
|
|
15
|
+
resetViolationStmt;
|
|
10
16
|
cleanupTimer;
|
|
11
17
|
constructor(options = {}) {
|
|
12
18
|
this.db = new Database(options.path ?? ":memory:");
|
|
@@ -17,6 +23,19 @@ class BunSqliteStore {
|
|
|
17
23
|
reset_at INTEGER NOT NULL
|
|
18
24
|
)
|
|
19
25
|
`);
|
|
26
|
+
this.db.exec(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS hitlimit_bans (
|
|
28
|
+
key TEXT PRIMARY KEY,
|
|
29
|
+
expires_at INTEGER NOT NULL
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
this.db.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS hitlimit_violations (
|
|
34
|
+
key TEXT PRIMARY KEY,
|
|
35
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
36
|
+
reset_at INTEGER NOT NULL
|
|
37
|
+
)
|
|
38
|
+
`);
|
|
20
39
|
this.hitStmt = this.db.prepare(`
|
|
21
40
|
INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
|
|
22
41
|
ON CONFLICT(key) DO UPDATE SET
|
|
@@ -25,8 +44,22 @@ class BunSqliteStore {
|
|
|
25
44
|
`);
|
|
26
45
|
this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
|
|
27
46
|
this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
|
|
47
|
+
this.isBannedStmt = this.db.prepare("SELECT 1 FROM hitlimit_bans WHERE key = ?1 AND expires_at > ?2");
|
|
48
|
+
this.banStmt = this.db.prepare("INSERT OR REPLACE INTO hitlimit_bans (key, expires_at) VALUES (?1, ?2)");
|
|
49
|
+
this.recordViolationStmt = this.db.prepare(`
|
|
50
|
+
INSERT INTO hitlimit_violations (key, count, reset_at) VALUES (?1, 1, ?2)
|
|
51
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
52
|
+
count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
|
|
53
|
+
reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
|
|
54
|
+
`);
|
|
55
|
+
this.getViolationStmt = this.db.prepare("SELECT count FROM hitlimit_violations WHERE key = ?");
|
|
56
|
+
this.resetBanStmt = this.db.prepare("DELETE FROM hitlimit_bans WHERE key = ?");
|
|
57
|
+
this.resetViolationStmt = this.db.prepare("DELETE FROM hitlimit_violations WHERE key = ?");
|
|
28
58
|
this.cleanupTimer = setInterval(() => {
|
|
29
|
-
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(now);
|
|
61
|
+
this.db.prepare("DELETE FROM hitlimit_bans WHERE expires_at <= ?").run(now);
|
|
62
|
+
this.db.prepare("DELETE FROM hitlimit_violations WHERE reset_at <= ?").run(now);
|
|
30
63
|
}, 60000);
|
|
31
64
|
}
|
|
32
65
|
hit(key, windowMs, _limit) {
|
|
@@ -36,8 +69,23 @@ class BunSqliteStore {
|
|
|
36
69
|
const row = this.getStmt.get(key);
|
|
37
70
|
return { count: row.count, resetAt: row.reset_at };
|
|
38
71
|
}
|
|
72
|
+
isBanned(key) {
|
|
73
|
+
return this.isBannedStmt.get(key, Date.now()) !== null;
|
|
74
|
+
}
|
|
75
|
+
ban(key, durationMs) {
|
|
76
|
+
this.banStmt.run(key, Date.now() + durationMs);
|
|
77
|
+
}
|
|
78
|
+
recordViolation(key, windowMs) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const resetAt = now + windowMs;
|
|
81
|
+
this.recordViolationStmt.run(key, resetAt, now);
|
|
82
|
+
const row = this.getViolationStmt.get(key);
|
|
83
|
+
return row?.count ?? 1;
|
|
84
|
+
}
|
|
39
85
|
reset(key) {
|
|
40
86
|
this.resetStmt.run(key);
|
|
87
|
+
this.resetBanStmt.run(key);
|
|
88
|
+
this.resetViolationStmt.run(key);
|
|
41
89
|
}
|
|
42
90
|
shutdown() {
|
|
43
91
|
clearInterval(this.cleanupTimer);
|
|
@@ -83,7 +131,9 @@ function resolveConfig(options, defaultStore, defaultKey) {
|
|
|
83
131
|
},
|
|
84
132
|
store: options.store ?? defaultStore,
|
|
85
133
|
onStoreError: options.onStoreError ?? (() => "allow"),
|
|
86
|
-
skip: options.skip
|
|
134
|
+
skip: options.skip,
|
|
135
|
+
ban: options.ban ? { threshold: options.ban.threshold, durationMs: parseWindow(options.ban.duration) } : null,
|
|
136
|
+
group: options.group ?? null
|
|
87
137
|
};
|
|
88
138
|
}
|
|
89
139
|
|
|
@@ -103,6 +153,12 @@ function buildHeaders(info, config, allowed) {
|
|
|
103
153
|
if (!allowed && config.retryAfter) {
|
|
104
154
|
headers["Retry-After"] = String(info.resetIn);
|
|
105
155
|
}
|
|
156
|
+
if (info.banned) {
|
|
157
|
+
headers["X-RateLimit-Ban"] = "true";
|
|
158
|
+
if (info.banExpiresAt) {
|
|
159
|
+
headers["X-RateLimit-Ban-Expires"] = String(Math.ceil(info.banExpiresAt / 1000));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
106
162
|
return headers;
|
|
107
163
|
}
|
|
108
164
|
|
|
@@ -111,34 +167,76 @@ function buildBody(response, info) {
|
|
|
111
167
|
if (typeof response === "function") {
|
|
112
168
|
return response(info);
|
|
113
169
|
}
|
|
114
|
-
|
|
170
|
+
const body = {
|
|
115
171
|
...response,
|
|
116
172
|
limit: info.limit,
|
|
117
173
|
remaining: info.remaining,
|
|
118
174
|
resetIn: info.resetIn
|
|
119
175
|
};
|
|
176
|
+
if (info.banned) {
|
|
177
|
+
body.banned = true;
|
|
178
|
+
body.banExpiresAt = info.banExpiresAt;
|
|
179
|
+
body.message = "You have been temporarily banned due to repeated rate limit violations";
|
|
180
|
+
}
|
|
181
|
+
return body;
|
|
120
182
|
}
|
|
121
183
|
|
|
122
184
|
// src/core/limiter.ts
|
|
185
|
+
async function resolveKey(config, req) {
|
|
186
|
+
let key = await config.key(req);
|
|
187
|
+
let groupId;
|
|
188
|
+
if (config.group) {
|
|
189
|
+
groupId = typeof config.group === "function" ? await config.group(req) : config.group;
|
|
190
|
+
key = `group:${groupId}:${key}`;
|
|
191
|
+
}
|
|
192
|
+
return { key, groupId };
|
|
193
|
+
}
|
|
194
|
+
function resolveTier(config, tierName) {
|
|
195
|
+
if (tierName && config.tiers) {
|
|
196
|
+
const tierConfig = config.tiers[tierName];
|
|
197
|
+
if (tierConfig) {
|
|
198
|
+
return {
|
|
199
|
+
limit: tierConfig.limit,
|
|
200
|
+
windowMs: tierConfig.window ? parseWindow(tierConfig.window) : config.windowMs
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { limit: config.limit, windowMs: config.windowMs };
|
|
205
|
+
}
|
|
123
206
|
async function checkLimit(config, req) {
|
|
124
|
-
const key = await config
|
|
125
|
-
let limit = config.limit;
|
|
126
|
-
let windowMs = config.windowMs;
|
|
207
|
+
const { key, groupId } = await resolveKey(config, req);
|
|
127
208
|
let tierName;
|
|
128
209
|
if (config.tier && config.tiers) {
|
|
129
210
|
tierName = await config.tier(req);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
211
|
+
}
|
|
212
|
+
const { limit, windowMs } = resolveTier(config, tierName);
|
|
213
|
+
if (config.ban && config.store.isBanned) {
|
|
214
|
+
const banned = await config.store.isBanned(key);
|
|
215
|
+
if (banned) {
|
|
216
|
+
const banResetIn = Math.ceil(config.ban.durationMs / 1000);
|
|
217
|
+
const info2 = {
|
|
218
|
+
limit,
|
|
219
|
+
remaining: 0,
|
|
220
|
+
resetIn: banResetIn,
|
|
221
|
+
resetAt: Date.now() + config.ban.durationMs,
|
|
222
|
+
key,
|
|
223
|
+
tier: tierName,
|
|
224
|
+
banned: true,
|
|
225
|
+
banExpiresAt: Date.now() + config.ban.durationMs,
|
|
226
|
+
group: groupId
|
|
227
|
+
};
|
|
228
|
+
return {
|
|
229
|
+
allowed: false,
|
|
230
|
+
info: info2,
|
|
231
|
+
headers: buildHeaders(info2, config.headers, false),
|
|
232
|
+
body: buildBody(config.response, info2)
|
|
233
|
+
};
|
|
136
234
|
}
|
|
137
235
|
}
|
|
138
236
|
if (limit === Infinity) {
|
|
139
237
|
return {
|
|
140
238
|
allowed: true,
|
|
141
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName },
|
|
239
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
142
240
|
headers: {},
|
|
143
241
|
body: {}
|
|
144
242
|
};
|
|
@@ -154,8 +252,18 @@ async function checkLimit(config, req) {
|
|
|
154
252
|
resetIn,
|
|
155
253
|
resetAt: result.resetAt,
|
|
156
254
|
key,
|
|
157
|
-
tier: tierName
|
|
255
|
+
tier: tierName,
|
|
256
|
+
group: groupId
|
|
158
257
|
};
|
|
258
|
+
if (!allowed && config.ban && config.store.recordViolation) {
|
|
259
|
+
const violations = await config.store.recordViolation(key, config.ban.durationMs);
|
|
260
|
+
info.violations = violations;
|
|
261
|
+
if (violations >= config.ban.threshold && config.store.ban) {
|
|
262
|
+
await config.store.ban(key, config.ban.durationMs);
|
|
263
|
+
info.banned = true;
|
|
264
|
+
info.banExpiresAt = now + config.ban.durationMs;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
159
267
|
return {
|
|
160
268
|
allowed,
|
|
161
269
|
info,
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { HitLimitOptions } from '@joint-ops/hitlimit-types';
|
|
2
|
-
|
|
2
|
+
import { checkLimit } from './core/limiter.js';
|
|
3
|
+
export type { HitLimitOptions, HitLimitInfo, HitLimitResult, HitLimitStore, StoreResult, TierConfig, HeadersConfig, ResolvedConfig, KeyGenerator, TierResolver, SkipFunction, StoreErrorHandler, ResponseFormatter, ResponseConfig, BanConfig, GroupIdResolver } from '@joint-ops/hitlimit-types';
|
|
3
4
|
export { DEFAULT_LIMIT, DEFAULT_WINDOW, DEFAULT_WINDOW_MS, DEFAULT_MESSAGE } from '@joint-ops/hitlimit-types';
|
|
4
5
|
export { sqliteStore } from './stores/sqlite.js';
|
|
5
|
-
export { checkLimit }
|
|
6
|
+
export { checkLimit };
|
|
6
7
|
export interface BunHitLimitOptions extends HitLimitOptions<Request> {
|
|
7
8
|
sqlitePath?: string;
|
|
8
9
|
}
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAEhE,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAG9C,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AACjS,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAC7G,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAAE,UAAU,EAAE,CAAA;AAErB,MAAM,WAAW,kBAAmB,SAAQ,eAAe,CAAC,OAAO,CAAC;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,KAAK,SAAS,GAAG;IAAE,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CAAE,CAAA;AAExE,KAAK,YAAY,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAMrF,wBAAgB,QAAQ,CACtB,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,YAAY,GACpB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAiJnE;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAA;IAClF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CACzC;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAgD3E"}
|
package/dist/index.js
CHANGED
|
@@ -7,6 +7,12 @@ class BunSqliteStore {
|
|
|
7
7
|
hitStmt;
|
|
8
8
|
getStmt;
|
|
9
9
|
resetStmt;
|
|
10
|
+
isBannedStmt;
|
|
11
|
+
banStmt;
|
|
12
|
+
recordViolationStmt;
|
|
13
|
+
getViolationStmt;
|
|
14
|
+
resetBanStmt;
|
|
15
|
+
resetViolationStmt;
|
|
10
16
|
cleanupTimer;
|
|
11
17
|
constructor(options = {}) {
|
|
12
18
|
this.db = new Database(options.path ?? ":memory:");
|
|
@@ -17,6 +23,19 @@ class BunSqliteStore {
|
|
|
17
23
|
reset_at INTEGER NOT NULL
|
|
18
24
|
)
|
|
19
25
|
`);
|
|
26
|
+
this.db.exec(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS hitlimit_bans (
|
|
28
|
+
key TEXT PRIMARY KEY,
|
|
29
|
+
expires_at INTEGER NOT NULL
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
this.db.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS hitlimit_violations (
|
|
34
|
+
key TEXT PRIMARY KEY,
|
|
35
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
36
|
+
reset_at INTEGER NOT NULL
|
|
37
|
+
)
|
|
38
|
+
`);
|
|
20
39
|
this.hitStmt = this.db.prepare(`
|
|
21
40
|
INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
|
|
22
41
|
ON CONFLICT(key) DO UPDATE SET
|
|
@@ -25,8 +44,22 @@ class BunSqliteStore {
|
|
|
25
44
|
`);
|
|
26
45
|
this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
|
|
27
46
|
this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
|
|
47
|
+
this.isBannedStmt = this.db.prepare("SELECT 1 FROM hitlimit_bans WHERE key = ?1 AND expires_at > ?2");
|
|
48
|
+
this.banStmt = this.db.prepare("INSERT OR REPLACE INTO hitlimit_bans (key, expires_at) VALUES (?1, ?2)");
|
|
49
|
+
this.recordViolationStmt = this.db.prepare(`
|
|
50
|
+
INSERT INTO hitlimit_violations (key, count, reset_at) VALUES (?1, 1, ?2)
|
|
51
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
52
|
+
count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
|
|
53
|
+
reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
|
|
54
|
+
`);
|
|
55
|
+
this.getViolationStmt = this.db.prepare("SELECT count FROM hitlimit_violations WHERE key = ?");
|
|
56
|
+
this.resetBanStmt = this.db.prepare("DELETE FROM hitlimit_bans WHERE key = ?");
|
|
57
|
+
this.resetViolationStmt = this.db.prepare("DELETE FROM hitlimit_violations WHERE key = ?");
|
|
28
58
|
this.cleanupTimer = setInterval(() => {
|
|
29
|
-
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(now);
|
|
61
|
+
this.db.prepare("DELETE FROM hitlimit_bans WHERE expires_at <= ?").run(now);
|
|
62
|
+
this.db.prepare("DELETE FROM hitlimit_violations WHERE reset_at <= ?").run(now);
|
|
30
63
|
}, 60000);
|
|
31
64
|
}
|
|
32
65
|
hit(key, windowMs, _limit) {
|
|
@@ -36,8 +69,23 @@ class BunSqliteStore {
|
|
|
36
69
|
const row = this.getStmt.get(key);
|
|
37
70
|
return { count: row.count, resetAt: row.reset_at };
|
|
38
71
|
}
|
|
72
|
+
isBanned(key) {
|
|
73
|
+
return this.isBannedStmt.get(key, Date.now()) !== null;
|
|
74
|
+
}
|
|
75
|
+
ban(key, durationMs) {
|
|
76
|
+
this.banStmt.run(key, Date.now() + durationMs);
|
|
77
|
+
}
|
|
78
|
+
recordViolation(key, windowMs) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const resetAt = now + windowMs;
|
|
81
|
+
this.recordViolationStmt.run(key, resetAt, now);
|
|
82
|
+
const row = this.getViolationStmt.get(key);
|
|
83
|
+
return row?.count ?? 1;
|
|
84
|
+
}
|
|
39
85
|
reset(key) {
|
|
40
86
|
this.resetStmt.run(key);
|
|
87
|
+
this.resetBanStmt.run(key);
|
|
88
|
+
this.resetViolationStmt.run(key);
|
|
41
89
|
}
|
|
42
90
|
shutdown() {
|
|
43
91
|
clearInterval(this.cleanupTimer);
|
|
@@ -80,15 +128,12 @@ function resolveConfig(options, defaultStore, defaultKey) {
|
|
|
80
128
|
},
|
|
81
129
|
store: options.store ?? defaultStore,
|
|
82
130
|
onStoreError: options.onStoreError ?? (() => "allow"),
|
|
83
|
-
skip: options.skip
|
|
131
|
+
skip: options.skip,
|
|
132
|
+
ban: options.ban ? { threshold: options.ban.threshold, durationMs: parseWindow(options.ban.duration) } : null,
|
|
133
|
+
group: options.group ?? null
|
|
84
134
|
};
|
|
85
135
|
}
|
|
86
136
|
|
|
87
|
-
// ../types/dist/index.js
|
|
88
|
-
var DEFAULT_LIMIT = 100;
|
|
89
|
-
var DEFAULT_WINDOW = "1m";
|
|
90
|
-
var DEFAULT_WINDOW_MS = 60000;
|
|
91
|
-
var DEFAULT_MESSAGE = "Whoa there! Rate limit exceeded.";
|
|
92
137
|
// src/core/headers.ts
|
|
93
138
|
function buildHeaders(info, config, allowed) {
|
|
94
139
|
const headers = {};
|
|
@@ -105,6 +150,12 @@ function buildHeaders(info, config, allowed) {
|
|
|
105
150
|
if (!allowed && config.retryAfter) {
|
|
106
151
|
headers["Retry-After"] = String(info.resetIn);
|
|
107
152
|
}
|
|
153
|
+
if (info.banned) {
|
|
154
|
+
headers["X-RateLimit-Ban"] = "true";
|
|
155
|
+
if (info.banExpiresAt) {
|
|
156
|
+
headers["X-RateLimit-Ban-Expires"] = String(Math.ceil(info.banExpiresAt / 1000));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
108
159
|
return headers;
|
|
109
160
|
}
|
|
110
161
|
|
|
@@ -113,34 +164,76 @@ function buildBody(response, info) {
|
|
|
113
164
|
if (typeof response === "function") {
|
|
114
165
|
return response(info);
|
|
115
166
|
}
|
|
116
|
-
|
|
167
|
+
const body = {
|
|
117
168
|
...response,
|
|
118
169
|
limit: info.limit,
|
|
119
170
|
remaining: info.remaining,
|
|
120
171
|
resetIn: info.resetIn
|
|
121
172
|
};
|
|
173
|
+
if (info.banned) {
|
|
174
|
+
body.banned = true;
|
|
175
|
+
body.banExpiresAt = info.banExpiresAt;
|
|
176
|
+
body.message = "You have been temporarily banned due to repeated rate limit violations";
|
|
177
|
+
}
|
|
178
|
+
return body;
|
|
122
179
|
}
|
|
123
180
|
|
|
124
181
|
// src/core/limiter.ts
|
|
182
|
+
async function resolveKey(config, req) {
|
|
183
|
+
let key = await config.key(req);
|
|
184
|
+
let groupId;
|
|
185
|
+
if (config.group) {
|
|
186
|
+
groupId = typeof config.group === "function" ? await config.group(req) : config.group;
|
|
187
|
+
key = `group:${groupId}:${key}`;
|
|
188
|
+
}
|
|
189
|
+
return { key, groupId };
|
|
190
|
+
}
|
|
191
|
+
function resolveTier(config, tierName) {
|
|
192
|
+
if (tierName && config.tiers) {
|
|
193
|
+
const tierConfig = config.tiers[tierName];
|
|
194
|
+
if (tierConfig) {
|
|
195
|
+
return {
|
|
196
|
+
limit: tierConfig.limit,
|
|
197
|
+
windowMs: tierConfig.window ? parseWindow(tierConfig.window) : config.windowMs
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { limit: config.limit, windowMs: config.windowMs };
|
|
202
|
+
}
|
|
125
203
|
async function checkLimit(config, req) {
|
|
126
|
-
const key = await config
|
|
127
|
-
let limit = config.limit;
|
|
128
|
-
let windowMs = config.windowMs;
|
|
204
|
+
const { key, groupId } = await resolveKey(config, req);
|
|
129
205
|
let tierName;
|
|
130
206
|
if (config.tier && config.tiers) {
|
|
131
207
|
tierName = await config.tier(req);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
208
|
+
}
|
|
209
|
+
const { limit, windowMs } = resolveTier(config, tierName);
|
|
210
|
+
if (config.ban && config.store.isBanned) {
|
|
211
|
+
const banned = await config.store.isBanned(key);
|
|
212
|
+
if (banned) {
|
|
213
|
+
const banResetIn = Math.ceil(config.ban.durationMs / 1000);
|
|
214
|
+
const info2 = {
|
|
215
|
+
limit,
|
|
216
|
+
remaining: 0,
|
|
217
|
+
resetIn: banResetIn,
|
|
218
|
+
resetAt: Date.now() + config.ban.durationMs,
|
|
219
|
+
key,
|
|
220
|
+
tier: tierName,
|
|
221
|
+
banned: true,
|
|
222
|
+
banExpiresAt: Date.now() + config.ban.durationMs,
|
|
223
|
+
group: groupId
|
|
224
|
+
};
|
|
225
|
+
return {
|
|
226
|
+
allowed: false,
|
|
227
|
+
info: info2,
|
|
228
|
+
headers: buildHeaders(info2, config.headers, false),
|
|
229
|
+
body: buildBody(config.response, info2)
|
|
230
|
+
};
|
|
138
231
|
}
|
|
139
232
|
}
|
|
140
233
|
if (limit === Infinity) {
|
|
141
234
|
return {
|
|
142
235
|
allowed: true,
|
|
143
|
-
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName },
|
|
236
|
+
info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName, group: groupId },
|
|
144
237
|
headers: {},
|
|
145
238
|
body: {}
|
|
146
239
|
};
|
|
@@ -156,8 +249,18 @@ async function checkLimit(config, req) {
|
|
|
156
249
|
resetIn,
|
|
157
250
|
resetAt: result.resetAt,
|
|
158
251
|
key,
|
|
159
|
-
tier: tierName
|
|
252
|
+
tier: tierName,
|
|
253
|
+
group: groupId
|
|
160
254
|
};
|
|
255
|
+
if (!allowed && config.ban && config.store.recordViolation) {
|
|
256
|
+
const violations = await config.store.recordViolation(key, config.ban.durationMs);
|
|
257
|
+
info.violations = violations;
|
|
258
|
+
if (violations >= config.ban.threshold && config.store.ban) {
|
|
259
|
+
await config.store.ban(key, config.ban.durationMs);
|
|
260
|
+
info.banned = true;
|
|
261
|
+
info.banExpiresAt = now + config.ban.durationMs;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
161
264
|
return {
|
|
162
265
|
allowed,
|
|
163
266
|
info,
|
|
@@ -166,15 +269,26 @@ async function checkLimit(config, req) {
|
|
|
166
269
|
};
|
|
167
270
|
}
|
|
168
271
|
|
|
272
|
+
// ../types/dist/index.js
|
|
273
|
+
var DEFAULT_LIMIT = 100;
|
|
274
|
+
var DEFAULT_WINDOW = "1m";
|
|
275
|
+
var DEFAULT_WINDOW_MS = 60000;
|
|
276
|
+
var DEFAULT_MESSAGE = "Whoa there! Rate limit exceeded.";
|
|
169
277
|
// src/index.ts
|
|
170
278
|
function getDefaultKey(req, server) {
|
|
171
279
|
return server.requestIP(req)?.address || "unknown";
|
|
172
280
|
}
|
|
173
281
|
function hitlimit(options, handler) {
|
|
282
|
+
let activeServer;
|
|
283
|
+
const defaultKey = (req) => {
|
|
284
|
+
return activeServer?.requestIP(req)?.address || "unknown";
|
|
285
|
+
};
|
|
174
286
|
const store = options.store ?? sqliteStore({ path: options.sqlitePath });
|
|
175
|
-
const config = resolveConfig(options, store,
|
|
287
|
+
const config = resolveConfig(options, store, options.key ?? defaultKey);
|
|
176
288
|
const hasSkip = !!config.skip;
|
|
177
289
|
const hasTiers = !!(config.tier && config.tiers);
|
|
290
|
+
const hasBan = !!config.ban;
|
|
291
|
+
const hasGroup = !!config.group;
|
|
178
292
|
const standardHeaders = config.headers.standard;
|
|
179
293
|
const legacyHeaders = config.headers.legacy;
|
|
180
294
|
const retryAfterHeader = config.headers.retryAfter;
|
|
@@ -183,7 +297,7 @@ function hitlimit(options, handler) {
|
|
|
183
297
|
const response = config.response;
|
|
184
298
|
const customKey = options.key;
|
|
185
299
|
const blockedBody = JSON.stringify(response);
|
|
186
|
-
if (!hasSkip && !hasTiers) {
|
|
300
|
+
if (!hasSkip && !hasTiers && !hasBan && !hasGroup) {
|
|
187
301
|
return async (req, server) => {
|
|
188
302
|
try {
|
|
189
303
|
const key = customKey ? await customKey(req) : getDefaultKey(req, server);
|
|
@@ -242,6 +356,7 @@ function hitlimit(options, handler) {
|
|
|
242
356
|
};
|
|
243
357
|
}
|
|
244
358
|
return async (req, server) => {
|
|
359
|
+
activeServer = server;
|
|
245
360
|
if (hasSkip) {
|
|
246
361
|
const shouldSkip = await config.skip(req);
|
|
247
362
|
if (shouldSkip) {
|
|
@@ -249,56 +364,19 @@ function hitlimit(options, handler) {
|
|
|
249
364
|
}
|
|
250
365
|
}
|
|
251
366
|
try {
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (tierConfig) {
|
|
259
|
-
effectiveLimit = tierConfig.limit;
|
|
260
|
-
if (tierConfig.window) {
|
|
261
|
-
effectiveWindowMs = parseWindow2(tierConfig.window);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (effectiveLimit === Infinity) {
|
|
266
|
-
return handler(req, server);
|
|
267
|
-
}
|
|
268
|
-
const result = await store.hit(key, effectiveWindowMs, effectiveLimit);
|
|
269
|
-
const allowed = result.count <= effectiveLimit;
|
|
270
|
-
if (!allowed) {
|
|
271
|
-
const headers = { "Content-Type": "application/json" };
|
|
272
|
-
const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
|
|
273
|
-
if (standardHeaders) {
|
|
274
|
-
headers["RateLimit-Limit"] = String(effectiveLimit);
|
|
275
|
-
headers["RateLimit-Remaining"] = "0";
|
|
276
|
-
headers["RateLimit-Reset"] = String(resetIn);
|
|
277
|
-
}
|
|
278
|
-
if (legacyHeaders) {
|
|
279
|
-
headers["X-RateLimit-Limit"] = String(effectiveLimit);
|
|
280
|
-
headers["X-RateLimit-Remaining"] = "0";
|
|
281
|
-
headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000));
|
|
282
|
-
}
|
|
283
|
-
if (retryAfterHeader) {
|
|
284
|
-
headers["Retry-After"] = String(resetIn);
|
|
285
|
-
}
|
|
286
|
-
return new Response(blockedBody, { status: 429, headers });
|
|
367
|
+
const result = await checkLimit(config, req);
|
|
368
|
+
if (!result.allowed) {
|
|
369
|
+
return new Response(JSON.stringify(result.body), {
|
|
370
|
+
status: 429,
|
|
371
|
+
headers: { "Content-Type": "application/json", ...result.headers }
|
|
372
|
+
});
|
|
287
373
|
}
|
|
288
374
|
const res = await handler(req, server);
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const remaining = Math.max(0, effectiveLimit - result.count);
|
|
375
|
+
const headerEntries = Object.entries(result.headers);
|
|
376
|
+
if (headerEntries.length > 0) {
|
|
292
377
|
const newHeaders = new Headers(res.headers);
|
|
293
|
-
|
|
294
|
-
newHeaders.set(
|
|
295
|
-
newHeaders.set("RateLimit-Remaining", String(remaining));
|
|
296
|
-
newHeaders.set("RateLimit-Reset", String(resetIn));
|
|
297
|
-
}
|
|
298
|
-
if (legacyHeaders) {
|
|
299
|
-
newHeaders.set("X-RateLimit-Limit", String(effectiveLimit));
|
|
300
|
-
newHeaders.set("X-RateLimit-Remaining", String(remaining));
|
|
301
|
-
newHeaders.set("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
|
|
378
|
+
for (const [key, value] of headerEntries) {
|
|
379
|
+
newHeaders.set(key, value);
|
|
302
380
|
}
|
|
303
381
|
return new Response(res.body, {
|
|
304
382
|
status: res.status,
|
|
@@ -320,62 +398,28 @@ function hitlimit(options, handler) {
|
|
|
320
398
|
};
|
|
321
399
|
}
|
|
322
400
|
function createHitLimit(options = {}) {
|
|
401
|
+
let activeServer;
|
|
402
|
+
const defaultKey = (req) => {
|
|
403
|
+
return activeServer?.requestIP(req)?.address || "unknown";
|
|
404
|
+
};
|
|
323
405
|
const store = options.store ?? sqliteStore({ path: options.sqlitePath });
|
|
324
|
-
const config = resolveConfig(options, store,
|
|
325
|
-
const hasSkip = !!config.skip;
|
|
326
|
-
const hasTiers = !!(config.tier && config.tiers);
|
|
327
|
-
const standardHeaders = config.headers.standard;
|
|
328
|
-
const legacyHeaders = config.headers.legacy;
|
|
329
|
-
const retryAfterHeader = config.headers.retryAfter;
|
|
330
|
-
const limit = config.limit;
|
|
331
|
-
const windowMs = config.windowMs;
|
|
332
|
-
const response = config.response;
|
|
333
|
-
const customKey = options.key;
|
|
334
|
-
const blockedBody = JSON.stringify(response);
|
|
406
|
+
const config = resolveConfig(options, store, options.key ?? defaultKey);
|
|
335
407
|
return {
|
|
336
408
|
async check(req, server) {
|
|
337
|
-
|
|
409
|
+
activeServer = server;
|
|
410
|
+
if (config.skip) {
|
|
338
411
|
const shouldSkip = await config.skip(req);
|
|
339
412
|
if (shouldSkip) {
|
|
340
413
|
return null;
|
|
341
414
|
}
|
|
342
415
|
}
|
|
343
416
|
try {
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (tierConfig) {
|
|
351
|
-
effectiveLimit = tierConfig.limit;
|
|
352
|
-
if (tierConfig.window) {
|
|
353
|
-
effectiveWindowMs = parseWindow2(tierConfig.window);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
if (effectiveLimit === Infinity) {
|
|
358
|
-
return null;
|
|
359
|
-
}
|
|
360
|
-
const result = await store.hit(key, effectiveWindowMs, effectiveLimit);
|
|
361
|
-
const allowed = result.count <= effectiveLimit;
|
|
362
|
-
if (!allowed) {
|
|
363
|
-
const headers = { "Content-Type": "application/json" };
|
|
364
|
-
const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
|
|
365
|
-
if (standardHeaders) {
|
|
366
|
-
headers["RateLimit-Limit"] = String(effectiveLimit);
|
|
367
|
-
headers["RateLimit-Remaining"] = "0";
|
|
368
|
-
headers["RateLimit-Reset"] = String(resetIn);
|
|
369
|
-
}
|
|
370
|
-
if (legacyHeaders) {
|
|
371
|
-
headers["X-RateLimit-Limit"] = String(effectiveLimit);
|
|
372
|
-
headers["X-RateLimit-Remaining"] = "0";
|
|
373
|
-
headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000));
|
|
374
|
-
}
|
|
375
|
-
if (retryAfterHeader) {
|
|
376
|
-
headers["Retry-After"] = String(resetIn);
|
|
377
|
-
}
|
|
378
|
-
return new Response(blockedBody, { status: 429, headers });
|
|
417
|
+
const result = await checkLimit(config, req);
|
|
418
|
+
if (!result.allowed) {
|
|
419
|
+
return new Response(JSON.stringify(result.body), {
|
|
420
|
+
status: 429,
|
|
421
|
+
headers: { "Content-Type": "application/json", ...result.headers }
|
|
422
|
+
});
|
|
379
423
|
}
|
|
380
424
|
return null;
|
|
381
425
|
} catch (error) {
|
|
@@ -394,29 +438,6 @@ function createHitLimit(options = {}) {
|
|
|
394
438
|
}
|
|
395
439
|
};
|
|
396
440
|
}
|
|
397
|
-
function parseWindow2(window) {
|
|
398
|
-
if (typeof window === "number")
|
|
399
|
-
return window;
|
|
400
|
-
const match = window.match(/^(\d+)(ms|s|m|h|d)$/);
|
|
401
|
-
if (!match)
|
|
402
|
-
return 60000;
|
|
403
|
-
const value = parseInt(match[1], 10);
|
|
404
|
-
const unit = match[2];
|
|
405
|
-
switch (unit) {
|
|
406
|
-
case "ms":
|
|
407
|
-
return value;
|
|
408
|
-
case "s":
|
|
409
|
-
return value * 1000;
|
|
410
|
-
case "m":
|
|
411
|
-
return value * 60 * 1000;
|
|
412
|
-
case "h":
|
|
413
|
-
return value * 60 * 60 * 1000;
|
|
414
|
-
case "d":
|
|
415
|
-
return value * 24 * 60 * 60 * 1000;
|
|
416
|
-
default:
|
|
417
|
-
return 60000;
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
441
|
export {
|
|
421
442
|
sqliteStore,
|
|
422
443
|
hitlimit,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/stores/memory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/stores/memory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAgJ3E,wBAAgB,WAAW,IAAI,aAAa,CAE3C"}
|
package/dist/stores/memory.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// src/stores/memory.ts
|
|
3
3
|
class MemoryStore {
|
|
4
4
|
hits = new Map;
|
|
5
|
+
bans = new Map;
|
|
6
|
+
violations = new Map;
|
|
5
7
|
hit(key, windowMs, _limit) {
|
|
6
8
|
const entry = this.hits.get(key);
|
|
7
9
|
if (entry !== undefined) {
|
|
@@ -19,18 +21,78 @@ class MemoryStore {
|
|
|
19
21
|
this.hits.set(key, { count: 1, resetAt, timeoutId });
|
|
20
22
|
return { count: 1, resetAt };
|
|
21
23
|
}
|
|
24
|
+
isBanned(key) {
|
|
25
|
+
const ban = this.bans.get(key);
|
|
26
|
+
if (!ban)
|
|
27
|
+
return false;
|
|
28
|
+
if (Date.now() >= ban.expiresAt) {
|
|
29
|
+
clearTimeout(ban.timeoutId);
|
|
30
|
+
this.bans.delete(key);
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
ban(key, durationMs) {
|
|
36
|
+
const existing = this.bans.get(key);
|
|
37
|
+
if (existing)
|
|
38
|
+
clearTimeout(existing.timeoutId);
|
|
39
|
+
const expiresAt = Date.now() + durationMs;
|
|
40
|
+
const timeoutId = setTimeout(() => {
|
|
41
|
+
this.bans.delete(key);
|
|
42
|
+
}, durationMs);
|
|
43
|
+
if (typeof timeoutId.unref === "function") {
|
|
44
|
+
timeoutId.unref();
|
|
45
|
+
}
|
|
46
|
+
this.bans.set(key, { expiresAt, timeoutId });
|
|
47
|
+
}
|
|
48
|
+
recordViolation(key, windowMs) {
|
|
49
|
+
const entry = this.violations.get(key);
|
|
50
|
+
if (entry && Date.now() < entry.resetAt) {
|
|
51
|
+
entry.count++;
|
|
52
|
+
return entry.count;
|
|
53
|
+
}
|
|
54
|
+
if (entry)
|
|
55
|
+
clearTimeout(entry.timeoutId);
|
|
56
|
+
const resetAt = Date.now() + windowMs;
|
|
57
|
+
const timeoutId = setTimeout(() => {
|
|
58
|
+
this.violations.delete(key);
|
|
59
|
+
}, windowMs);
|
|
60
|
+
if (typeof timeoutId.unref === "function") {
|
|
61
|
+
timeoutId.unref();
|
|
62
|
+
}
|
|
63
|
+
this.violations.set(key, { count: 1, resetAt, timeoutId });
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
22
66
|
reset(key) {
|
|
23
67
|
const entry = this.hits.get(key);
|
|
24
68
|
if (entry) {
|
|
25
69
|
clearTimeout(entry.timeoutId);
|
|
26
70
|
this.hits.delete(key);
|
|
27
71
|
}
|
|
72
|
+
const ban = this.bans.get(key);
|
|
73
|
+
if (ban) {
|
|
74
|
+
clearTimeout(ban.timeoutId);
|
|
75
|
+
this.bans.delete(key);
|
|
76
|
+
}
|
|
77
|
+
const violation = this.violations.get(key);
|
|
78
|
+
if (violation) {
|
|
79
|
+
clearTimeout(violation.timeoutId);
|
|
80
|
+
this.violations.delete(key);
|
|
81
|
+
}
|
|
28
82
|
}
|
|
29
83
|
shutdown() {
|
|
30
84
|
for (const [, entry] of this.hits) {
|
|
31
85
|
clearTimeout(entry.timeoutId);
|
|
32
86
|
}
|
|
33
87
|
this.hits.clear();
|
|
88
|
+
for (const [, entry] of this.bans) {
|
|
89
|
+
clearTimeout(entry.timeoutId);
|
|
90
|
+
}
|
|
91
|
+
this.bans.clear();
|
|
92
|
+
for (const [, entry] of this.violations) {
|
|
93
|
+
clearTimeout(entry.timeoutId);
|
|
94
|
+
}
|
|
95
|
+
this.violations.clear();
|
|
34
96
|
}
|
|
35
97
|
}
|
|
36
98
|
function memoryStore() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAG3E,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;
|
|
1
|
+
{"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAG3E,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAqED,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa,CAErE"}
|
package/dist/stores/redis.js
CHANGED
|
@@ -5,9 +5,13 @@ import Redis from "ioredis";
|
|
|
5
5
|
class RedisStore {
|
|
6
6
|
redis;
|
|
7
7
|
prefix;
|
|
8
|
+
banPrefix;
|
|
9
|
+
violationPrefix;
|
|
8
10
|
constructor(options = {}) {
|
|
9
11
|
this.redis = new Redis(options.url ?? "redis://localhost:6379");
|
|
10
12
|
this.prefix = options.keyPrefix ?? "hitlimit:";
|
|
13
|
+
this.banPrefix = (options.keyPrefix ?? "hitlimit:") + "ban:";
|
|
14
|
+
this.violationPrefix = (options.keyPrefix ?? "hitlimit:") + "violations:";
|
|
11
15
|
}
|
|
12
16
|
async hit(key, windowMs, _limit) {
|
|
13
17
|
const redisKey = this.prefix + key;
|
|
@@ -22,8 +26,23 @@ class RedisStore {
|
|
|
22
26
|
const resetAt = now + ttl;
|
|
23
27
|
return { count, resetAt };
|
|
24
28
|
}
|
|
29
|
+
async isBanned(key) {
|
|
30
|
+
const result = await this.redis.exists(this.banPrefix + key);
|
|
31
|
+
return result === 1;
|
|
32
|
+
}
|
|
33
|
+
async ban(key, durationMs) {
|
|
34
|
+
await this.redis.set(this.banPrefix + key, "1", "PX", durationMs);
|
|
35
|
+
}
|
|
36
|
+
async recordViolation(key, windowMs) {
|
|
37
|
+
const redisKey = this.violationPrefix + key;
|
|
38
|
+
const count = await this.redis.incr(redisKey);
|
|
39
|
+
if (count === 1) {
|
|
40
|
+
await this.redis.pexpire(redisKey, windowMs);
|
|
41
|
+
}
|
|
42
|
+
return count;
|
|
43
|
+
}
|
|
25
44
|
async reset(key) {
|
|
26
|
-
await this.redis.del(this.prefix + key);
|
|
45
|
+
await this.redis.del(this.prefix + key, this.banPrefix + key, this.violationPrefix + key);
|
|
27
46
|
}
|
|
28
47
|
async shutdown() {
|
|
29
48
|
await this.redis.quit();
|
|
@@ -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;AA+GD,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
|
package/dist/stores/sqlite.js
CHANGED
|
@@ -7,6 +7,12 @@ class BunSqliteStore {
|
|
|
7
7
|
hitStmt;
|
|
8
8
|
getStmt;
|
|
9
9
|
resetStmt;
|
|
10
|
+
isBannedStmt;
|
|
11
|
+
banStmt;
|
|
12
|
+
recordViolationStmt;
|
|
13
|
+
getViolationStmt;
|
|
14
|
+
resetBanStmt;
|
|
15
|
+
resetViolationStmt;
|
|
10
16
|
cleanupTimer;
|
|
11
17
|
constructor(options = {}) {
|
|
12
18
|
this.db = new Database(options.path ?? ":memory:");
|
|
@@ -17,6 +23,19 @@ class BunSqliteStore {
|
|
|
17
23
|
reset_at INTEGER NOT NULL
|
|
18
24
|
)
|
|
19
25
|
`);
|
|
26
|
+
this.db.exec(`
|
|
27
|
+
CREATE TABLE IF NOT EXISTS hitlimit_bans (
|
|
28
|
+
key TEXT PRIMARY KEY,
|
|
29
|
+
expires_at INTEGER NOT NULL
|
|
30
|
+
)
|
|
31
|
+
`);
|
|
32
|
+
this.db.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS hitlimit_violations (
|
|
34
|
+
key TEXT PRIMARY KEY,
|
|
35
|
+
count INTEGER NOT NULL DEFAULT 1,
|
|
36
|
+
reset_at INTEGER NOT NULL
|
|
37
|
+
)
|
|
38
|
+
`);
|
|
20
39
|
this.hitStmt = this.db.prepare(`
|
|
21
40
|
INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
|
|
22
41
|
ON CONFLICT(key) DO UPDATE SET
|
|
@@ -25,8 +44,22 @@ class BunSqliteStore {
|
|
|
25
44
|
`);
|
|
26
45
|
this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
|
|
27
46
|
this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
|
|
47
|
+
this.isBannedStmt = this.db.prepare("SELECT 1 FROM hitlimit_bans WHERE key = ?1 AND expires_at > ?2");
|
|
48
|
+
this.banStmt = this.db.prepare("INSERT OR REPLACE INTO hitlimit_bans (key, expires_at) VALUES (?1, ?2)");
|
|
49
|
+
this.recordViolationStmt = this.db.prepare(`
|
|
50
|
+
INSERT INTO hitlimit_violations (key, count, reset_at) VALUES (?1, 1, ?2)
|
|
51
|
+
ON CONFLICT(key) DO UPDATE SET
|
|
52
|
+
count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
|
|
53
|
+
reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
|
|
54
|
+
`);
|
|
55
|
+
this.getViolationStmt = this.db.prepare("SELECT count FROM hitlimit_violations WHERE key = ?");
|
|
56
|
+
this.resetBanStmt = this.db.prepare("DELETE FROM hitlimit_bans WHERE key = ?");
|
|
57
|
+
this.resetViolationStmt = this.db.prepare("DELETE FROM hitlimit_violations WHERE key = ?");
|
|
28
58
|
this.cleanupTimer = setInterval(() => {
|
|
29
|
-
|
|
59
|
+
const now = Date.now();
|
|
60
|
+
this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(now);
|
|
61
|
+
this.db.prepare("DELETE FROM hitlimit_bans WHERE expires_at <= ?").run(now);
|
|
62
|
+
this.db.prepare("DELETE FROM hitlimit_violations WHERE reset_at <= ?").run(now);
|
|
30
63
|
}, 60000);
|
|
31
64
|
}
|
|
32
65
|
hit(key, windowMs, _limit) {
|
|
@@ -36,8 +69,23 @@ class BunSqliteStore {
|
|
|
36
69
|
const row = this.getStmt.get(key);
|
|
37
70
|
return { count: row.count, resetAt: row.reset_at };
|
|
38
71
|
}
|
|
72
|
+
isBanned(key) {
|
|
73
|
+
return this.isBannedStmt.get(key, Date.now()) !== null;
|
|
74
|
+
}
|
|
75
|
+
ban(key, durationMs) {
|
|
76
|
+
this.banStmt.run(key, Date.now() + durationMs);
|
|
77
|
+
}
|
|
78
|
+
recordViolation(key, windowMs) {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const resetAt = now + windowMs;
|
|
81
|
+
this.recordViolationStmt.run(key, resetAt, now);
|
|
82
|
+
const row = this.getViolationStmt.get(key);
|
|
83
|
+
return row?.count ?? 1;
|
|
84
|
+
}
|
|
39
85
|
reset(key) {
|
|
40
86
|
this.resetStmt.run(key);
|
|
87
|
+
this.resetBanStmt.run(key);
|
|
88
|
+
this.resetViolationStmt.run(key);
|
|
41
89
|
}
|
|
42
90
|
shutdown() {
|
|
43
91
|
clearInterval(this.cleanupTimer);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joint-ops/hitlimit-bun",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Fast Bun-native rate limiting for Bun.serve & Elysia - API throttling with bun:sqlite, high performance request limiting",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Shayan M Hussain",
|
|
@@ -111,7 +111,7 @@
|
|
|
111
111
|
"test:watch": "bun test --watch"
|
|
112
112
|
},
|
|
113
113
|
"dependencies": {
|
|
114
|
-
"@joint-ops/hitlimit-types": "1.0.
|
|
114
|
+
"@joint-ops/hitlimit-types": "1.0.6"
|
|
115
115
|
},
|
|
116
116
|
"peerDependencies": {
|
|
117
117
|
"elysia": ">=1.0.0",
|