@joint-ops/hitlimit-bun 1.2.0 → 1.3.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 +46 -1
- 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/redis.d.ts +17 -1
- package/dist/stores/redis.d.ts.map +1 -1
- package/dist/stores/redis.js +4 -1
- 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 +20 -4
package/README.md
CHANGED
|
@@ -94,10 +94,19 @@ new Elysia()
|
|
|
94
94
|
|
|
95
95
|
---
|
|
96
96
|
|
|
97
|
-
##
|
|
97
|
+
## 6 Storage Backends
|
|
98
98
|
|
|
99
99
|
All built in. No extra packages to install.
|
|
100
100
|
|
|
101
|
+
| Store | Best For | Peer Dependency |
|
|
102
|
+
|---|---|---|
|
|
103
|
+
| Memory | Development, single server | None |
|
|
104
|
+
| bun:sqlite | Single server + persistence | None (built-in) |
|
|
105
|
+
| Redis | Distributed, production | `ioredis` |
|
|
106
|
+
| **Valkey** | **Distributed, open-source Redis alternative** | `ioredis` |
|
|
107
|
+
| **DragonflyDB** | **High-throughput distributed** | `ioredis` |
|
|
108
|
+
| PostgreSQL | Shared database infrastructure | `pg` |
|
|
109
|
+
|
|
101
110
|
```typescript
|
|
102
111
|
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
103
112
|
|
|
@@ -112,6 +121,14 @@ Bun.serve({ fetch: hitlimit({ store: sqliteStore({ path: './ratelimit.db' }) },
|
|
|
112
121
|
import { redisStore } from '@joint-ops/hitlimit-bun/stores/redis'
|
|
113
122
|
Bun.serve({ fetch: hitlimit({ store: redisStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
114
123
|
|
|
124
|
+
// Valkey — open-source Redis alternative
|
|
125
|
+
import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
|
|
126
|
+
Bun.serve({ fetch: hitlimit({ store: valkeyStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
127
|
+
|
|
128
|
+
// DragonflyDB — high-throughput Redis alternative
|
|
129
|
+
import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
|
|
130
|
+
Bun.serve({ fetch: hitlimit({ store: dragonflyStore({ url: 'redis://localhost:6379' }) }, handler) })
|
|
131
|
+
|
|
115
132
|
// Postgres — distributed, atomic upserts
|
|
116
133
|
import { postgresStore } from '@joint-ops/hitlimit-bun/stores/postgres'
|
|
117
134
|
Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:5432/mydb' }) }, handler) })
|
|
@@ -124,6 +141,34 @@ Bun.serve({ fetch: hitlimit({ store: postgresStore({ url: 'postgres://localhost:
|
|
|
124
141
|
| Redis | 6,700 | 148μs | Multi-server / distributed |
|
|
125
142
|
| Postgres | 3,700 | 273μs | Multi-server / already using Postgres |
|
|
126
143
|
|
|
144
|
+
### Valkey (Redis Alternative)
|
|
145
|
+
```typescript
|
|
146
|
+
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
147
|
+
import { valkeyStore } from '@joint-ops/hitlimit-bun/stores/valkey'
|
|
148
|
+
|
|
149
|
+
Bun.serve({
|
|
150
|
+
fetch: hitlimit({
|
|
151
|
+
store: valkeyStore({ url: 'redis://localhost:6379' }),
|
|
152
|
+
limit: 100,
|
|
153
|
+
window: '1m'
|
|
154
|
+
}, handler)
|
|
155
|
+
})
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### DragonflyDB
|
|
159
|
+
```typescript
|
|
160
|
+
import { hitlimit } from '@joint-ops/hitlimit-bun'
|
|
161
|
+
import { dragonflyStore } from '@joint-ops/hitlimit-bun/stores/dragonfly'
|
|
162
|
+
|
|
163
|
+
Bun.serve({
|
|
164
|
+
fetch: hitlimit({
|
|
165
|
+
store: dragonflyStore({ url: 'redis://localhost:6379' }),
|
|
166
|
+
limit: 100,
|
|
167
|
+
window: '1m'
|
|
168
|
+
}, handler)
|
|
169
|
+
})
|
|
170
|
+
```
|
|
171
|
+
|
|
127
172
|
---
|
|
128
173
|
|
|
129
174
|
## Performance
|
|
@@ -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
|
+
};
|
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
|
@@ -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.3.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",
|
|
@@ -86,7 +86,13 @@
|
|
|
86
86
|
"bun-framework",
|
|
87
87
|
"lightweight",
|
|
88
88
|
"zero-dependency",
|
|
89
|
-
"esm"
|
|
89
|
+
"esm",
|
|
90
|
+
"valkey",
|
|
91
|
+
"valkey-rate-limit",
|
|
92
|
+
"dragonfly",
|
|
93
|
+
"dragonflydb",
|
|
94
|
+
"dragonfly-rate-limit",
|
|
95
|
+
"redis-alternative"
|
|
90
96
|
],
|
|
91
97
|
"type": "module",
|
|
92
98
|
"main": "./dist/index.js",
|
|
@@ -127,6 +133,16 @@
|
|
|
127
133
|
"types": "./dist/stores/postgres.d.ts",
|
|
128
134
|
"bun": "./dist/stores/postgres.js",
|
|
129
135
|
"import": "./dist/stores/postgres.js"
|
|
136
|
+
},
|
|
137
|
+
"./stores/valkey": {
|
|
138
|
+
"types": "./dist/stores/valkey.d.ts",
|
|
139
|
+
"bun": "./dist/stores/valkey.js",
|
|
140
|
+
"import": "./dist/stores/valkey.js"
|
|
141
|
+
},
|
|
142
|
+
"./stores/dragonfly": {
|
|
143
|
+
"types": "./dist/stores/dragonfly.d.ts",
|
|
144
|
+
"bun": "./dist/stores/dragonfly.js",
|
|
145
|
+
"import": "./dist/stores/dragonfly.js"
|
|
130
146
|
}
|
|
131
147
|
},
|
|
132
148
|
"files": [
|
|
@@ -134,13 +150,13 @@
|
|
|
134
150
|
],
|
|
135
151
|
"sideEffects": false,
|
|
136
152
|
"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",
|
|
153
|
+
"build": "bun build ./src/index.ts ./src/elysia.ts ./src/hono.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts ./src/stores/postgres.ts ./src/stores/valkey.ts ./src/stores/dragonfly.ts --outdir=./dist --root=./src --target=bun --external=elysia --external=hono --external=ioredis --external=pg --external=@sinclair/typebox && tsc --emitDeclarationOnly",
|
|
138
154
|
"clean": "rm -rf dist",
|
|
139
155
|
"test": "bun test",
|
|
140
156
|
"test:watch": "bun test --watch"
|
|
141
157
|
},
|
|
142
158
|
"dependencies": {
|
|
143
|
-
"@joint-ops/hitlimit-types": "1.
|
|
159
|
+
"@joint-ops/hitlimit-types": "1.3.0"
|
|
144
160
|
},
|
|
145
161
|
"peerDependencies": {
|
|
146
162
|
"elysia": ">=1.0.0",
|