@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 CHANGED
@@ -19,20 +19,22 @@
19
19
  ```
20
20
  ┌─────────────────────────────────────────────────────────────────┐
21
21
  │ │
22
- │ bun:sqlite ████████████████████████████ 520,000 ops/s
23
- │ better-sqlite3 ██████████████████░░░░░░░░░░ 400,000 ops/s │
22
+ │ bun:sqlite ████████████████████████░░░ 386,000 ops/s
23
+ │ better-sqlite3 ████████████████████████░░░ 400,000 ops/s*
24
24
  │ │
25
- │ bun:sqlite is 30% faster with zero FFI overhead
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
- - **7.2M ops/sec** - Memory store performance
32
- - **520K ops/sec** - With bun:sqlite persistence
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,CAiB1B"}
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,CAoBxB"}
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"}
@@ -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;AAK7F,wBAAsB,UAAU,CAAC,QAAQ,EACvC,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,cAAc,CAAC,CAiDzB"}
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,CAWrB"}
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
- this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(Date.now());
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
- return {
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.key(req);
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
- const tierConfig = config.tiers[tierName];
131
- if (tierConfig) {
132
- limit = tierConfig.limit;
133
- if (tierConfig.window) {
134
- windowMs = parseWindow(tierConfig.window);
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
- export type { HitLimitOptions, HitLimitInfo, HitLimitResult, HitLimitStore, StoreResult, TierConfig, HeadersConfig, ResolvedConfig, KeyGenerator, TierResolver, SkipFunction, StoreErrorHandler, ResponseFormatter, ResponseConfig } from '@joint-ops/hitlimit-types';
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 } from './core/limiter.js';
6
+ export { checkLimit };
6
7
  export interface BunHitLimitOptions extends HitLimitOptions<Request> {
7
8
  sqlitePath?: string;
8
9
  }
@@ -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;AAIhE,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,MAAM,2BAA2B,CAAA;AACrQ,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,MAAM,mBAAmB,CAAA;AAE9C,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,CAkLnE;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,CAsF3E"}
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
- this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(Date.now());
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
- return {
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.key(req);
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
- const tierConfig = config.tiers[tierName];
133
- if (tierConfig) {
134
- limit = tierConfig.limit;
135
- if (tierConfig.window) {
136
- windowMs = parseWindow(tierConfig.window);
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, () => "unknown");
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 key = customKey ? await customKey(req) : getDefaultKey(req, server);
253
- let effectiveLimit = limit;
254
- let effectiveWindowMs = windowMs;
255
- if (hasTiers) {
256
- const tierName = await config.tier(req);
257
- const tierConfig = config.tiers[tierName];
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
- if (standardHeaders || legacyHeaders) {
290
- const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
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
- if (standardHeaders) {
294
- newHeaders.set("RateLimit-Limit", String(effectiveLimit));
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, () => "unknown");
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
- if (hasSkip) {
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 key = customKey ? await customKey(req) : getDefaultKey(req, server);
345
- let effectiveLimit = limit;
346
- let effectiveWindowMs = windowMs;
347
- if (hasTiers) {
348
- const tierName = await config.tier(req);
349
- const tierConfig = config.tiers[tierName];
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;AA+D3E,wBAAgB,WAAW,IAAI,aAAa,CAE3C"}
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"}
@@ -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;AA2CD,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa,CAErE"}
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"}
@@ -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;AAuDD,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;AA+GD,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
@@ -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
- this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(Date.now());
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.4",
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.4"
114
+ "@joint-ops/hitlimit-types": "1.0.6"
115
115
  },
116
116
  "peerDependencies": {
117
117
  "elysia": ">=1.0.0",