limitwall 0.1.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/LICENSE +21 -0
- package/README.md +135 -0
- package/dist/index.cjs +333 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +137 -0
- package/dist/index.d.ts +137 -0
- package/dist/index.js +304 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kevin Rozario
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# limitwall
|
|
2
|
+
|
|
3
|
+
Rate limiting middleware for [Hono](https://hono.dev), backed by Redis. Supports four algorithms - fixed window, sliding window, token bucket, and leaky bucket (GCRA) behind one config-driven middleware, with atomic Lua-script checks so counts stay correct under real concurrent traffic.
|
|
4
|
+
|
|
5
|
+
Works on regular Node servers (via `ioredis`) and edge/serverless runtimes like Cloudflare Workers (via Upstash's REST-based Redis client).
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Four algorithms** - pick whichever fits your traffic shape, per rule
|
|
10
|
+
- **Atomic by design** - every check runs as a single Lua script inside Redis, so concurrent requests can't race past the limit
|
|
11
|
+
- **Edge-ready** - the Upstash store works anywhere `fetch` exists, no persistent connection required
|
|
12
|
+
- **Privacy-conscious keys** - identifiers (IPs, user IDs, etc.) are hashed before ever touching Redis
|
|
13
|
+
- **No silent defaults on failure** - you explicitly choose fail-open or fail-closed behavior when Redis is unreachable
|
|
14
|
+
- **Standard headers** - both IETF `RateLimit-*` header drafts supported, plus `Retry-After`
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pnpm install limitwall hono
|
|
20
|
+
|
|
21
|
+
# pick the store(s) you need:
|
|
22
|
+
pnpm install ioredis # for regular Node servers
|
|
23
|
+
pnpm install @upstash/redis # for edge/serverless runtimes
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick start - Node + Redis
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { serve } from "@hono/node-server";
|
|
30
|
+
import { Hono } from "hono";
|
|
31
|
+
import { Redis } from "ioredis";
|
|
32
|
+
|
|
33
|
+
import { rateLimiter, RedisStore } from "limitwall";
|
|
34
|
+
|
|
35
|
+
const app = new Hono();
|
|
36
|
+
const store = new RedisStore({ client: new Redis() });
|
|
37
|
+
|
|
38
|
+
app.use(
|
|
39
|
+
"/api/*",
|
|
40
|
+
rateLimiter({
|
|
41
|
+
algorithm: "tokenBucket",
|
|
42
|
+
ruleName: "api",
|
|
43
|
+
capacity: 10,
|
|
44
|
+
refillRate: 0.5,
|
|
45
|
+
identifier: c => c.req.header("x-forwarded-for") ?? "unknown",
|
|
46
|
+
store,
|
|
47
|
+
onError: "fail-open",
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
serve(app);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Quick start - Cloudflare Workers + Upstash
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { Redis } from "@upstash/redis/cloudflare";
|
|
58
|
+
import { Hono } from "hono";
|
|
59
|
+
|
|
60
|
+
import { rateLimiter, UpstashStore } from "limitwall";
|
|
61
|
+
|
|
62
|
+
const app = new Hono<{
|
|
63
|
+
Bindings: { UPSTASH_URL: string; UPSTASH_TOKEN: string };
|
|
64
|
+
}>();
|
|
65
|
+
|
|
66
|
+
app.use("/api/*", async (c, next) => {
|
|
67
|
+
const store = new UpstashStore({
|
|
68
|
+
client: new Redis({ url: c.env.UPSTASH_URL, token: c.env.UPSTASH_TOKEN }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return rateLimiter({
|
|
72
|
+
algorithm: "leakyBucket",
|
|
73
|
+
ruleName: "api",
|
|
74
|
+
capacity: 5,
|
|
75
|
+
rate: 2,
|
|
76
|
+
identifier: c => c.req.header("cf-connecting-ip") ?? "unknown",
|
|
77
|
+
store,
|
|
78
|
+
onError: "fail-closed",
|
|
79
|
+
})(c, next);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export default app;
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Algorithms
|
|
86
|
+
|
|
87
|
+
| Algorithm | Config fields | Behavior |
|
|
88
|
+
| --------------- | ------------------------ | ------------------------------------------------------------------------------------- |
|
|
89
|
+
| `fixedWindow` | `limit`, `windowSeconds` | Simplest. Resets on the dot every window; allows a burst right at the boundary. |
|
|
90
|
+
| `slidingWindow` | `limit`, `windowSeconds` | Blends the current and previous window's counts, avoiding the boundary-burst problem. |
|
|
91
|
+
| `tokenBucket` | `capacity`, `refillRate` | Bucket refills continuously; allows saved-up bursts up to `capacity`. |
|
|
92
|
+
| `leakyBucket` | `capacity`, `rate` | Implemented as GCRA. Smooths traffic into an even pace rather than allowing bursts. |
|
|
93
|
+
|
|
94
|
+
Every algorithm also accepts `ruleName` (required - namespaces its Redis keys) and `cost` (optional, defaults to `1`).
|
|
95
|
+
|
|
96
|
+
## Config reference
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
rateLimiter({
|
|
100
|
+
algorithm: "tokenBucket" | "fixedWindow" | "slidingWindow" | "leakyBucket",
|
|
101
|
+
ruleName: string,
|
|
102
|
+
// ...algorithm-specific fields, see table above
|
|
103
|
+
cost?: number,
|
|
104
|
+
|
|
105
|
+
identifier: (c: Context) => string, // required - what to rate limit by
|
|
106
|
+
store: RateLimitStore, // RedisStore or UpstashStore
|
|
107
|
+
onError: "fail-open" | "fail-closed", // required - no silent default
|
|
108
|
+
headerStyle?: "draft-6" | "draft-7", // defaults to "draft-6"
|
|
109
|
+
message?: (c: Context) => Response, // overrides the default 429 body
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Response headers
|
|
114
|
+
|
|
115
|
+
On every request:
|
|
116
|
+
|
|
117
|
+
- `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (draft-6, default), or
|
|
118
|
+
- `RateLimit: limit=..., remaining=..., reset=...` (draft-7, if configured)
|
|
119
|
+
|
|
120
|
+
On a rejected leaky-bucket request, `Retry-After` is also set, telling the client exactly how long to wait.
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
pnpm install
|
|
126
|
+
pnpm run build # tsup -> dist/
|
|
127
|
+
pnpm run test # vitest, spins up real Redis via Testcontainers
|
|
128
|
+
pnpm run lint # eslint (antfu config)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Tests use [Testcontainers](https://node.testcontainers.org/) to run against a real, disposable Redis instance - Docker must be running locally.
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
MIT © Kevin Rozario
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
RedisStore: () => RedisStore,
|
|
24
|
+
UpstashStore: () => UpstashStore,
|
|
25
|
+
rateLimiter: () => rateLimiter
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
|
|
29
|
+
// src/middleware.ts
|
|
30
|
+
var import_factory = require("hono/factory");
|
|
31
|
+
|
|
32
|
+
// src/key.ts
|
|
33
|
+
async function buildKey(ruleName, algorithm, identifier) {
|
|
34
|
+
const hashed = await hashIdentifier(identifier);
|
|
35
|
+
return `ratelimit:${ruleName}:${algorithm}:${hashed}`;
|
|
36
|
+
}
|
|
37
|
+
async function hashIdentifier(identifier) {
|
|
38
|
+
const encoded = new TextEncoder().encode(identifier);
|
|
39
|
+
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
|
40
|
+
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// src/algorithms/fixed-window.ts
|
|
44
|
+
var FIXED_WINDOW_SCRIPT = `
|
|
45
|
+
local limit = tonumber(ARGV[1])
|
|
46
|
+
local windowSeconds = tonumber(ARGV[2])
|
|
47
|
+
|
|
48
|
+
local current = redis.call('INCR', KEYS[1])
|
|
49
|
+
if current == 1 then
|
|
50
|
+
redis.call('EXPIRE', KEYS[1], windowSeconds)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
local ttl = redis.call('TTL', KEYS[1])
|
|
54
|
+
local allowed = current <= limit
|
|
55
|
+
local remaining = math.max(0, limit - current)
|
|
56
|
+
|
|
57
|
+
return { allowed and 1 or 0, remaining, ttl }
|
|
58
|
+
`;
|
|
59
|
+
async function checkFixedWindow(store, identifier, config) {
|
|
60
|
+
const key = await buildKey(config.ruleName, "fixedwindow", identifier);
|
|
61
|
+
const [allowed, remaining, ttl] = await store.eval(
|
|
62
|
+
FIXED_WINDOW_SCRIPT,
|
|
63
|
+
[key],
|
|
64
|
+
[config.limit, config.windowSeconds]
|
|
65
|
+
);
|
|
66
|
+
return {
|
|
67
|
+
allowed: allowed === 1,
|
|
68
|
+
remaining,
|
|
69
|
+
resetSeconds: ttl
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// src/algorithms/leaky-bucket.ts
|
|
74
|
+
var LEAKY_BUCKET_SCRIPT = `
|
|
75
|
+
local capacity = tonumber(ARGV[1])
|
|
76
|
+
local rate = tonumber(ARGV[2])
|
|
77
|
+
local cost = tonumber(ARGV[3])
|
|
78
|
+
|
|
79
|
+
local emissionInterval = 1 / rate
|
|
80
|
+
local burstOffset = emissionInterval * capacity
|
|
81
|
+
|
|
82
|
+
local time = redis.call('TIME')
|
|
83
|
+
local now = tonumber(time[1]) + (tonumber(time[2]) / 1000000)
|
|
84
|
+
|
|
85
|
+
local tat = tonumber(redis.call('GET', KEYS[1]))
|
|
86
|
+
if tat == nil or tat < now then
|
|
87
|
+
tat = now
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
local newTat = tat + (emissionInterval * cost)
|
|
91
|
+
local allowAt = newTat - burstOffset
|
|
92
|
+
local allowed = allowAt <= now
|
|
93
|
+
|
|
94
|
+
if allowed then
|
|
95
|
+
redis.call('SET', KEYS[1], newTat, 'EX', math.ceil(burstOffset + emissionInterval))
|
|
96
|
+
return { 1, "0" }
|
|
97
|
+
else
|
|
98
|
+
-- Redis truncates Lua numbers to integers on return, silently dropping
|
|
99
|
+
-- the fractional part \u2014 return as a string to preserve sub-second precision.
|
|
100
|
+
return { 0, tostring(allowAt - now) }
|
|
101
|
+
end
|
|
102
|
+
`;
|
|
103
|
+
async function checkLeakyBucket(store, identifier, config) {
|
|
104
|
+
const key = await buildKey(config.ruleName, "leakybucket", identifier);
|
|
105
|
+
const cost = config.cost ?? 1;
|
|
106
|
+
const [allowed, waitSecondsRaw] = await store.eval(
|
|
107
|
+
LEAKY_BUCKET_SCRIPT,
|
|
108
|
+
[key],
|
|
109
|
+
[config.capacity, config.rate, cost]
|
|
110
|
+
);
|
|
111
|
+
const waitSeconds = Number.parseFloat(waitSecondsRaw);
|
|
112
|
+
const isAllowed = allowed === 1;
|
|
113
|
+
return {
|
|
114
|
+
allowed: isAllowed,
|
|
115
|
+
resetSeconds: Math.ceil(waitSeconds),
|
|
116
|
+
retryAfterSeconds: isAllowed ? void 0 : Math.ceil(waitSeconds)
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// src/algorithms/sliding-window.ts
|
|
121
|
+
var SLIDING_WINDOW_SCRIPT = `
|
|
122
|
+
local limit = tonumber(ARGV[1])
|
|
123
|
+
local windowSeconds = tonumber(ARGV[2])
|
|
124
|
+
|
|
125
|
+
local time = redis.call('TIME')
|
|
126
|
+
local now = tonumber(time[1])
|
|
127
|
+
|
|
128
|
+
local currentWindow = math.floor(now / windowSeconds)
|
|
129
|
+
local previousWindow = currentWindow - 1
|
|
130
|
+
|
|
131
|
+
local currentKey = KEYS[1] .. ':' .. currentWindow
|
|
132
|
+
local previousKey = KEYS[1] .. ':' .. previousWindow
|
|
133
|
+
|
|
134
|
+
local previousCount = tonumber(redis.call('GET', previousKey)) or 0
|
|
135
|
+
local elapsedInWindow = now % windowSeconds
|
|
136
|
+
local weight = (windowSeconds - elapsedInWindow) / windowSeconds
|
|
137
|
+
|
|
138
|
+
local currentCount = redis.call('INCR', currentKey)
|
|
139
|
+
if currentCount == 1 then
|
|
140
|
+
redis.call('EXPIRE', currentKey, windowSeconds * 2)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
local estimatedTotal = (previousCount * weight) + currentCount
|
|
144
|
+
local allowed = estimatedTotal <= limit
|
|
145
|
+
|
|
146
|
+
if not allowed then
|
|
147
|
+
redis.call('DECR', currentKey)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
local remaining = math.max(0, math.floor(limit - estimatedTotal))
|
|
151
|
+
local resetSeconds = math.ceil(windowSeconds - elapsedInWindow)
|
|
152
|
+
|
|
153
|
+
return { allowed and 1 or 0, remaining, resetSeconds }
|
|
154
|
+
`;
|
|
155
|
+
async function checkSlidingWindow(store, identifier, config) {
|
|
156
|
+
const key = await buildKey(config.ruleName, "slidingwindow", identifier);
|
|
157
|
+
const [allowed, remaining, resetSeconds] = await store.eval(SLIDING_WINDOW_SCRIPT, [key], [config.limit, config.windowSeconds]);
|
|
158
|
+
return {
|
|
159
|
+
allowed: allowed === 1,
|
|
160
|
+
remaining,
|
|
161
|
+
resetSeconds
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// src/algorithms/token-bucket.ts
|
|
166
|
+
var TOKEN_BUCKET_SCRIPT = `
|
|
167
|
+
local capacity = tonumber(ARGV[1])
|
|
168
|
+
local refillRate = tonumber(ARGV[2])
|
|
169
|
+
local cost = tonumber(ARGV[3])
|
|
170
|
+
|
|
171
|
+
local time = redis.call('TIME')
|
|
172
|
+
local now = tonumber(time[1])
|
|
173
|
+
|
|
174
|
+
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
|
|
175
|
+
local tokens = tonumber(bucket[1])
|
|
176
|
+
local lastRefill = tonumber(bucket[2])
|
|
177
|
+
|
|
178
|
+
if tokens == nil then
|
|
179
|
+
tokens = capacity
|
|
180
|
+
lastRefill = now
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
local elapsed = now - lastRefill
|
|
184
|
+
tokens = math.min(capacity, tokens + (elapsed * refillRate))
|
|
185
|
+
|
|
186
|
+
local allowed = tokens >= cost
|
|
187
|
+
if allowed then
|
|
188
|
+
tokens = tokens - cost
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
|
|
192
|
+
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refillRate))
|
|
193
|
+
|
|
194
|
+
-- Redis truncates Lua numbers to integers on return, silently dropping
|
|
195
|
+
-- the fractional part \u2014 return as a string to preserve partial-refill precision.
|
|
196
|
+
return { allowed and 1 or 0, tostring(tokens) }
|
|
197
|
+
`;
|
|
198
|
+
async function checkTokenBucket(store, identifier, config) {
|
|
199
|
+
const key = await buildKey(config.ruleName, "tokenbucket", identifier);
|
|
200
|
+
const cost = config.cost ?? 1;
|
|
201
|
+
const [allowed, tokensRemainingRaw] = await store.eval(
|
|
202
|
+
TOKEN_BUCKET_SCRIPT,
|
|
203
|
+
[key],
|
|
204
|
+
[config.capacity, config.refillRate, cost]
|
|
205
|
+
);
|
|
206
|
+
const tokensRemaining = Number.parseFloat(tokensRemainingRaw);
|
|
207
|
+
const resetSeconds = Math.ceil(
|
|
208
|
+
(config.capacity - tokensRemaining) / config.refillRate
|
|
209
|
+
);
|
|
210
|
+
return {
|
|
211
|
+
allowed: allowed === 1,
|
|
212
|
+
remaining: tokensRemaining,
|
|
213
|
+
resetSeconds: Math.max(0, resetSeconds)
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// src/headers.ts
|
|
218
|
+
function buildRateLimitHeaders(limit, result, style = "draft-6") {
|
|
219
|
+
const headers = style === "draft-7" ? {
|
|
220
|
+
RateLimit: `limit=${limit}, remaining=${result.remaining ?? 0}, reset=${result.resetSeconds}`
|
|
221
|
+
} : {
|
|
222
|
+
"RateLimit-Limit": String(limit),
|
|
223
|
+
"RateLimit-Remaining": String(result.remaining ?? 0),
|
|
224
|
+
"RateLimit-Reset": String(result.resetSeconds)
|
|
225
|
+
};
|
|
226
|
+
if (result.retryAfterSeconds !== void 0) {
|
|
227
|
+
headers["Retry-After"] = String(result.retryAfterSeconds);
|
|
228
|
+
}
|
|
229
|
+
return headers;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// src/middleware.ts
|
|
233
|
+
function getLimitValue(config) {
|
|
234
|
+
switch (config.algorithm) {
|
|
235
|
+
case "tokenBucket":
|
|
236
|
+
case "leakyBucket":
|
|
237
|
+
return config.capacity;
|
|
238
|
+
case "fixedWindow":
|
|
239
|
+
case "slidingWindow":
|
|
240
|
+
return config.limit;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function runCheck(config, identifier) {
|
|
244
|
+
switch (config.algorithm) {
|
|
245
|
+
case "tokenBucket":
|
|
246
|
+
return checkTokenBucket(config.store, identifier, config);
|
|
247
|
+
case "fixedWindow":
|
|
248
|
+
return checkFixedWindow(config.store, identifier, config);
|
|
249
|
+
case "slidingWindow":
|
|
250
|
+
return checkSlidingWindow(config.store, identifier, config);
|
|
251
|
+
case "leakyBucket":
|
|
252
|
+
return checkLeakyBucket(config.store, identifier, config);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function rateLimiter(config) {
|
|
256
|
+
return (0, import_factory.createMiddleware)(async (c, next) => {
|
|
257
|
+
const identifier = config.identifier(c);
|
|
258
|
+
let result;
|
|
259
|
+
try {
|
|
260
|
+
result = await runCheck(config, identifier);
|
|
261
|
+
} catch {
|
|
262
|
+
if (config.onError === "fail-open") {
|
|
263
|
+
await next();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
c.status(503);
|
|
267
|
+
return c.json({ error: "Rate limiter unavailable" });
|
|
268
|
+
}
|
|
269
|
+
const headers = buildRateLimitHeaders(
|
|
270
|
+
getLimitValue(config),
|
|
271
|
+
result,
|
|
272
|
+
config.headerStyle
|
|
273
|
+
);
|
|
274
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
275
|
+
c.header(key, value);
|
|
276
|
+
}
|
|
277
|
+
if (!result.allowed) {
|
|
278
|
+
if (config.message)
|
|
279
|
+
return config.message(c);
|
|
280
|
+
c.status(429);
|
|
281
|
+
return c.json({ error: "Too many requests" });
|
|
282
|
+
}
|
|
283
|
+
await next();
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/store/redis-store.ts
|
|
288
|
+
var RedisStore = class {
|
|
289
|
+
client;
|
|
290
|
+
definedCommands = /* @__PURE__ */ new Set();
|
|
291
|
+
constructor(options) {
|
|
292
|
+
this.client = options.client;
|
|
293
|
+
}
|
|
294
|
+
async eval(script, keys, args) {
|
|
295
|
+
const commandName = `limitwall_${hashScript(script)}`;
|
|
296
|
+
if (!this.definedCommands.has(commandName)) {
|
|
297
|
+
this.client.defineCommand(commandName, {
|
|
298
|
+
numberOfKeys: keys.length,
|
|
299
|
+
lua: script
|
|
300
|
+
});
|
|
301
|
+
this.definedCommands.add(commandName);
|
|
302
|
+
}
|
|
303
|
+
const result = await this.client[commandName](...keys, ...args.map(String));
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
function hashScript(script) {
|
|
308
|
+
let hash = 0;
|
|
309
|
+
for (let i = 0; i < script.length; i++) {
|
|
310
|
+
hash = (hash << 5) - hash + script.charCodeAt(i);
|
|
311
|
+
hash |= 0;
|
|
312
|
+
}
|
|
313
|
+
return Math.abs(hash).toString(36);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/store/upstash-store.ts
|
|
317
|
+
var UpstashStore = class {
|
|
318
|
+
client;
|
|
319
|
+
constructor(options) {
|
|
320
|
+
this.client = options.client;
|
|
321
|
+
}
|
|
322
|
+
async eval(script, keys, args) {
|
|
323
|
+
const result = await this.client.eval(script, keys, args);
|
|
324
|
+
return result;
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
328
|
+
0 && (module.exports = {
|
|
329
|
+
RedisStore,
|
|
330
|
+
UpstashStore,
|
|
331
|
+
rateLimiter
|
|
332
|
+
});
|
|
333
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/key.ts","../src/algorithms/fixed-window.ts","../src/algorithms/leaky-bucket.ts","../src/algorithms/sliding-window.ts","../src/algorithms/token-bucket.ts","../src/headers.ts","../src/store/redis-store.ts","../src/store/upstash-store.ts"],"sourcesContent":["/**\n * limitwall - rate limiting middleware for Hono, backed by Redis.\n * Supports fixed window, sliding window, token bucket, and leaky\n * bucket (GCRA) algorithms behind one config-driven middleware.\n */\n\nexport type { HeaderStyle } from \"./headers\";\nexport { rateLimiter } from \"./middleware\";\n\nexport type { RateLimiterConfig } from \"./middleware\";\nexport { RedisStore } from \"./store/redis-store\";\nexport type { RateLimitStore } from \"./store/types\";\n\nexport { UpstashStore } from \"./store/upstash-store\";\n\nexport type {\n FixedWindowConfig,\n LeakyBucketConfig,\n RateLimitAlgorithmConfig,\n RateLimitResult,\n SlidingWindowConfig,\n TokenBucketConfig,\n} from \"./types\";\n","import type { Context } from \"hono\";\n\nimport { createMiddleware } from \"hono/factory\";\n\nimport type { RateLimitStore } from \"./store/types\";\nimport type { RateLimitAlgorithmConfig, RateLimitResult } from \"./types\";\n\nimport { checkFixedWindow } from \"./algorithms/fixed-window\";\nimport { checkLeakyBucket } from \"./algorithms/leaky-bucket\";\nimport { checkSlidingWindow } from \"./algorithms/sliding-window\";\nimport { checkTokenBucket } from \"./algorithms/token-bucket\";\nimport { buildRateLimitHeaders, type HeaderStyle } from \"./headers\";\n\n/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */\nexport type RateLimiterConfig = RateLimitAlgorithmConfig & {\n /** Extracts the identifier to rate limit by (IP, user ID, API key, etc.). */\n identifier: (c: Context) => string;\n store: RateLimitStore;\n /** Required, explicit behavior when the store is unreachable — no silent default. */\n onError: \"fail-open\" | \"fail-closed\";\n headerStyle?: HeaderStyle;\n /** Overrides the default 429 JSON body. */\n message?: (c: Context) => Response;\n};\n\n/** Resolves the configured limit/capacity, regardless of which algorithm is active. */\nfunction getLimitValue(config: RateLimiterConfig): number {\n switch (config.algorithm) {\n case \"tokenBucket\":\n case \"leakyBucket\":\n return config.capacity;\n case \"fixedWindow\":\n case \"slidingWindow\":\n return config.limit;\n }\n}\n\n/** Runs the configured algorithm's check function. */\nasync function runCheck(\n config: RateLimiterConfig,\n identifier: string,\n): Promise<RateLimitResult> {\n switch (config.algorithm) {\n case \"tokenBucket\":\n return checkTokenBucket(config.store, identifier, config);\n case \"fixedWindow\":\n return checkFixedWindow(config.store, identifier, config);\n case \"slidingWindow\":\n return checkSlidingWindow(config.store, identifier, config);\n case \"leakyBucket\":\n return checkLeakyBucket(config.store, identifier, config);\n }\n}\n\n/** Creates a Hono rate-limiting middleware for the given config. */\nexport function rateLimiter(config: RateLimiterConfig) {\n return createMiddleware(async (c, next) => {\n const identifier = config.identifier(c);\n\n let result: RateLimitResult;\n try {\n result = await runCheck(config, identifier);\n }\n catch {\n // Store unreachable - do exactly what was explicitly configured.\n if (config.onError === \"fail-open\") {\n await next();\n return;\n }\n c.status(503);\n return c.json({ error: \"Rate limiter unavailable\" });\n }\n\n const headers = buildRateLimitHeaders(\n getLimitValue(config),\n result,\n config.headerStyle,\n );\n for (const [key, value] of Object.entries(headers)) {\n c.header(key, value);\n }\n\n if (!result.allowed) {\n if (config.message)\n return config.message(c);\n c.status(429);\n return c.json({ error: \"Too many requests\" });\n }\n\n await next();\n });\n}\n","/**\n * Builds the Redis key for a given rule + algorithm + identifier.\n * The identifier is always hashed before being stored, so raw IPs,\n * emails, or user IDs never sit in Redis in plain text.\n *\n * Uses the Web Crypto API (`crypto.subtle`) instead of Node's\n * `node:crypto`, since Node's module doesn't exist on edge runtimes\n * like Cloudflare Workers - Web Crypto works identically everywhere.\n */\nexport async function buildKey(\n ruleName: string,\n algorithm: string,\n identifier: string,\n): Promise<string> {\n const hashed = await hashIdentifier(identifier);\n return `ratelimit:${ruleName}:${algorithm}:${hashed}`;\n}\n\n/** SHA-256 hashes a string and returns it as lowercase hex. */\nasync function hashIdentifier(identifier: string): Promise<string> {\n const encoded = new TextEncoder().encode(identifier);\n const digest = await crypto.subtle.digest(\"SHA-256\", encoded);\n return Array.from(new Uint8Array(digest))\n .map(byte => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { FixedWindowConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Counts requests within a fixed time slot and resets when the slot's\n * TTL expires. Simplest algorithm; allows a burst right at the window\n * boundary - use sliding window if that's a problem for your use case.\n */\nconst FIXED_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal current = redis.call('INCR', KEYS[1])\nif current == 1 then\n redis.call('EXPIRE', KEYS[1], windowSeconds)\nend\n\nlocal ttl = redis.call('TTL', KEYS[1])\nlocal allowed = current <= limit\nlocal remaining = math.max(0, limit - current)\n\nreturn { allowed and 1 or 0, remaining, ttl }\n`;\n\n/** Runs a fixed window check for one identifier under one rule. */\nexport async function checkFixedWindow(\n store: RateLimitStore,\n identifier: string,\n config: FixedWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"fixedwindow\", identifier);\n\n const [allowed, remaining, ttl] = await store.eval<[number, number, number]>(\n FIXED_WINDOW_SCRIPT,\n [key],\n [config.limit, config.windowSeconds],\n );\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds: ttl,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { LeakyBucketConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Implements leaky bucket as GCRA (Generic Cell Rate Algorithm): tracks\n * one \"theoretical arrival time\" per identifier instead of a token\n * count, producing smooth, evenly-paced traffic rather than the bursty\n * traffic token bucket allows.\n */\nconst LEAKY_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal rate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal emissionInterval = 1 / rate\nlocal burstOffset = emissionInterval * capacity\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1]) + (tonumber(time[2]) / 1000000)\n\nlocal tat = tonumber(redis.call('GET', KEYS[1]))\nif tat == nil or tat < now then\n tat = now\nend\n\nlocal newTat = tat + (emissionInterval * cost)\nlocal allowAt = newTat - burstOffset\nlocal allowed = allowAt <= now\n\nif allowed then\n redis.call('SET', KEYS[1], newTat, 'EX', math.ceil(burstOffset + emissionInterval))\n return { 1, \"0\" }\nelse\n -- Redis truncates Lua numbers to integers on return, silently dropping\n -- the fractional part — return as a string to preserve sub-second precision.\n return { 0, tostring(allowAt - now) }\nend\n`;\n\n/** Runs a leaky bucket (GCRA) check for one identifier under one rule. */\nexport async function checkLeakyBucket(\n store: RateLimitStore,\n identifier: string,\n config: LeakyBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"leakybucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, waitSecondsRaw] = await store.eval<[number, string]>(\n LEAKY_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.rate, cost],\n );\n\n const waitSeconds = Number.parseFloat(waitSecondsRaw);\n const isAllowed = allowed === 1;\n\n return {\n allowed: isAllowed,\n resetSeconds: Math.ceil(waitSeconds),\n retryAfterSeconds: isAllowed ? undefined : Math.ceil(waitSeconds),\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, SlidingWindowConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Blends the current window's count with a weighted portion of the\n * previous window's count, so traffic can't double up right at a\n * fixed-window boundary.\n */\nconst SLIDING_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal currentWindow = math.floor(now / windowSeconds)\nlocal previousWindow = currentWindow - 1\n\nlocal currentKey = KEYS[1] .. ':' .. currentWindow\nlocal previousKey = KEYS[1] .. ':' .. previousWindow\n\nlocal previousCount = tonumber(redis.call('GET', previousKey)) or 0\nlocal elapsedInWindow = now % windowSeconds\nlocal weight = (windowSeconds - elapsedInWindow) / windowSeconds\n\nlocal currentCount = redis.call('INCR', currentKey)\nif currentCount == 1 then\n redis.call('EXPIRE', currentKey, windowSeconds * 2)\nend\n\nlocal estimatedTotal = (previousCount * weight) + currentCount\nlocal allowed = estimatedTotal <= limit\n\nif not allowed then\n redis.call('DECR', currentKey)\nend\n\nlocal remaining = math.max(0, math.floor(limit - estimatedTotal))\nlocal resetSeconds = math.ceil(windowSeconds - elapsedInWindow)\n\nreturn { allowed and 1 or 0, remaining, resetSeconds }\n`;\n\n/** Runs a sliding window check for one identifier under one rule. */\nexport async function checkSlidingWindow(\n store: RateLimitStore,\n identifier: string,\n config: SlidingWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"slidingwindow\", identifier);\n\n const [allowed, remaining, resetSeconds] = await store.eval<\n [number, number, number]\n >(SLIDING_WINDOW_SCRIPT, [key], [config.limit, config.windowSeconds]);\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, TokenBucketConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Lazily refills a bucket of tokens based on elapsed time, entirely\n * inside one atomic Redis operation. See the module README for the\n * plain-language walkthrough of the algorithm.\n */\nconst TOKEN_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal refillRate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')\nlocal tokens = tonumber(bucket[1])\nlocal lastRefill = tonumber(bucket[2])\n\nif tokens == nil then\n tokens = capacity\n lastRefill = now\nend\n\nlocal elapsed = now - lastRefill\ntokens = math.min(capacity, tokens + (elapsed * refillRate))\n\nlocal allowed = tokens >= cost\nif allowed then\n tokens = tokens - cost\nend\n\nredis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)\nredis.call('EXPIRE', KEYS[1], math.ceil(capacity / refillRate))\n\n-- Redis truncates Lua numbers to integers on return, silently dropping\n-- the fractional part — return as a string to preserve partial-refill precision.\nreturn { allowed and 1 or 0, tostring(tokens) }\n`;\n\n/** Runs a token bucket check for one identifier under one rule. */\nexport async function checkTokenBucket(\n store: RateLimitStore,\n identifier: string,\n config: TokenBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"tokenbucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, tokensRemainingRaw] = await store.eval<[number, string]>(\n TOKEN_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.refillRate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.refillRate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\n","import type { RateLimitResult } from \"./types\";\n\nexport type HeaderStyle = \"draft-6\" | \"draft-7\";\n\n/**\n * Builds response headers for a rate limit result, following one of the\n * two IETF RateLimit header drafts, plus the standard HTTP `Retry-After`\n * header when the result carries a `retryAfterSeconds` (leaky bucket).\n *\n * draft-6 uses three separate `RateLimit-*` headers.\n * draft-7 combines them into a single `RateLimit` header.\n */\nexport function buildRateLimitHeaders(\n limit: number,\n result: RateLimitResult,\n style: HeaderStyle = \"draft-6\",\n): Record<string, string> {\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n result.remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(result.remaining ?? 0),\n \"RateLimit-Reset\": String(result.resetSeconds),\n };\n\n if (result.retryAfterSeconds !== undefined) {\n headers[\"Retry-After\"] = String(result.retryAfterSeconds);\n }\n\n return headers;\n}\n","import type { Redis } from \"ioredis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for regular Node.js servers, backed by a persistent\n * connection via `ioredis`. Not for edge runtimes — use UpstashStore\n * there instead, since ioredis depends on Node's networking APIs.\n *\n * Registers each unique script once via `defineCommand`, so ioredis\n * automatically uses the cached `EVALSHA` form after the first call\n * instead of resending the full script text every request.\n */\nexport class RedisStore implements RateLimitStore {\n private readonly client: Redis;\n private readonly definedCommands = new Set<string>();\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const commandName = `limitwall_${hashScript(script)}`;\n\n if (!this.definedCommands.has(commandName)) {\n this.client.defineCommand(commandName, {\n numberOfKeys: keys.length,\n lua: script,\n });\n this.definedCommands.add(commandName);\n }\n\n // @ts-expect-error - commandName is registered dynamically above\n const result = await this.client[commandName](...keys, ...args.map(String));\n return result as T;\n }\n}\n\n/** Cheap, stable identifier for a script's text - not cryptographic, just needs to be unique per script. */\nfunction hashScript(script: string): string {\n let hash = 0;\n for (let i = 0; i < script.length; i++) {\n hash = (hash << 5) - hash + script.charCodeAt(i);\n hash |= 0;\n }\n return Math.abs(hash).toString(36);\n}\n","import type { Redis } from \"@upstash/redis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for edge/serverless runtimes (Cloudflare Workers, Deno,\n * Vercel Edge) via Upstash's REST API, which needs no persistent\n * connection. Use RedisStore instead on regular Node servers.\n *\n * Note: unlike ioredis, the Upstash SDK has no built-in script-caching\n * mechanism equivalent to `defineCommand` — every call sends the full\n * script text over HTTP. This is an accepted tradeoff for edge runtimes,\n * not a bug: REST-over-HTTP has per-call overhead regardless.\n */\nexport class UpstashStore implements RateLimitStore {\n private readonly client: Redis;\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const result = await this.client.eval(script, keys, args);\n return result as T;\n }\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as hono from 'hono';
|
|
2
|
+
import { Context } from 'hono';
|
|
3
|
+
import { Redis } from 'ioredis';
|
|
4
|
+
import { Redis as Redis$1 } from '@upstash/redis';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Result of a single rate limit check, normalized across all algorithms.
|
|
8
|
+
*
|
|
9
|
+
* `remaining` and `resetSeconds` are the standard pair used for the
|
|
10
|
+
* RateLimit-* headers. `retryAfterSeconds` is only set by pacing-based
|
|
11
|
+
* algorithms (leaky bucket) on rejection, since GCRA answers "how long
|
|
12
|
+
* until you can try again" rather than "how many are left" - and maps
|
|
13
|
+
* directly onto the standard HTTP `Retry-After` header.
|
|
14
|
+
*/
|
|
15
|
+
interface RateLimitResult {
|
|
16
|
+
/** Whether this request is allowed to proceed. */
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
/** Count remaining. Set by fixed window, sliding window, token bucket. */
|
|
19
|
+
remaining?: number;
|
|
20
|
+
/** Seconds until this key's limit meaningfully resets or refills. */
|
|
21
|
+
resetSeconds: number;
|
|
22
|
+
/** Seconds to wait before retrying. Only set by leaky bucket (GCRA) on rejection. */
|
|
23
|
+
retryAfterSeconds?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Fields every algorithm's config shares. */
|
|
26
|
+
interface BaseAlgorithmConfig {
|
|
27
|
+
/** Unique name for this rule; namespaces its Redis keys from other rules. */
|
|
28
|
+
ruleName: string;
|
|
29
|
+
/** Cost of a single request, in whatever unit the algorithm counts. Defaults to 1. */
|
|
30
|
+
cost?: number;
|
|
31
|
+
}
|
|
32
|
+
interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
33
|
+
algorithm: "tokenBucket";
|
|
34
|
+
/** Maximum tokens the bucket can hold (max burst size). */
|
|
35
|
+
capacity: number;
|
|
36
|
+
/** Tokens added back to the bucket per second. */
|
|
37
|
+
refillRate: number;
|
|
38
|
+
}
|
|
39
|
+
interface FixedWindowConfig extends BaseAlgorithmConfig {
|
|
40
|
+
algorithm: "fixedWindow";
|
|
41
|
+
/** Max requests allowed per window. */
|
|
42
|
+
limit: number;
|
|
43
|
+
/** Length of the window, in seconds. */
|
|
44
|
+
windowSeconds: number;
|
|
45
|
+
}
|
|
46
|
+
interface SlidingWindowConfig extends BaseAlgorithmConfig {
|
|
47
|
+
algorithm: "slidingWindow";
|
|
48
|
+
/** Max requests allowed per window. */
|
|
49
|
+
limit: number;
|
|
50
|
+
/** Length of the window, in seconds. */
|
|
51
|
+
windowSeconds: number;
|
|
52
|
+
}
|
|
53
|
+
interface LeakyBucketConfig extends BaseAlgorithmConfig {
|
|
54
|
+
algorithm: "leakyBucket";
|
|
55
|
+
/** Max burst size allowed above the steady rate. */
|
|
56
|
+
capacity: number;
|
|
57
|
+
/** Sustained requests allowed per second. */
|
|
58
|
+
rate: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Discriminated union of every algorithm's config, tagged by `algorithm`.
|
|
62
|
+
* Picking `algorithm: "tokenBucket"` requires `capacity` + `refillRate`;
|
|
63
|
+
* TypeScript rejects the wrong field set for whichever algorithm is chosen.
|
|
64
|
+
*/
|
|
65
|
+
type RateLimitAlgorithmConfig = TokenBucketConfig | FixedWindowConfig | SlidingWindowConfig | LeakyBucketConfig;
|
|
66
|
+
|
|
67
|
+
type HeaderStyle = "draft-6" | "draft-7";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The minimal contract every storage backend must satisfy.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately thin: each algorithm owns its own atomic Lua script, so
|
|
73
|
+
* a store's only job is "run this script for me, atomically." This is
|
|
74
|
+
* what lets RedisStore (persistent connection) and UpstashStore (REST
|
|
75
|
+
* over HTTP) work behind the exact same interface.
|
|
76
|
+
*/
|
|
77
|
+
interface RateLimitStore {
|
|
78
|
+
/**
|
|
79
|
+
* Runs a Lua script atomically and returns its result.
|
|
80
|
+
* @param script The raw Lua script text.
|
|
81
|
+
* @param keys Redis KEYS[] — the keys the script will touch.
|
|
82
|
+
* @param args Redis ARGV[] — plain values the script needs.
|
|
83
|
+
*/
|
|
84
|
+
eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */
|
|
88
|
+
type RateLimiterConfig = RateLimitAlgorithmConfig & {
|
|
89
|
+
/** Extracts the identifier to rate limit by (IP, user ID, API key, etc.). */
|
|
90
|
+
identifier: (c: Context) => string;
|
|
91
|
+
store: RateLimitStore;
|
|
92
|
+
/** Required, explicit behavior when the store is unreachable — no silent default. */
|
|
93
|
+
onError: "fail-open" | "fail-closed";
|
|
94
|
+
headerStyle?: HeaderStyle;
|
|
95
|
+
/** Overrides the default 429 JSON body. */
|
|
96
|
+
message?: (c: Context) => Response;
|
|
97
|
+
};
|
|
98
|
+
/** Creates a Hono rate-limiting middleware for the given config. */
|
|
99
|
+
declare function rateLimiter(config: RateLimiterConfig): hono.MiddlewareHandler<any, string, {}, Response>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Store backend for regular Node.js servers, backed by a persistent
|
|
103
|
+
* connection via `ioredis`. Not for edge runtimes — use UpstashStore
|
|
104
|
+
* there instead, since ioredis depends on Node's networking APIs.
|
|
105
|
+
*
|
|
106
|
+
* Registers each unique script once via `defineCommand`, so ioredis
|
|
107
|
+
* automatically uses the cached `EVALSHA` form after the first call
|
|
108
|
+
* instead of resending the full script text every request.
|
|
109
|
+
*/
|
|
110
|
+
declare class RedisStore implements RateLimitStore {
|
|
111
|
+
private readonly client;
|
|
112
|
+
private readonly definedCommands;
|
|
113
|
+
constructor(options: {
|
|
114
|
+
client: Redis;
|
|
115
|
+
});
|
|
116
|
+
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Store backend for edge/serverless runtimes (Cloudflare Workers, Deno,
|
|
121
|
+
* Vercel Edge) via Upstash's REST API, which needs no persistent
|
|
122
|
+
* connection. Use RedisStore instead on regular Node servers.
|
|
123
|
+
*
|
|
124
|
+
* Note: unlike ioredis, the Upstash SDK has no built-in script-caching
|
|
125
|
+
* mechanism equivalent to `defineCommand` — every call sends the full
|
|
126
|
+
* script text over HTTP. This is an accepted tradeoff for edge runtimes,
|
|
127
|
+
* not a bug: REST-over-HTTP has per-call overhead regardless.
|
|
128
|
+
*/
|
|
129
|
+
declare class UpstashStore implements RateLimitStore {
|
|
130
|
+
private readonly client;
|
|
131
|
+
constructor(options: {
|
|
132
|
+
client: Redis$1;
|
|
133
|
+
});
|
|
134
|
+
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig, RedisStore, type SlidingWindowConfig, type TokenBucketConfig, UpstashStore, rateLimiter };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import * as hono from 'hono';
|
|
2
|
+
import { Context } from 'hono';
|
|
3
|
+
import { Redis } from 'ioredis';
|
|
4
|
+
import { Redis as Redis$1 } from '@upstash/redis';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Result of a single rate limit check, normalized across all algorithms.
|
|
8
|
+
*
|
|
9
|
+
* `remaining` and `resetSeconds` are the standard pair used for the
|
|
10
|
+
* RateLimit-* headers. `retryAfterSeconds` is only set by pacing-based
|
|
11
|
+
* algorithms (leaky bucket) on rejection, since GCRA answers "how long
|
|
12
|
+
* until you can try again" rather than "how many are left" - and maps
|
|
13
|
+
* directly onto the standard HTTP `Retry-After` header.
|
|
14
|
+
*/
|
|
15
|
+
interface RateLimitResult {
|
|
16
|
+
/** Whether this request is allowed to proceed. */
|
|
17
|
+
allowed: boolean;
|
|
18
|
+
/** Count remaining. Set by fixed window, sliding window, token bucket. */
|
|
19
|
+
remaining?: number;
|
|
20
|
+
/** Seconds until this key's limit meaningfully resets or refills. */
|
|
21
|
+
resetSeconds: number;
|
|
22
|
+
/** Seconds to wait before retrying. Only set by leaky bucket (GCRA) on rejection. */
|
|
23
|
+
retryAfterSeconds?: number;
|
|
24
|
+
}
|
|
25
|
+
/** Fields every algorithm's config shares. */
|
|
26
|
+
interface BaseAlgorithmConfig {
|
|
27
|
+
/** Unique name for this rule; namespaces its Redis keys from other rules. */
|
|
28
|
+
ruleName: string;
|
|
29
|
+
/** Cost of a single request, in whatever unit the algorithm counts. Defaults to 1. */
|
|
30
|
+
cost?: number;
|
|
31
|
+
}
|
|
32
|
+
interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
33
|
+
algorithm: "tokenBucket";
|
|
34
|
+
/** Maximum tokens the bucket can hold (max burst size). */
|
|
35
|
+
capacity: number;
|
|
36
|
+
/** Tokens added back to the bucket per second. */
|
|
37
|
+
refillRate: number;
|
|
38
|
+
}
|
|
39
|
+
interface FixedWindowConfig extends BaseAlgorithmConfig {
|
|
40
|
+
algorithm: "fixedWindow";
|
|
41
|
+
/** Max requests allowed per window. */
|
|
42
|
+
limit: number;
|
|
43
|
+
/** Length of the window, in seconds. */
|
|
44
|
+
windowSeconds: number;
|
|
45
|
+
}
|
|
46
|
+
interface SlidingWindowConfig extends BaseAlgorithmConfig {
|
|
47
|
+
algorithm: "slidingWindow";
|
|
48
|
+
/** Max requests allowed per window. */
|
|
49
|
+
limit: number;
|
|
50
|
+
/** Length of the window, in seconds. */
|
|
51
|
+
windowSeconds: number;
|
|
52
|
+
}
|
|
53
|
+
interface LeakyBucketConfig extends BaseAlgorithmConfig {
|
|
54
|
+
algorithm: "leakyBucket";
|
|
55
|
+
/** Max burst size allowed above the steady rate. */
|
|
56
|
+
capacity: number;
|
|
57
|
+
/** Sustained requests allowed per second. */
|
|
58
|
+
rate: number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Discriminated union of every algorithm's config, tagged by `algorithm`.
|
|
62
|
+
* Picking `algorithm: "tokenBucket"` requires `capacity` + `refillRate`;
|
|
63
|
+
* TypeScript rejects the wrong field set for whichever algorithm is chosen.
|
|
64
|
+
*/
|
|
65
|
+
type RateLimitAlgorithmConfig = TokenBucketConfig | FixedWindowConfig | SlidingWindowConfig | LeakyBucketConfig;
|
|
66
|
+
|
|
67
|
+
type HeaderStyle = "draft-6" | "draft-7";
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The minimal contract every storage backend must satisfy.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately thin: each algorithm owns its own atomic Lua script, so
|
|
73
|
+
* a store's only job is "run this script for me, atomically." This is
|
|
74
|
+
* what lets RedisStore (persistent connection) and UpstashStore (REST
|
|
75
|
+
* over HTTP) work behind the exact same interface.
|
|
76
|
+
*/
|
|
77
|
+
interface RateLimitStore {
|
|
78
|
+
/**
|
|
79
|
+
* Runs a Lua script atomically and returns its result.
|
|
80
|
+
* @param script The raw Lua script text.
|
|
81
|
+
* @param keys Redis KEYS[] — the keys the script will touch.
|
|
82
|
+
* @param args Redis ARGV[] — plain values the script needs.
|
|
83
|
+
*/
|
|
84
|
+
eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */
|
|
88
|
+
type RateLimiterConfig = RateLimitAlgorithmConfig & {
|
|
89
|
+
/** Extracts the identifier to rate limit by (IP, user ID, API key, etc.). */
|
|
90
|
+
identifier: (c: Context) => string;
|
|
91
|
+
store: RateLimitStore;
|
|
92
|
+
/** Required, explicit behavior when the store is unreachable — no silent default. */
|
|
93
|
+
onError: "fail-open" | "fail-closed";
|
|
94
|
+
headerStyle?: HeaderStyle;
|
|
95
|
+
/** Overrides the default 429 JSON body. */
|
|
96
|
+
message?: (c: Context) => Response;
|
|
97
|
+
};
|
|
98
|
+
/** Creates a Hono rate-limiting middleware for the given config. */
|
|
99
|
+
declare function rateLimiter(config: RateLimiterConfig): hono.MiddlewareHandler<any, string, {}, Response>;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Store backend for regular Node.js servers, backed by a persistent
|
|
103
|
+
* connection via `ioredis`. Not for edge runtimes — use UpstashStore
|
|
104
|
+
* there instead, since ioredis depends on Node's networking APIs.
|
|
105
|
+
*
|
|
106
|
+
* Registers each unique script once via `defineCommand`, so ioredis
|
|
107
|
+
* automatically uses the cached `EVALSHA` form after the first call
|
|
108
|
+
* instead of resending the full script text every request.
|
|
109
|
+
*/
|
|
110
|
+
declare class RedisStore implements RateLimitStore {
|
|
111
|
+
private readonly client;
|
|
112
|
+
private readonly definedCommands;
|
|
113
|
+
constructor(options: {
|
|
114
|
+
client: Redis;
|
|
115
|
+
});
|
|
116
|
+
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Store backend for edge/serverless runtimes (Cloudflare Workers, Deno,
|
|
121
|
+
* Vercel Edge) via Upstash's REST API, which needs no persistent
|
|
122
|
+
* connection. Use RedisStore instead on regular Node servers.
|
|
123
|
+
*
|
|
124
|
+
* Note: unlike ioredis, the Upstash SDK has no built-in script-caching
|
|
125
|
+
* mechanism equivalent to `defineCommand` — every call sends the full
|
|
126
|
+
* script text over HTTP. This is an accepted tradeoff for edge runtimes,
|
|
127
|
+
* not a bug: REST-over-HTTP has per-call overhead regardless.
|
|
128
|
+
*/
|
|
129
|
+
declare class UpstashStore implements RateLimitStore {
|
|
130
|
+
private readonly client;
|
|
131
|
+
constructor(options: {
|
|
132
|
+
client: Redis$1;
|
|
133
|
+
});
|
|
134
|
+
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig, RedisStore, type SlidingWindowConfig, type TokenBucketConfig, UpstashStore, rateLimiter };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// src/middleware.ts
|
|
2
|
+
import { createMiddleware } from "hono/factory";
|
|
3
|
+
|
|
4
|
+
// src/key.ts
|
|
5
|
+
async function buildKey(ruleName, algorithm, identifier) {
|
|
6
|
+
const hashed = await hashIdentifier(identifier);
|
|
7
|
+
return `ratelimit:${ruleName}:${algorithm}:${hashed}`;
|
|
8
|
+
}
|
|
9
|
+
async function hashIdentifier(identifier) {
|
|
10
|
+
const encoded = new TextEncoder().encode(identifier);
|
|
11
|
+
const digest = await crypto.subtle.digest("SHA-256", encoded);
|
|
12
|
+
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// src/algorithms/fixed-window.ts
|
|
16
|
+
var FIXED_WINDOW_SCRIPT = `
|
|
17
|
+
local limit = tonumber(ARGV[1])
|
|
18
|
+
local windowSeconds = tonumber(ARGV[2])
|
|
19
|
+
|
|
20
|
+
local current = redis.call('INCR', KEYS[1])
|
|
21
|
+
if current == 1 then
|
|
22
|
+
redis.call('EXPIRE', KEYS[1], windowSeconds)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
local ttl = redis.call('TTL', KEYS[1])
|
|
26
|
+
local allowed = current <= limit
|
|
27
|
+
local remaining = math.max(0, limit - current)
|
|
28
|
+
|
|
29
|
+
return { allowed and 1 or 0, remaining, ttl }
|
|
30
|
+
`;
|
|
31
|
+
async function checkFixedWindow(store, identifier, config) {
|
|
32
|
+
const key = await buildKey(config.ruleName, "fixedwindow", identifier);
|
|
33
|
+
const [allowed, remaining, ttl] = await store.eval(
|
|
34
|
+
FIXED_WINDOW_SCRIPT,
|
|
35
|
+
[key],
|
|
36
|
+
[config.limit, config.windowSeconds]
|
|
37
|
+
);
|
|
38
|
+
return {
|
|
39
|
+
allowed: allowed === 1,
|
|
40
|
+
remaining,
|
|
41
|
+
resetSeconds: ttl
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/algorithms/leaky-bucket.ts
|
|
46
|
+
var LEAKY_BUCKET_SCRIPT = `
|
|
47
|
+
local capacity = tonumber(ARGV[1])
|
|
48
|
+
local rate = tonumber(ARGV[2])
|
|
49
|
+
local cost = tonumber(ARGV[3])
|
|
50
|
+
|
|
51
|
+
local emissionInterval = 1 / rate
|
|
52
|
+
local burstOffset = emissionInterval * capacity
|
|
53
|
+
|
|
54
|
+
local time = redis.call('TIME')
|
|
55
|
+
local now = tonumber(time[1]) + (tonumber(time[2]) / 1000000)
|
|
56
|
+
|
|
57
|
+
local tat = tonumber(redis.call('GET', KEYS[1]))
|
|
58
|
+
if tat == nil or tat < now then
|
|
59
|
+
tat = now
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
local newTat = tat + (emissionInterval * cost)
|
|
63
|
+
local allowAt = newTat - burstOffset
|
|
64
|
+
local allowed = allowAt <= now
|
|
65
|
+
|
|
66
|
+
if allowed then
|
|
67
|
+
redis.call('SET', KEYS[1], newTat, 'EX', math.ceil(burstOffset + emissionInterval))
|
|
68
|
+
return { 1, "0" }
|
|
69
|
+
else
|
|
70
|
+
-- Redis truncates Lua numbers to integers on return, silently dropping
|
|
71
|
+
-- the fractional part \u2014 return as a string to preserve sub-second precision.
|
|
72
|
+
return { 0, tostring(allowAt - now) }
|
|
73
|
+
end
|
|
74
|
+
`;
|
|
75
|
+
async function checkLeakyBucket(store, identifier, config) {
|
|
76
|
+
const key = await buildKey(config.ruleName, "leakybucket", identifier);
|
|
77
|
+
const cost = config.cost ?? 1;
|
|
78
|
+
const [allowed, waitSecondsRaw] = await store.eval(
|
|
79
|
+
LEAKY_BUCKET_SCRIPT,
|
|
80
|
+
[key],
|
|
81
|
+
[config.capacity, config.rate, cost]
|
|
82
|
+
);
|
|
83
|
+
const waitSeconds = Number.parseFloat(waitSecondsRaw);
|
|
84
|
+
const isAllowed = allowed === 1;
|
|
85
|
+
return {
|
|
86
|
+
allowed: isAllowed,
|
|
87
|
+
resetSeconds: Math.ceil(waitSeconds),
|
|
88
|
+
retryAfterSeconds: isAllowed ? void 0 : Math.ceil(waitSeconds)
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/algorithms/sliding-window.ts
|
|
93
|
+
var SLIDING_WINDOW_SCRIPT = `
|
|
94
|
+
local limit = tonumber(ARGV[1])
|
|
95
|
+
local windowSeconds = tonumber(ARGV[2])
|
|
96
|
+
|
|
97
|
+
local time = redis.call('TIME')
|
|
98
|
+
local now = tonumber(time[1])
|
|
99
|
+
|
|
100
|
+
local currentWindow = math.floor(now / windowSeconds)
|
|
101
|
+
local previousWindow = currentWindow - 1
|
|
102
|
+
|
|
103
|
+
local currentKey = KEYS[1] .. ':' .. currentWindow
|
|
104
|
+
local previousKey = KEYS[1] .. ':' .. previousWindow
|
|
105
|
+
|
|
106
|
+
local previousCount = tonumber(redis.call('GET', previousKey)) or 0
|
|
107
|
+
local elapsedInWindow = now % windowSeconds
|
|
108
|
+
local weight = (windowSeconds - elapsedInWindow) / windowSeconds
|
|
109
|
+
|
|
110
|
+
local currentCount = redis.call('INCR', currentKey)
|
|
111
|
+
if currentCount == 1 then
|
|
112
|
+
redis.call('EXPIRE', currentKey, windowSeconds * 2)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
local estimatedTotal = (previousCount * weight) + currentCount
|
|
116
|
+
local allowed = estimatedTotal <= limit
|
|
117
|
+
|
|
118
|
+
if not allowed then
|
|
119
|
+
redis.call('DECR', currentKey)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
local remaining = math.max(0, math.floor(limit - estimatedTotal))
|
|
123
|
+
local resetSeconds = math.ceil(windowSeconds - elapsedInWindow)
|
|
124
|
+
|
|
125
|
+
return { allowed and 1 or 0, remaining, resetSeconds }
|
|
126
|
+
`;
|
|
127
|
+
async function checkSlidingWindow(store, identifier, config) {
|
|
128
|
+
const key = await buildKey(config.ruleName, "slidingwindow", identifier);
|
|
129
|
+
const [allowed, remaining, resetSeconds] = await store.eval(SLIDING_WINDOW_SCRIPT, [key], [config.limit, config.windowSeconds]);
|
|
130
|
+
return {
|
|
131
|
+
allowed: allowed === 1,
|
|
132
|
+
remaining,
|
|
133
|
+
resetSeconds
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// src/algorithms/token-bucket.ts
|
|
138
|
+
var TOKEN_BUCKET_SCRIPT = `
|
|
139
|
+
local capacity = tonumber(ARGV[1])
|
|
140
|
+
local refillRate = tonumber(ARGV[2])
|
|
141
|
+
local cost = tonumber(ARGV[3])
|
|
142
|
+
|
|
143
|
+
local time = redis.call('TIME')
|
|
144
|
+
local now = tonumber(time[1])
|
|
145
|
+
|
|
146
|
+
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
|
|
147
|
+
local tokens = tonumber(bucket[1])
|
|
148
|
+
local lastRefill = tonumber(bucket[2])
|
|
149
|
+
|
|
150
|
+
if tokens == nil then
|
|
151
|
+
tokens = capacity
|
|
152
|
+
lastRefill = now
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
local elapsed = now - lastRefill
|
|
156
|
+
tokens = math.min(capacity, tokens + (elapsed * refillRate))
|
|
157
|
+
|
|
158
|
+
local allowed = tokens >= cost
|
|
159
|
+
if allowed then
|
|
160
|
+
tokens = tokens - cost
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
|
|
164
|
+
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / refillRate))
|
|
165
|
+
|
|
166
|
+
-- Redis truncates Lua numbers to integers on return, silently dropping
|
|
167
|
+
-- the fractional part \u2014 return as a string to preserve partial-refill precision.
|
|
168
|
+
return { allowed and 1 or 0, tostring(tokens) }
|
|
169
|
+
`;
|
|
170
|
+
async function checkTokenBucket(store, identifier, config) {
|
|
171
|
+
const key = await buildKey(config.ruleName, "tokenbucket", identifier);
|
|
172
|
+
const cost = config.cost ?? 1;
|
|
173
|
+
const [allowed, tokensRemainingRaw] = await store.eval(
|
|
174
|
+
TOKEN_BUCKET_SCRIPT,
|
|
175
|
+
[key],
|
|
176
|
+
[config.capacity, config.refillRate, cost]
|
|
177
|
+
);
|
|
178
|
+
const tokensRemaining = Number.parseFloat(tokensRemainingRaw);
|
|
179
|
+
const resetSeconds = Math.ceil(
|
|
180
|
+
(config.capacity - tokensRemaining) / config.refillRate
|
|
181
|
+
);
|
|
182
|
+
return {
|
|
183
|
+
allowed: allowed === 1,
|
|
184
|
+
remaining: tokensRemaining,
|
|
185
|
+
resetSeconds: Math.max(0, resetSeconds)
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// src/headers.ts
|
|
190
|
+
function buildRateLimitHeaders(limit, result, style = "draft-6") {
|
|
191
|
+
const headers = style === "draft-7" ? {
|
|
192
|
+
RateLimit: `limit=${limit}, remaining=${result.remaining ?? 0}, reset=${result.resetSeconds}`
|
|
193
|
+
} : {
|
|
194
|
+
"RateLimit-Limit": String(limit),
|
|
195
|
+
"RateLimit-Remaining": String(result.remaining ?? 0),
|
|
196
|
+
"RateLimit-Reset": String(result.resetSeconds)
|
|
197
|
+
};
|
|
198
|
+
if (result.retryAfterSeconds !== void 0) {
|
|
199
|
+
headers["Retry-After"] = String(result.retryAfterSeconds);
|
|
200
|
+
}
|
|
201
|
+
return headers;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// src/middleware.ts
|
|
205
|
+
function getLimitValue(config) {
|
|
206
|
+
switch (config.algorithm) {
|
|
207
|
+
case "tokenBucket":
|
|
208
|
+
case "leakyBucket":
|
|
209
|
+
return config.capacity;
|
|
210
|
+
case "fixedWindow":
|
|
211
|
+
case "slidingWindow":
|
|
212
|
+
return config.limit;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function runCheck(config, identifier) {
|
|
216
|
+
switch (config.algorithm) {
|
|
217
|
+
case "tokenBucket":
|
|
218
|
+
return checkTokenBucket(config.store, identifier, config);
|
|
219
|
+
case "fixedWindow":
|
|
220
|
+
return checkFixedWindow(config.store, identifier, config);
|
|
221
|
+
case "slidingWindow":
|
|
222
|
+
return checkSlidingWindow(config.store, identifier, config);
|
|
223
|
+
case "leakyBucket":
|
|
224
|
+
return checkLeakyBucket(config.store, identifier, config);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function rateLimiter(config) {
|
|
228
|
+
return createMiddleware(async (c, next) => {
|
|
229
|
+
const identifier = config.identifier(c);
|
|
230
|
+
let result;
|
|
231
|
+
try {
|
|
232
|
+
result = await runCheck(config, identifier);
|
|
233
|
+
} catch {
|
|
234
|
+
if (config.onError === "fail-open") {
|
|
235
|
+
await next();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
c.status(503);
|
|
239
|
+
return c.json({ error: "Rate limiter unavailable" });
|
|
240
|
+
}
|
|
241
|
+
const headers = buildRateLimitHeaders(
|
|
242
|
+
getLimitValue(config),
|
|
243
|
+
result,
|
|
244
|
+
config.headerStyle
|
|
245
|
+
);
|
|
246
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
247
|
+
c.header(key, value);
|
|
248
|
+
}
|
|
249
|
+
if (!result.allowed) {
|
|
250
|
+
if (config.message)
|
|
251
|
+
return config.message(c);
|
|
252
|
+
c.status(429);
|
|
253
|
+
return c.json({ error: "Too many requests" });
|
|
254
|
+
}
|
|
255
|
+
await next();
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// src/store/redis-store.ts
|
|
260
|
+
var RedisStore = class {
|
|
261
|
+
client;
|
|
262
|
+
definedCommands = /* @__PURE__ */ new Set();
|
|
263
|
+
constructor(options) {
|
|
264
|
+
this.client = options.client;
|
|
265
|
+
}
|
|
266
|
+
async eval(script, keys, args) {
|
|
267
|
+
const commandName = `limitwall_${hashScript(script)}`;
|
|
268
|
+
if (!this.definedCommands.has(commandName)) {
|
|
269
|
+
this.client.defineCommand(commandName, {
|
|
270
|
+
numberOfKeys: keys.length,
|
|
271
|
+
lua: script
|
|
272
|
+
});
|
|
273
|
+
this.definedCommands.add(commandName);
|
|
274
|
+
}
|
|
275
|
+
const result = await this.client[commandName](...keys, ...args.map(String));
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
};
|
|
279
|
+
function hashScript(script) {
|
|
280
|
+
let hash = 0;
|
|
281
|
+
for (let i = 0; i < script.length; i++) {
|
|
282
|
+
hash = (hash << 5) - hash + script.charCodeAt(i);
|
|
283
|
+
hash |= 0;
|
|
284
|
+
}
|
|
285
|
+
return Math.abs(hash).toString(36);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// src/store/upstash-store.ts
|
|
289
|
+
var UpstashStore = class {
|
|
290
|
+
client;
|
|
291
|
+
constructor(options) {
|
|
292
|
+
this.client = options.client;
|
|
293
|
+
}
|
|
294
|
+
async eval(script, keys, args) {
|
|
295
|
+
const result = await this.client.eval(script, keys, args);
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
export {
|
|
300
|
+
RedisStore,
|
|
301
|
+
UpstashStore,
|
|
302
|
+
rateLimiter
|
|
303
|
+
};
|
|
304
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/middleware.ts","../src/key.ts","../src/algorithms/fixed-window.ts","../src/algorithms/leaky-bucket.ts","../src/algorithms/sliding-window.ts","../src/algorithms/token-bucket.ts","../src/headers.ts","../src/store/redis-store.ts","../src/store/upstash-store.ts"],"sourcesContent":["import type { Context } from \"hono\";\n\nimport { createMiddleware } from \"hono/factory\";\n\nimport type { RateLimitStore } from \"./store/types\";\nimport type { RateLimitAlgorithmConfig, RateLimitResult } from \"./types\";\n\nimport { checkFixedWindow } from \"./algorithms/fixed-window\";\nimport { checkLeakyBucket } from \"./algorithms/leaky-bucket\";\nimport { checkSlidingWindow } from \"./algorithms/sliding-window\";\nimport { checkTokenBucket } from \"./algorithms/token-bucket\";\nimport { buildRateLimitHeaders, type HeaderStyle } from \"./headers\";\n\n/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */\nexport type RateLimiterConfig = RateLimitAlgorithmConfig & {\n /** Extracts the identifier to rate limit by (IP, user ID, API key, etc.). */\n identifier: (c: Context) => string;\n store: RateLimitStore;\n /** Required, explicit behavior when the store is unreachable — no silent default. */\n onError: \"fail-open\" | \"fail-closed\";\n headerStyle?: HeaderStyle;\n /** Overrides the default 429 JSON body. */\n message?: (c: Context) => Response;\n};\n\n/** Resolves the configured limit/capacity, regardless of which algorithm is active. */\nfunction getLimitValue(config: RateLimiterConfig): number {\n switch (config.algorithm) {\n case \"tokenBucket\":\n case \"leakyBucket\":\n return config.capacity;\n case \"fixedWindow\":\n case \"slidingWindow\":\n return config.limit;\n }\n}\n\n/** Runs the configured algorithm's check function. */\nasync function runCheck(\n config: RateLimiterConfig,\n identifier: string,\n): Promise<RateLimitResult> {\n switch (config.algorithm) {\n case \"tokenBucket\":\n return checkTokenBucket(config.store, identifier, config);\n case \"fixedWindow\":\n return checkFixedWindow(config.store, identifier, config);\n case \"slidingWindow\":\n return checkSlidingWindow(config.store, identifier, config);\n case \"leakyBucket\":\n return checkLeakyBucket(config.store, identifier, config);\n }\n}\n\n/** Creates a Hono rate-limiting middleware for the given config. */\nexport function rateLimiter(config: RateLimiterConfig) {\n return createMiddleware(async (c, next) => {\n const identifier = config.identifier(c);\n\n let result: RateLimitResult;\n try {\n result = await runCheck(config, identifier);\n }\n catch {\n // Store unreachable - do exactly what was explicitly configured.\n if (config.onError === \"fail-open\") {\n await next();\n return;\n }\n c.status(503);\n return c.json({ error: \"Rate limiter unavailable\" });\n }\n\n const headers = buildRateLimitHeaders(\n getLimitValue(config),\n result,\n config.headerStyle,\n );\n for (const [key, value] of Object.entries(headers)) {\n c.header(key, value);\n }\n\n if (!result.allowed) {\n if (config.message)\n return config.message(c);\n c.status(429);\n return c.json({ error: \"Too many requests\" });\n }\n\n await next();\n });\n}\n","/**\n * Builds the Redis key for a given rule + algorithm + identifier.\n * The identifier is always hashed before being stored, so raw IPs,\n * emails, or user IDs never sit in Redis in plain text.\n *\n * Uses the Web Crypto API (`crypto.subtle`) instead of Node's\n * `node:crypto`, since Node's module doesn't exist on edge runtimes\n * like Cloudflare Workers - Web Crypto works identically everywhere.\n */\nexport async function buildKey(\n ruleName: string,\n algorithm: string,\n identifier: string,\n): Promise<string> {\n const hashed = await hashIdentifier(identifier);\n return `ratelimit:${ruleName}:${algorithm}:${hashed}`;\n}\n\n/** SHA-256 hashes a string and returns it as lowercase hex. */\nasync function hashIdentifier(identifier: string): Promise<string> {\n const encoded = new TextEncoder().encode(identifier);\n const digest = await crypto.subtle.digest(\"SHA-256\", encoded);\n return Array.from(new Uint8Array(digest))\n .map(byte => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { FixedWindowConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Counts requests within a fixed time slot and resets when the slot's\n * TTL expires. Simplest algorithm; allows a burst right at the window\n * boundary - use sliding window if that's a problem for your use case.\n */\nconst FIXED_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal current = redis.call('INCR', KEYS[1])\nif current == 1 then\n redis.call('EXPIRE', KEYS[1], windowSeconds)\nend\n\nlocal ttl = redis.call('TTL', KEYS[1])\nlocal allowed = current <= limit\nlocal remaining = math.max(0, limit - current)\n\nreturn { allowed and 1 or 0, remaining, ttl }\n`;\n\n/** Runs a fixed window check for one identifier under one rule. */\nexport async function checkFixedWindow(\n store: RateLimitStore,\n identifier: string,\n config: FixedWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"fixedwindow\", identifier);\n\n const [allowed, remaining, ttl] = await store.eval<[number, number, number]>(\n FIXED_WINDOW_SCRIPT,\n [key],\n [config.limit, config.windowSeconds],\n );\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds: ttl,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { LeakyBucketConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Implements leaky bucket as GCRA (Generic Cell Rate Algorithm): tracks\n * one \"theoretical arrival time\" per identifier instead of a token\n * count, producing smooth, evenly-paced traffic rather than the bursty\n * traffic token bucket allows.\n */\nconst LEAKY_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal rate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal emissionInterval = 1 / rate\nlocal burstOffset = emissionInterval * capacity\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1]) + (tonumber(time[2]) / 1000000)\n\nlocal tat = tonumber(redis.call('GET', KEYS[1]))\nif tat == nil or tat < now then\n tat = now\nend\n\nlocal newTat = tat + (emissionInterval * cost)\nlocal allowAt = newTat - burstOffset\nlocal allowed = allowAt <= now\n\nif allowed then\n redis.call('SET', KEYS[1], newTat, 'EX', math.ceil(burstOffset + emissionInterval))\n return { 1, \"0\" }\nelse\n -- Redis truncates Lua numbers to integers on return, silently dropping\n -- the fractional part — return as a string to preserve sub-second precision.\n return { 0, tostring(allowAt - now) }\nend\n`;\n\n/** Runs a leaky bucket (GCRA) check for one identifier under one rule. */\nexport async function checkLeakyBucket(\n store: RateLimitStore,\n identifier: string,\n config: LeakyBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"leakybucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, waitSecondsRaw] = await store.eval<[number, string]>(\n LEAKY_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.rate, cost],\n );\n\n const waitSeconds = Number.parseFloat(waitSecondsRaw);\n const isAllowed = allowed === 1;\n\n return {\n allowed: isAllowed,\n resetSeconds: Math.ceil(waitSeconds),\n retryAfterSeconds: isAllowed ? undefined : Math.ceil(waitSeconds),\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, SlidingWindowConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Blends the current window's count with a weighted portion of the\n * previous window's count, so traffic can't double up right at a\n * fixed-window boundary.\n */\nconst SLIDING_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal currentWindow = math.floor(now / windowSeconds)\nlocal previousWindow = currentWindow - 1\n\nlocal currentKey = KEYS[1] .. ':' .. currentWindow\nlocal previousKey = KEYS[1] .. ':' .. previousWindow\n\nlocal previousCount = tonumber(redis.call('GET', previousKey)) or 0\nlocal elapsedInWindow = now % windowSeconds\nlocal weight = (windowSeconds - elapsedInWindow) / windowSeconds\n\nlocal currentCount = redis.call('INCR', currentKey)\nif currentCount == 1 then\n redis.call('EXPIRE', currentKey, windowSeconds * 2)\nend\n\nlocal estimatedTotal = (previousCount * weight) + currentCount\nlocal allowed = estimatedTotal <= limit\n\nif not allowed then\n redis.call('DECR', currentKey)\nend\n\nlocal remaining = math.max(0, math.floor(limit - estimatedTotal))\nlocal resetSeconds = math.ceil(windowSeconds - elapsedInWindow)\n\nreturn { allowed and 1 or 0, remaining, resetSeconds }\n`;\n\n/** Runs a sliding window check for one identifier under one rule. */\nexport async function checkSlidingWindow(\n store: RateLimitStore,\n identifier: string,\n config: SlidingWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"slidingwindow\", identifier);\n\n const [allowed, remaining, resetSeconds] = await store.eval<\n [number, number, number]\n >(SLIDING_WINDOW_SCRIPT, [key], [config.limit, config.windowSeconds]);\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, TokenBucketConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Lazily refills a bucket of tokens based on elapsed time, entirely\n * inside one atomic Redis operation. See the module README for the\n * plain-language walkthrough of the algorithm.\n */\nconst TOKEN_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal refillRate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')\nlocal tokens = tonumber(bucket[1])\nlocal lastRefill = tonumber(bucket[2])\n\nif tokens == nil then\n tokens = capacity\n lastRefill = now\nend\n\nlocal elapsed = now - lastRefill\ntokens = math.min(capacity, tokens + (elapsed * refillRate))\n\nlocal allowed = tokens >= cost\nif allowed then\n tokens = tokens - cost\nend\n\nredis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)\nredis.call('EXPIRE', KEYS[1], math.ceil(capacity / refillRate))\n\n-- Redis truncates Lua numbers to integers on return, silently dropping\n-- the fractional part — return as a string to preserve partial-refill precision.\nreturn { allowed and 1 or 0, tostring(tokens) }\n`;\n\n/** Runs a token bucket check for one identifier under one rule. */\nexport async function checkTokenBucket(\n store: RateLimitStore,\n identifier: string,\n config: TokenBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"tokenbucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, tokensRemainingRaw] = await store.eval<[number, string]>(\n TOKEN_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.refillRate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.refillRate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\n","import type { RateLimitResult } from \"./types\";\n\nexport type HeaderStyle = \"draft-6\" | \"draft-7\";\n\n/**\n * Builds response headers for a rate limit result, following one of the\n * two IETF RateLimit header drafts, plus the standard HTTP `Retry-After`\n * header when the result carries a `retryAfterSeconds` (leaky bucket).\n *\n * draft-6 uses three separate `RateLimit-*` headers.\n * draft-7 combines them into a single `RateLimit` header.\n */\nexport function buildRateLimitHeaders(\n limit: number,\n result: RateLimitResult,\n style: HeaderStyle = \"draft-6\",\n): Record<string, string> {\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n result.remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(result.remaining ?? 0),\n \"RateLimit-Reset\": String(result.resetSeconds),\n };\n\n if (result.retryAfterSeconds !== undefined) {\n headers[\"Retry-After\"] = String(result.retryAfterSeconds);\n }\n\n return headers;\n}\n","import type { Redis } from \"ioredis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for regular Node.js servers, backed by a persistent\n * connection via `ioredis`. Not for edge runtimes — use UpstashStore\n * there instead, since ioredis depends on Node's networking APIs.\n *\n * Registers each unique script once via `defineCommand`, so ioredis\n * automatically uses the cached `EVALSHA` form after the first call\n * instead of resending the full script text every request.\n */\nexport class RedisStore implements RateLimitStore {\n private readonly client: Redis;\n private readonly definedCommands = new Set<string>();\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const commandName = `limitwall_${hashScript(script)}`;\n\n if (!this.definedCommands.has(commandName)) {\n this.client.defineCommand(commandName, {\n numberOfKeys: keys.length,\n lua: script,\n });\n this.definedCommands.add(commandName);\n }\n\n // @ts-expect-error - commandName is registered dynamically above\n const result = await this.client[commandName](...keys, ...args.map(String));\n return result as T;\n }\n}\n\n/** Cheap, stable identifier for a script's text - not cryptographic, just needs to be unique per script. */\nfunction hashScript(script: string): string {\n let hash = 0;\n for (let i = 0; i < script.length; i++) {\n hash = (hash << 5) - hash + script.charCodeAt(i);\n hash |= 0;\n }\n return Math.abs(hash).toString(36);\n}\n","import type { Redis } from \"@upstash/redis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for edge/serverless runtimes (Cloudflare Workers, Deno,\n * Vercel Edge) via Upstash's REST API, which needs no persistent\n * connection. Use RedisStore instead on regular Node servers.\n *\n * Note: unlike ioredis, the Upstash SDK has no built-in script-caching\n * mechanism equivalent to `defineCommand` — every call sends the full\n * script text over HTTP. This is an accepted tradeoff for edge runtimes,\n * not a bug: REST-over-HTTP has per-call overhead regardless.\n */\nexport class UpstashStore implements RateLimitStore {\n private readonly client: Redis;\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const result = await this.client.eval(script, keys, args);\n return result as T;\n }\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "limitwall",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Rate limiting middleware for Hono, backed by Redis. Fixed window, sliding window, token bucket, and leaky bucket (GCRA) in one package",
|
|
6
|
+
"author": "Kevin Rozario",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"homepage": "https://github.com/Kevin-Rozario/limitwall#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Kevin-Rozario/limitwall.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/Kevin-Rozario/limitwall/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"hono",
|
|
18
|
+
"rate-limit",
|
|
19
|
+
"rate-limiter",
|
|
20
|
+
"redis",
|
|
21
|
+
"token-bucket",
|
|
22
|
+
"leaky-bucket",
|
|
23
|
+
"sliding-window",
|
|
24
|
+
"fixed-window",
|
|
25
|
+
"gcra",
|
|
26
|
+
"middleware"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"import": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"default": "./dist/index.js"
|
|
34
|
+
},
|
|
35
|
+
"require": {
|
|
36
|
+
"types": "./dist/index.d.cts",
|
|
37
|
+
"default": "./dist/index.cjs"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"main": "./dist/index.cjs",
|
|
42
|
+
"module": "./dist/index.js",
|
|
43
|
+
"types": "./dist/index.d.ts",
|
|
44
|
+
"files": [
|
|
45
|
+
"dist"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@upstash/redis": ">=1",
|
|
52
|
+
"hono": ">=4",
|
|
53
|
+
"ioredis": "^5"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@upstash/redis": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"ioredis": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@antfu/eslint-config": "^3",
|
|
65
|
+
"@types/node": "^22",
|
|
66
|
+
"@upstash/redis": "^1",
|
|
67
|
+
"eslint": "^9",
|
|
68
|
+
"eslint-plugin-format": "^2.0.1",
|
|
69
|
+
"hono": "^4",
|
|
70
|
+
"ioredis": "^5",
|
|
71
|
+
"testcontainers": "^10",
|
|
72
|
+
"tsup": "^8",
|
|
73
|
+
"typescript": "^5",
|
|
74
|
+
"vitest": "^2"
|
|
75
|
+
},
|
|
76
|
+
"scripts": {
|
|
77
|
+
"build": "tsup",
|
|
78
|
+
"dev": "tsup --watch",
|
|
79
|
+
"test": "vitest run",
|
|
80
|
+
"lint": "eslint .",
|
|
81
|
+
"lint:fix": "eslint . --fix"
|
|
82
|
+
}
|
|
83
|
+
}
|