limitwall 1.0.1 → 1.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/README.md +39 -4
- package/dist/index.cjs +66 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +12 -4
- package/dist/index.d.ts +12 -4
- package/dist/index.js +65 -14
- package/dist/index.js.map +1 -1
- package/package.json +11 -9
package/README.md
CHANGED
|
@@ -12,6 +12,7 @@ Works on regular Node servers (via `ioredis`) and edge/serverless runtimes like
|
|
|
12
12
|
- **Privacy-conscious keys** - identifiers (IPs, user IDs, etc.) are hashed before ever touching Redis
|
|
13
13
|
- **No silent defaults on failure** - you explicitly choose fail-open or fail-closed behavior when Redis is unreachable
|
|
14
14
|
- **Standard headers** - both IETF `RateLimit-*` header drafts supported, plus `Retry-After`
|
|
15
|
+
- **Multi-rule composition** - `combineRules()` evaluates several rules in one middleware; stacking `rateLimiter()` still works
|
|
15
16
|
|
|
16
17
|
## Install
|
|
17
18
|
|
|
@@ -41,7 +42,7 @@ app.use(
|
|
|
41
42
|
algorithm: "tokenBucket",
|
|
42
43
|
ruleName: "api",
|
|
43
44
|
capacity: 10,
|
|
44
|
-
|
|
45
|
+
rate: 0.5,
|
|
45
46
|
identifier: c => c.req.header("x-forwarded-for") ?? "unknown",
|
|
46
47
|
store,
|
|
47
48
|
onError: "fail-open",
|
|
@@ -88,10 +89,10 @@ export default app;
|
|
|
88
89
|
| --------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
89
90
|
| `fixedWindow` | `limit`, `windowSeconds` | Simplest. Resets on the dot every window; allows a burst right at the boundary. |
|
|
90
91
|
| `slidingWindow` | `limit`, `windowSeconds` | Blends the current and previous window's counts, avoiding the boundary-burst problem. |
|
|
91
|
-
| `tokenBucket` | `capacity`, `
|
|
92
|
+
| `tokenBucket` | `capacity`, `rate` | Bucket refills continuously; allows saved-up bursts up to `capacity`. |
|
|
92
93
|
| `leakyBucket` | `capacity`, `rate` | Implemented as GCRA. Smooths traffic into an even pace rather than allowing bursts. See note below on `remaining`/`reset` values. |
|
|
93
94
|
|
|
94
|
-
Every algorithm also accepts `ruleName` (required - namespaces its Redis keys) and `cost` (optional, defaults to `1`).
|
|
95
|
+
Every algorithm also accepts `ruleName` (required - namespaces its Redis keys). `tokenBucket` and `leakyBucket` also accept `cost` (optional, defaults to `1`). Window algorithms always count one request.
|
|
95
96
|
|
|
96
97
|
> **Note on `leakyBucket`:** unlike the other three algorithms, GCRA doesn't track a discrete "count remaining" - it tracks a single theoretical arrival time. As a result, `RateLimit-Remaining` and `RateLimit-Reset` are not meaningful on **allowed** `leakyBucket` requests and will read `0`. This is expected, not a bug. The value that _is_ meaningful for this algorithm is `Retry-After`, which is only set on **rejected** requests and tells the caller exactly how long to wait.
|
|
97
98
|
|
|
@@ -102,7 +103,7 @@ interface RateLimiterConfig {
|
|
|
102
103
|
algorithm: "tokenBucket" | "fixedWindow" | "slidingWindow" | "leakyBucket";
|
|
103
104
|
ruleName: string;
|
|
104
105
|
// ...algorithm-specific fields, see table above
|
|
105
|
-
cost?: number
|
|
106
|
+
// cost?: number — tokenBucket and leakyBucket only, defaults to 1
|
|
106
107
|
|
|
107
108
|
identifier: (c: Context) => string; // required - what to rate limit by
|
|
108
109
|
store: RateLimitStore; // NodeRedisStore or UpstashRedisStore
|
|
@@ -112,6 +113,40 @@ interface RateLimiterConfig {
|
|
|
112
113
|
}
|
|
113
114
|
```
|
|
114
115
|
|
|
116
|
+
## Combining rules
|
|
117
|
+
|
|
118
|
+
`combineRules()` takes an array of the same configs you would pass to `rateLimiter()`. Rules run in order. The first reject wins and later rules are not charged. On allow, headers come from the rule with the lowest remaining (or the last rule if none report remaining, as with leaky bucket). Each rule still uses one Redis round trip.
|
|
119
|
+
|
|
120
|
+
Stacking multiple `rateLimiter()` middlewares remains valid.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
import { combineRules, NodeRedisStore } from "limitwall";
|
|
124
|
+
|
|
125
|
+
app.use(
|
|
126
|
+
"/api/*",
|
|
127
|
+
combineRules([
|
|
128
|
+
{
|
|
129
|
+
algorithm: "fixedWindow",
|
|
130
|
+
ruleName: "per-ip",
|
|
131
|
+
limit: 100,
|
|
132
|
+
windowSeconds: 60,
|
|
133
|
+
identifier: c => c.req.header("x-forwarded-for") ?? "unknown",
|
|
134
|
+
store,
|
|
135
|
+
onError: "fail-closed",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
algorithm: "tokenBucket",
|
|
139
|
+
ruleName: "per-user",
|
|
140
|
+
capacity: 20,
|
|
141
|
+
rate: 1,
|
|
142
|
+
identifier: c => c.get("userId") ?? "anon",
|
|
143
|
+
store,
|
|
144
|
+
onError: "fail-closed",
|
|
145
|
+
},
|
|
146
|
+
]),
|
|
147
|
+
);
|
|
148
|
+
```
|
|
149
|
+
|
|
115
150
|
## Response headers
|
|
116
151
|
|
|
117
152
|
On every request:
|
package/dist/index.cjs
CHANGED
|
@@ -26,6 +26,7 @@ __export(index_exports, {
|
|
|
26
26
|
checkLeakyBucket: () => checkLeakyBucket,
|
|
27
27
|
checkSlidingWindow: () => checkSlidingWindow,
|
|
28
28
|
checkTokenBucket: () => checkTokenBucket,
|
|
29
|
+
combineRules: () => combineRules,
|
|
29
30
|
rateLimiter: () => rateLimiter
|
|
30
31
|
});
|
|
31
32
|
module.exports = __toCommonJS(index_exports);
|
|
@@ -215,6 +216,9 @@ async function checkTokenBucket(store, identifier, config) {
|
|
|
215
216
|
};
|
|
216
217
|
}
|
|
217
218
|
|
|
219
|
+
// src/compose.ts
|
|
220
|
+
var import_factory2 = require("hono/factory");
|
|
221
|
+
|
|
218
222
|
// src/middleware.ts
|
|
219
223
|
var import_factory = require("hono/factory");
|
|
220
224
|
|
|
@@ -234,7 +238,7 @@ function buildRateLimitHeaders(limit, result, style = "draft-6") {
|
|
|
234
238
|
return headers;
|
|
235
239
|
}
|
|
236
240
|
|
|
237
|
-
// src/
|
|
241
|
+
// src/run-check.ts
|
|
238
242
|
function getLimitValue(config) {
|
|
239
243
|
switch (config.algorithm) {
|
|
240
244
|
case "tokenBucket":
|
|
@@ -257,6 +261,25 @@ async function runCheck(config, identifier) {
|
|
|
257
261
|
return checkLeakyBucket(config.store, identifier, config);
|
|
258
262
|
}
|
|
259
263
|
}
|
|
264
|
+
|
|
265
|
+
// src/middleware.ts
|
|
266
|
+
async function applyRateLimitResult(c, next, config, result) {
|
|
267
|
+
const headers = buildRateLimitHeaders(
|
|
268
|
+
getLimitValue(config),
|
|
269
|
+
result,
|
|
270
|
+
config.headerStyle
|
|
271
|
+
);
|
|
272
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
273
|
+
c.header(key, value);
|
|
274
|
+
}
|
|
275
|
+
if (!result.allowed) {
|
|
276
|
+
if (config.message)
|
|
277
|
+
return config.message(c);
|
|
278
|
+
c.status(429);
|
|
279
|
+
return c.json({ error: "Too many requests" });
|
|
280
|
+
}
|
|
281
|
+
await next();
|
|
282
|
+
}
|
|
260
283
|
function rateLimiter(config) {
|
|
261
284
|
return (0, import_factory.createMiddleware)(async (c, next) => {
|
|
262
285
|
const identifier = config.identifier(c);
|
|
@@ -271,21 +294,49 @@ function rateLimiter(config) {
|
|
|
271
294
|
c.status(503);
|
|
272
295
|
return c.json({ error: "Rate limiter unavailable" });
|
|
273
296
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
297
|
+
return applyRateLimitResult(c, next, config, result);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// src/compose.ts
|
|
302
|
+
function pickAllowHeaders(evaluated) {
|
|
303
|
+
const withRemaining = evaluated.filter(
|
|
304
|
+
(rule) => rule.result.remaining !== void 0
|
|
305
|
+
);
|
|
306
|
+
if (withRemaining.length === 0)
|
|
307
|
+
return evaluated[evaluated.length - 1];
|
|
308
|
+
return withRemaining.reduce((best, current) => {
|
|
309
|
+
if (current.result.remaining < best.result.remaining)
|
|
310
|
+
return current;
|
|
311
|
+
return best;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
function combineRules(rules) {
|
|
315
|
+
if (rules.length === 0)
|
|
316
|
+
throw new Error("combineRules requires at least one rule");
|
|
317
|
+
return (0, import_factory2.createMiddleware)(async (c, next) => {
|
|
318
|
+
const allowed = [];
|
|
319
|
+
for (const rule of rules) {
|
|
320
|
+
const identifier = rule.identifier(c);
|
|
321
|
+
let result;
|
|
322
|
+
try {
|
|
323
|
+
result = await runCheck(rule, identifier);
|
|
324
|
+
} catch {
|
|
325
|
+
if (rule.onError === "fail-open")
|
|
326
|
+
continue;
|
|
327
|
+
c.status(503);
|
|
328
|
+
return c.json({ error: "Rate limiter unavailable" });
|
|
329
|
+
}
|
|
330
|
+
if (!result.allowed)
|
|
331
|
+
return applyRateLimitResult(c, next, rule, result);
|
|
332
|
+
allowed.push({ config: rule, result });
|
|
281
333
|
}
|
|
282
|
-
if (
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
c.status(429);
|
|
286
|
-
return c.json({ error: "Too many requests" });
|
|
334
|
+
if (allowed.length === 0) {
|
|
335
|
+
await next();
|
|
336
|
+
return;
|
|
287
337
|
}
|
|
288
|
-
|
|
338
|
+
const chosen = pickAllowHeaders(allowed);
|
|
339
|
+
return applyRateLimitResult(c, next, chosen.config, chosen.result);
|
|
289
340
|
});
|
|
290
341
|
}
|
|
291
342
|
|
|
@@ -337,6 +388,7 @@ var UpstashRedisStore = class {
|
|
|
337
388
|
checkLeakyBucket,
|
|
338
389
|
checkSlidingWindow,
|
|
339
390
|
checkTokenBucket,
|
|
391
|
+
combineRules,
|
|
340
392
|
rateLimiter
|
|
341
393
|
});
|
|
342
394
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.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/middleware.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 { checkFixedWindow } from \"./algorithms/fixed-window\";\nexport { checkLeakyBucket } from \"./algorithms/leaky-bucket\";\n\nexport { checkSlidingWindow } from \"./algorithms/sliding-window\";\n// The algorithm check functions themselves have no Hono dependency -\n// they only need a store and an identifier string - so they're exported\n// directly for use outside Hono (plain Node scripts, other frameworks,\n// background jobs, etc.), not just through the `rateLimiter` middleware.\nexport { checkTokenBucket } from \"./algorithms/token-bucket\";\nexport type { HeaderStyle } from \"./headers\";\n\nexport { rateLimiter } from \"./middleware\";\nexport type { RateLimiterConfig } from \"./middleware\";\nexport { NodeRedisStore } from \"./store/redis-store\";\nexport type { RateLimitStore } from \"./store/types\";\n\nexport { UpstashRedisStore } from \"./store/upstash-store\";\n\nexport type {\n FixedWindowConfig,\n LeakyBucketConfig,\n RateLimitAlgorithmConfig,\n RateLimitResult,\n SlidingWindowConfig,\n TokenBucketConfig,\n} from \"./types\";\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 rate = 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 * rate))\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 / rate))\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.rate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.rate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\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","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 // RateLimit-Remaining is specified as a whole-number count of requests,\n // not fractional tokens - floor it here, at the presentation layer, so\n // the underlying result (result.remaining) keeps its real precision for\n // any code that reads it directly.\n const remaining = Math.floor(result.remaining ?? 0);\n\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(remaining),\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 NodeRedisStore 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 UpstashRedisStore 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":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACSA,eAAsB,SACpB,UACA,WACA,YACiB;AACjB,QAAM,SAAS,MAAM,eAAe,UAAU;AAC9C,SAAO,aAAa,QAAQ,IAAI,SAAS,IAAI,MAAM;AACrD;AAGA,eAAe,eAAe,YAAqC;AACjE,QAAM,UAAU,IAAI,YAAY,EAAE,OAAO,UAAU;AACnD,QAAM,SAAS,MAAM,OAAO,OAAO,OAAO,WAAW,OAAO;AAC5D,SAAO,MAAM,KAAK,IAAI,WAAW,MAAM,CAAC,EACrC,IAAI,UAAQ,KAAK,SAAS,EAAE,EAAE,SAAS,GAAG,GAAG,CAAC,EAC9C,KAAK,EAAE;AACZ;;;ACfA,IAAM,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAiB5B,eAAsB,iBACpB,OACA,YACA,QAC0B;AAC1B,QAAM,MAAM,MAAM,SAAS,OAAO,UAAU,eAAe,UAAU;AAErE,QAAM,CAAC,SAAS,WAAW,GAAG,IAAI,MAAM,MAAM;AAAA,IAC5C;AAAA,IACA,CAAC,GAAG;AAAA,IACJ,CAAC,OAAO,OAAO,OAAO,aAAa;AAAA,EACrC;AAEA,SAAO;AAAA,IACL,SAAS,YAAY;AAAA,IACrB;AAAA,IACA,cAAc;AAAA,EAChB;AACF;;;AClCA,IAAM,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA+B5B,eAAsB,iBACpB,OACA,YACA,QAC0B;AAC1B,QAAM,MAAM,MAAM,SAAS,OAAO,UAAU,eAAe,UAAU;AACrE,QAAM,OAAO,OAAO,QAAQ;AAE5B,QAAM,CAAC,SAAS,cAAc,IAAI,MAAM,MAAM;AAAA,IAC5C;AAAA,IACA,CAAC,GAAG;AAAA,IACJ,CAAC,OAAO,UAAU,OAAO,MAAM,IAAI;AAAA,EACrC;AAEA,QAAM,cAAc,OAAO,WAAW,cAAc;AACpD,QAAM,YAAY,YAAY;AAE9B,SAAO;AAAA,IACL,SAAS;AAAA,IACT,cAAc,KAAK,KAAK,WAAW;AAAA,IACnC,mBAAmB,YAAY,SAAY,KAAK,KAAK,WAAW;AAAA,EAClE;AACF;;;ACtDA,IAAM,wBAAwB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAoC9B,eAAsB,mBACpB,OACA,YACA,QAC0B;AAC1B,QAAM,MAAM,MAAM,SAAS,OAAO,UAAU,iBAAiB,UAAU;AAEvE,QAAM,CAAC,SAAS,WAAW,YAAY,IAAI,MAAM,MAAM,KAErD,uBAAuB,CAAC,GAAG,GAAG,CAAC,OAAO,OAAO,OAAO,aAAa,CAAC;AAEpE,SAAO;AAAA,IACL,SAAS,YAAY;AAAA,IACrB;AAAA,IACA;AAAA,EACF;AACF;;;ACpDA,IAAM,sBAAsB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAkC5B,eAAsB,iBACpB,OACA,YACA,QAC0B;AAC1B,QAAM,MAAM,MAAM,SAAS,OAAO,UAAU,eAAe,UAAU;AACrE,QAAM,OAAO,OAAO,QAAQ;AAE5B,QAAM,CAAC,SAAS,kBAAkB,IAAI,MAAM,MAAM;AAAA,IAChD;AAAA,IACA,CAAC,GAAG;AAAA,IACJ,CAAC,OAAO,UAAU,OAAO,MAAM,IAAI;AAAA,EACrC;AAEA,QAAM,kBAAkB,OAAO,WAAW,kBAAkB;AAE5D,QAAM,eAAe,KAAK;AAAA,KACvB,OAAO,WAAW,mBAAmB,OAAO;AAAA,EAC/C;AAEA,SAAO;AAAA,IACL,SAAS,YAAY;AAAA,IACrB,WAAW;AAAA,IACX,cAAc,KAAK,IAAI,GAAG,YAAY;AAAA,EACxC;AACF;;;ACnEA,qBAAiC;;;ACU1B,SAAS,sBACd,OACA,QACA,QAAqB,WACG;AAKxB,QAAM,YAAY,KAAK,MAAM,OAAO,aAAa,CAAC;AAElD,QAAM,UACF,UAAU,YACR;AAAA,IACE,WAAW,SAAS,KAAK,eACvB,aAAa,CACf,WAAW,OAAO,YAAY;AAAA,EAChC,IACA;AAAA,IACE,mBAAmB,OAAO,KAAK;AAAA,IAC/B,uBAAuB,OAAO,SAAS;AAAA,IACvC,mBAAmB,OAAO,OAAO,YAAY;AAAA,EAC/C;AAEN,MAAI,OAAO,sBAAsB,QAAW;AAC1C,YAAQ,aAAa,IAAI,OAAO,OAAO,iBAAiB;AAAA,EAC1D;AAEA,SAAO;AACT;;;ADfA,SAAS,cAAc,QAAmC;AACxD,UAAQ,OAAO,WAAW;AAAA,IACxB,KAAK;AAAA,IACL,KAAK;AACH,aAAO,OAAO;AAAA,IAChB,KAAK;AAAA,IACL,KAAK;AACH,aAAO,OAAO;AAAA,EAClB;AACF;AAGA,eAAe,SACb,QACA,YAC0B;AAC1B,UAAQ,OAAO,WAAW;AAAA,IACxB,KAAK;AACH,aAAO,iBAAiB,OAAO,OAAO,YAAY,MAAM;AAAA,IAC1D,KAAK;AACH,aAAO,iBAAiB,OAAO,OAAO,YAAY,MAAM;AAAA,IAC1D,KAAK;AACH,aAAO,mBAAmB,OAAO,OAAO,YAAY,MAAM;AAAA,IAC5D,KAAK;AACH,aAAO,iBAAiB,OAAO,OAAO,YAAY,MAAM;AAAA,EAC5D;AACF;AAGO,SAAS,YAAY,QAA2B;AACrD,aAAO,iCAAiB,OAAO,GAAG,SAAS;AACzC,UAAM,aAAa,OAAO,WAAW,CAAC;AAEtC,QAAI;AACJ,QAAI;AACF,eAAS,MAAM,SAAS,QAAQ,UAAU;AAAA,IAC5C,QACM;AAEJ,UAAI,OAAO,YAAY,aAAa;AAClC,cAAM,KAAK;AACX;AAAA,MACF;AACA,QAAE,OAAO,GAAG;AACZ,aAAO,EAAE,KAAK,EAAE,OAAO,2BAA2B,CAAC;AAAA,IACrD;AAEA,UAAM,UAAU;AAAA,MACd,cAAc,MAAM;AAAA,MACpB;AAAA,MACA,OAAO;AAAA,IACT;AACA,eAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,OAAO,GAAG;AAClD,QAAE,OAAO,KAAK,KAAK;AAAA,IACrB;AAEA,QAAI,CAAC,OAAO,SAAS;AACnB,UAAI,OAAO;AACT,eAAO,OAAO,QAAQ,CAAC;AACzB,QAAE,OAAO,GAAG;AACZ,aAAO,EAAE,KAAK,EAAE,OAAO,oBAAoB,CAAC;AAAA,IAC9C;AAEA,UAAM,KAAK;AAAA,EACb,CAAC;AACH;;;AE9EO,IAAM,iBAAN,MAA+C;AAAA,EACnC;AAAA,EACA,kBAAkB,oBAAI,IAAY;AAAA,EAEnD,YAAY,SAA4B;AACtC,SAAK,SAAS,QAAQ;AAAA,EACxB;AAAA,EAEA,MAAM,KACJ,QACA,MACA,MACY;AACZ,UAAM,cAAc,aAAa,WAAW,MAAM,CAAC;AAEnD,QAAI,CAAC,KAAK,gBAAgB,IAAI,WAAW,GAAG;AAC1C,WAAK,OAAO,cAAc,aAAa;AAAA,QACrC,cAAc,KAAK;AAAA,QACnB,KAAK;AAAA,MACP,CAAC;AACD,WAAK,gBAAgB,IAAI,WAAW;AAAA,IACtC;AAGA,UAAM,SAAS,MAAM,KAAK,OAAO,WAAW,EAAE,GAAG,MAAM,GAAG,KAAK,IAAI,MAAM,CAAC;AAC1E,WAAO;AAAA,EACT;AACF;AAGA,SAAS,WAAW,QAAwB;AAC1C,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,OAAO,QAAQ,KAAK;AACtC,YAAQ,QAAQ,KAAK,OAAO,OAAO,WAAW,CAAC;AAC/C,YAAQ;AAAA,EACV;AACA,SAAO,KAAK,IAAI,IAAI,EAAE,SAAS,EAAE;AACnC;;;ACpCO,IAAM,oBAAN,MAAkD;AAAA,EACtC;AAAA,EAEjB,YAAY,SAA4B;AACtC,SAAK,SAAS,QAAQ;AAAA,EACxB;AAAA,EAEA,MAAM,KACJ,QACA,MACA,MACY;AACZ,UAAM,SAAS,MAAM,KAAK,OAAO,KAAK,QAAQ,MAAM,IAAI;AACxD,WAAO;AAAA,EACT;AACF;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.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/compose.ts","../src/middleware.ts","../src/headers.ts","../src/run-check.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 { checkFixedWindow } from \"./algorithms/fixed-window\";\nexport { checkLeakyBucket } from \"./algorithms/leaky-bucket\";\nexport { checkSlidingWindow } from \"./algorithms/sliding-window\";\n// The algorithm check functions themselves have no Hono dependency -\n// they only need a store and an identifier string - so they're exported\n// directly for use outside Hono (plain Node scripts, other frameworks,\n// background jobs, etc.), not just through the `rateLimiter` middleware.\nexport { checkTokenBucket } from \"./algorithms/token-bucket\";\nexport { combineRules } from \"./compose\";\nexport type { HeaderStyle } from \"./headers\";\nexport { rateLimiter } from \"./middleware\";\nexport type { RateLimiterConfig } from \"./middleware\";\nexport { NodeRedisStore } from \"./store/redis-store\";\nexport type { RateLimitStore } from \"./store/types\";\n\nexport { UpstashRedisStore } from \"./store/upstash-store\";\n\nexport type {\n FixedWindowConfig,\n LeakyBucketConfig,\n RateLimitAlgorithmConfig,\n RateLimitResult,\n SlidingWindowConfig,\n TokenBucketConfig,\n} from \"./types\";\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 rate = 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 * rate))\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 / rate))\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.rate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.rate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\n","import { createMiddleware } from \"hono/factory\";\n\nimport type { RateLimitResult } from \"./types\";\n\nimport { applyRateLimitResult, type RateLimiterConfig } from \"./middleware\";\nimport { runCheck } from \"./run-check\";\n\ninterface EvaluatedRule {\n config: RateLimiterConfig;\n result: RateLimitResult;\n}\n\n/** Picks the allowed rule whose remaining is lowest; ties keep the earlier rule. */\nfunction pickAllowHeaders(evaluated: EvaluatedRule[]): EvaluatedRule {\n const withRemaining = evaluated.filter(\n rule => rule.result.remaining !== undefined,\n );\n\n if (withRemaining.length === 0)\n return evaluated[evaluated.length - 1];\n\n return withRemaining.reduce((best, current) => {\n if ((current.result.remaining as number) < (best.result.remaining as number))\n return current;\n return best;\n });\n}\n\n/**\n * Combines several rate-limit rules into one middleware.\n * Rules run in array order; the first reject or fail-closed error wins.\n */\nexport function combineRules(rules: RateLimiterConfig[]) {\n if (rules.length === 0)\n throw new Error(\"combineRules requires at least one rule\");\n\n return createMiddleware(async (c, next) => {\n const allowed: EvaluatedRule[] = [];\n\n for (const rule of rules) {\n const identifier = rule.identifier(c);\n let result: RateLimitResult;\n try {\n result = await runCheck(rule, identifier);\n }\n catch {\n if (rule.onError === \"fail-open\")\n continue;\n\n c.status(503);\n return c.json({ error: \"Rate limiter unavailable\" });\n }\n\n if (!result.allowed)\n return applyRateLimitResult(c, next, rule, result);\n\n allowed.push({ config: rule, result });\n }\n\n if (allowed.length === 0) {\n await next();\n return;\n }\n\n const chosen = pickAllowHeaders(allowed);\n return applyRateLimitResult(c, next, chosen.config, chosen.result);\n });\n}\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 { buildRateLimitHeaders, type HeaderStyle } from \"./headers\";\nimport { getLimitValue, runCheck } from \"./run-check\";\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/** Writes RateLimit headers and either rejects with 429 or continues. */\nexport async function applyRateLimitResult(\n c: Context,\n next: () => Promise<void>,\n config: RateLimiterConfig,\n result: RateLimitResult,\n): Promise<Response | void> {\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/** 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 return applyRateLimitResult(c, next, config, result);\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 // RateLimit-Remaining is specified as a whole-number count of requests,\n // not fractional tokens - floor it here, at the presentation layer, so\n // the underlying result (result.remaining) keeps its real precision for\n // any code that reads it directly.\n const remaining = Math.floor(result.remaining ?? 0);\n\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(remaining),\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 { 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\";\n\n/** Algorithm config plus the store the check should run against. */\nexport type RunnableConfig = RateLimitAlgorithmConfig & {\n store: RateLimitStore;\n};\n\n/** Resolves the configured limit/capacity, regardless of which algorithm is active. */\nexport function getLimitValue(config: RateLimitAlgorithmConfig): 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. */\nexport async function runCheck(\n config: RunnableConfig,\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","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 NodeRedisStore 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 UpstashRedisStore 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":["import_factory"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -44,8 +44,6 @@ interface RateLimitResult {
|
|
|
44
44
|
interface BaseAlgorithmConfig {
|
|
45
45
|
/** Unique name for this rule; namespaces its Redis keys from other rules. */
|
|
46
46
|
ruleName: string;
|
|
47
|
-
/** Cost of a single request, in whatever unit the algorithm counts. Defaults to 1. */
|
|
48
|
-
cost?: number;
|
|
49
47
|
}
|
|
50
48
|
interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
51
49
|
algorithm: "tokenBucket";
|
|
@@ -53,6 +51,8 @@ interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
|
53
51
|
capacity: number;
|
|
54
52
|
/** Tokens added back to the bucket per second. */
|
|
55
53
|
rate: number;
|
|
54
|
+
/** Cost of a single request in tokens. Defaults to 1. */
|
|
55
|
+
cost?: number;
|
|
56
56
|
}
|
|
57
57
|
interface FixedWindowConfig extends BaseAlgorithmConfig {
|
|
58
58
|
algorithm: "fixedWindow";
|
|
@@ -74,10 +74,12 @@ interface LeakyBucketConfig extends BaseAlgorithmConfig {
|
|
|
74
74
|
capacity: number;
|
|
75
75
|
/** Sustained requests allowed per second. */
|
|
76
76
|
rate: number;
|
|
77
|
+
/** Cost of a single request in emission units. Defaults to 1. */
|
|
78
|
+
cost?: number;
|
|
77
79
|
}
|
|
78
80
|
/**
|
|
79
81
|
* Discriminated union of every algorithm's config, tagged by `algorithm`.
|
|
80
|
-
* Picking `algorithm: "tokenBucket"` requires `capacity` + `
|
|
82
|
+
* Picking `algorithm: "tokenBucket"` requires `capacity` + `rate`;
|
|
81
83
|
* TypeScript rejects the wrong field set for whichever algorithm is chosen.
|
|
82
84
|
*/
|
|
83
85
|
type RateLimitAlgorithmConfig = TokenBucketConfig | FixedWindowConfig | SlidingWindowConfig | LeakyBucketConfig;
|
|
@@ -110,6 +112,12 @@ type RateLimiterConfig = RateLimitAlgorithmConfig & {
|
|
|
110
112
|
/** Creates a Hono rate-limiting middleware for the given config. */
|
|
111
113
|
declare function rateLimiter(config: RateLimiterConfig): hono.MiddlewareHandler<any, string, {}, Response>;
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Combines several rate-limit rules into one middleware.
|
|
117
|
+
* Rules run in array order; the first reject or fail-closed error wins.
|
|
118
|
+
*/
|
|
119
|
+
declare function combineRules(rules: RateLimiterConfig[]): hono.MiddlewareHandler<any, string, {}, Response>;
|
|
120
|
+
|
|
113
121
|
/**
|
|
114
122
|
* Store backend for regular Node.js servers, backed by a persistent
|
|
115
123
|
* connection via `ioredis`. Not for edge runtimes - use UpstashStore
|
|
@@ -146,4 +154,4 @@ declare class UpstashRedisStore implements RateLimitStore {
|
|
|
146
154
|
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, NodeRedisStore, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig, type SlidingWindowConfig, type TokenBucketConfig, UpstashRedisStore, checkFixedWindow, checkLeakyBucket, checkSlidingWindow, checkTokenBucket, rateLimiter };
|
|
157
|
+
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, NodeRedisStore, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig, type SlidingWindowConfig, type TokenBucketConfig, UpstashRedisStore, checkFixedWindow, checkLeakyBucket, checkSlidingWindow, checkTokenBucket, combineRules, rateLimiter };
|
package/dist/index.d.ts
CHANGED
|
@@ -44,8 +44,6 @@ interface RateLimitResult {
|
|
|
44
44
|
interface BaseAlgorithmConfig {
|
|
45
45
|
/** Unique name for this rule; namespaces its Redis keys from other rules. */
|
|
46
46
|
ruleName: string;
|
|
47
|
-
/** Cost of a single request, in whatever unit the algorithm counts. Defaults to 1. */
|
|
48
|
-
cost?: number;
|
|
49
47
|
}
|
|
50
48
|
interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
51
49
|
algorithm: "tokenBucket";
|
|
@@ -53,6 +51,8 @@ interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
|
53
51
|
capacity: number;
|
|
54
52
|
/** Tokens added back to the bucket per second. */
|
|
55
53
|
rate: number;
|
|
54
|
+
/** Cost of a single request in tokens. Defaults to 1. */
|
|
55
|
+
cost?: number;
|
|
56
56
|
}
|
|
57
57
|
interface FixedWindowConfig extends BaseAlgorithmConfig {
|
|
58
58
|
algorithm: "fixedWindow";
|
|
@@ -74,10 +74,12 @@ interface LeakyBucketConfig extends BaseAlgorithmConfig {
|
|
|
74
74
|
capacity: number;
|
|
75
75
|
/** Sustained requests allowed per second. */
|
|
76
76
|
rate: number;
|
|
77
|
+
/** Cost of a single request in emission units. Defaults to 1. */
|
|
78
|
+
cost?: number;
|
|
77
79
|
}
|
|
78
80
|
/**
|
|
79
81
|
* Discriminated union of every algorithm's config, tagged by `algorithm`.
|
|
80
|
-
* Picking `algorithm: "tokenBucket"` requires `capacity` + `
|
|
82
|
+
* Picking `algorithm: "tokenBucket"` requires `capacity` + `rate`;
|
|
81
83
|
* TypeScript rejects the wrong field set for whichever algorithm is chosen.
|
|
82
84
|
*/
|
|
83
85
|
type RateLimitAlgorithmConfig = TokenBucketConfig | FixedWindowConfig | SlidingWindowConfig | LeakyBucketConfig;
|
|
@@ -110,6 +112,12 @@ type RateLimiterConfig = RateLimitAlgorithmConfig & {
|
|
|
110
112
|
/** Creates a Hono rate-limiting middleware for the given config. */
|
|
111
113
|
declare function rateLimiter(config: RateLimiterConfig): hono.MiddlewareHandler<any, string, {}, Response>;
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Combines several rate-limit rules into one middleware.
|
|
117
|
+
* Rules run in array order; the first reject or fail-closed error wins.
|
|
118
|
+
*/
|
|
119
|
+
declare function combineRules(rules: RateLimiterConfig[]): hono.MiddlewareHandler<any, string, {}, Response>;
|
|
120
|
+
|
|
113
121
|
/**
|
|
114
122
|
* Store backend for regular Node.js servers, backed by a persistent
|
|
115
123
|
* connection via `ioredis`. Not for edge runtimes - use UpstashStore
|
|
@@ -146,4 +154,4 @@ declare class UpstashRedisStore implements RateLimitStore {
|
|
|
146
154
|
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
147
155
|
}
|
|
148
156
|
|
|
149
|
-
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, NodeRedisStore, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig, type SlidingWindowConfig, type TokenBucketConfig, UpstashRedisStore, checkFixedWindow, checkLeakyBucket, checkSlidingWindow, checkTokenBucket, rateLimiter };
|
|
157
|
+
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, NodeRedisStore, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig, type SlidingWindowConfig, type TokenBucketConfig, UpstashRedisStore, checkFixedWindow, checkLeakyBucket, checkSlidingWindow, checkTokenBucket, combineRules, rateLimiter };
|
package/dist/index.js
CHANGED
|
@@ -183,6 +183,9 @@ async function checkTokenBucket(store, identifier, config) {
|
|
|
183
183
|
};
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
// src/compose.ts
|
|
187
|
+
import { createMiddleware as createMiddleware2 } from "hono/factory";
|
|
188
|
+
|
|
186
189
|
// src/middleware.ts
|
|
187
190
|
import { createMiddleware } from "hono/factory";
|
|
188
191
|
|
|
@@ -202,7 +205,7 @@ function buildRateLimitHeaders(limit, result, style = "draft-6") {
|
|
|
202
205
|
return headers;
|
|
203
206
|
}
|
|
204
207
|
|
|
205
|
-
// src/
|
|
208
|
+
// src/run-check.ts
|
|
206
209
|
function getLimitValue(config) {
|
|
207
210
|
switch (config.algorithm) {
|
|
208
211
|
case "tokenBucket":
|
|
@@ -225,6 +228,25 @@ async function runCheck(config, identifier) {
|
|
|
225
228
|
return checkLeakyBucket(config.store, identifier, config);
|
|
226
229
|
}
|
|
227
230
|
}
|
|
231
|
+
|
|
232
|
+
// src/middleware.ts
|
|
233
|
+
async function applyRateLimitResult(c, next, config, result) {
|
|
234
|
+
const headers = buildRateLimitHeaders(
|
|
235
|
+
getLimitValue(config),
|
|
236
|
+
result,
|
|
237
|
+
config.headerStyle
|
|
238
|
+
);
|
|
239
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
240
|
+
c.header(key, value);
|
|
241
|
+
}
|
|
242
|
+
if (!result.allowed) {
|
|
243
|
+
if (config.message)
|
|
244
|
+
return config.message(c);
|
|
245
|
+
c.status(429);
|
|
246
|
+
return c.json({ error: "Too many requests" });
|
|
247
|
+
}
|
|
248
|
+
await next();
|
|
249
|
+
}
|
|
228
250
|
function rateLimiter(config) {
|
|
229
251
|
return createMiddleware(async (c, next) => {
|
|
230
252
|
const identifier = config.identifier(c);
|
|
@@ -239,21 +261,49 @@ function rateLimiter(config) {
|
|
|
239
261
|
c.status(503);
|
|
240
262
|
return c.json({ error: "Rate limiter unavailable" });
|
|
241
263
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
264
|
+
return applyRateLimitResult(c, next, config, result);
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/compose.ts
|
|
269
|
+
function pickAllowHeaders(evaluated) {
|
|
270
|
+
const withRemaining = evaluated.filter(
|
|
271
|
+
(rule) => rule.result.remaining !== void 0
|
|
272
|
+
);
|
|
273
|
+
if (withRemaining.length === 0)
|
|
274
|
+
return evaluated[evaluated.length - 1];
|
|
275
|
+
return withRemaining.reduce((best, current) => {
|
|
276
|
+
if (current.result.remaining < best.result.remaining)
|
|
277
|
+
return current;
|
|
278
|
+
return best;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
function combineRules(rules) {
|
|
282
|
+
if (rules.length === 0)
|
|
283
|
+
throw new Error("combineRules requires at least one rule");
|
|
284
|
+
return createMiddleware2(async (c, next) => {
|
|
285
|
+
const allowed = [];
|
|
286
|
+
for (const rule of rules) {
|
|
287
|
+
const identifier = rule.identifier(c);
|
|
288
|
+
let result;
|
|
289
|
+
try {
|
|
290
|
+
result = await runCheck(rule, identifier);
|
|
291
|
+
} catch {
|
|
292
|
+
if (rule.onError === "fail-open")
|
|
293
|
+
continue;
|
|
294
|
+
c.status(503);
|
|
295
|
+
return c.json({ error: "Rate limiter unavailable" });
|
|
296
|
+
}
|
|
297
|
+
if (!result.allowed)
|
|
298
|
+
return applyRateLimitResult(c, next, rule, result);
|
|
299
|
+
allowed.push({ config: rule, result });
|
|
249
300
|
}
|
|
250
|
-
if (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
c.status(429);
|
|
254
|
-
return c.json({ error: "Too many requests" });
|
|
301
|
+
if (allowed.length === 0) {
|
|
302
|
+
await next();
|
|
303
|
+
return;
|
|
255
304
|
}
|
|
256
|
-
|
|
305
|
+
const chosen = pickAllowHeaders(allowed);
|
|
306
|
+
return applyRateLimitResult(c, next, chosen.config, chosen.result);
|
|
257
307
|
});
|
|
258
308
|
}
|
|
259
309
|
|
|
@@ -304,6 +354,7 @@ export {
|
|
|
304
354
|
checkLeakyBucket,
|
|
305
355
|
checkSlidingWindow,
|
|
306
356
|
checkTokenBucket,
|
|
357
|
+
combineRules,
|
|
307
358
|
rateLimiter
|
|
308
359
|
};
|
|
309
360
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/key.ts","../src/algorithms/fixed-window.ts","../src/algorithms/leaky-bucket.ts","../src/algorithms/sliding-window.ts","../src/algorithms/token-bucket.ts","../src/middleware.ts","../src/headers.ts","../src/store/redis-store.ts","../src/store/upstash-store.ts"],"sourcesContent":["/**\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 rate = 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 * rate))\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 / rate))\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.rate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.rate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\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","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 // RateLimit-Remaining is specified as a whole-number count of requests,\n // not fractional tokens - floor it here, at the presentation layer, so\n // the underlying result (result.remaining) keeps its real precision for\n // any code that reads it directly.\n const remaining = Math.floor(result.remaining ?? 0);\n\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(remaining),\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 NodeRedisStore 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 UpstashRedisStore 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":[]}
|
|
1
|
+
{"version":3,"sources":["../src/key.ts","../src/algorithms/fixed-window.ts","../src/algorithms/leaky-bucket.ts","../src/algorithms/sliding-window.ts","../src/algorithms/token-bucket.ts","../src/compose.ts","../src/middleware.ts","../src/headers.ts","../src/run-check.ts","../src/store/redis-store.ts","../src/store/upstash-store.ts"],"sourcesContent":["/**\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 rate = 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 * rate))\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 / rate))\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.rate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.rate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\n","import { createMiddleware } from \"hono/factory\";\n\nimport type { RateLimitResult } from \"./types\";\n\nimport { applyRateLimitResult, type RateLimiterConfig } from \"./middleware\";\nimport { runCheck } from \"./run-check\";\n\ninterface EvaluatedRule {\n config: RateLimiterConfig;\n result: RateLimitResult;\n}\n\n/** Picks the allowed rule whose remaining is lowest; ties keep the earlier rule. */\nfunction pickAllowHeaders(evaluated: EvaluatedRule[]): EvaluatedRule {\n const withRemaining = evaluated.filter(\n rule => rule.result.remaining !== undefined,\n );\n\n if (withRemaining.length === 0)\n return evaluated[evaluated.length - 1];\n\n return withRemaining.reduce((best, current) => {\n if ((current.result.remaining as number) < (best.result.remaining as number))\n return current;\n return best;\n });\n}\n\n/**\n * Combines several rate-limit rules into one middleware.\n * Rules run in array order; the first reject or fail-closed error wins.\n */\nexport function combineRules(rules: RateLimiterConfig[]) {\n if (rules.length === 0)\n throw new Error(\"combineRules requires at least one rule\");\n\n return createMiddleware(async (c, next) => {\n const allowed: EvaluatedRule[] = [];\n\n for (const rule of rules) {\n const identifier = rule.identifier(c);\n let result: RateLimitResult;\n try {\n result = await runCheck(rule, identifier);\n }\n catch {\n if (rule.onError === \"fail-open\")\n continue;\n\n c.status(503);\n return c.json({ error: \"Rate limiter unavailable\" });\n }\n\n if (!result.allowed)\n return applyRateLimitResult(c, next, rule, result);\n\n allowed.push({ config: rule, result });\n }\n\n if (allowed.length === 0) {\n await next();\n return;\n }\n\n const chosen = pickAllowHeaders(allowed);\n return applyRateLimitResult(c, next, chosen.config, chosen.result);\n });\n}\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 { buildRateLimitHeaders, type HeaderStyle } from \"./headers\";\nimport { getLimitValue, runCheck } from \"./run-check\";\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/** Writes RateLimit headers and either rejects with 429 or continues. */\nexport async function applyRateLimitResult(\n c: Context,\n next: () => Promise<void>,\n config: RateLimiterConfig,\n result: RateLimitResult,\n): Promise<Response | void> {\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/** 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 return applyRateLimitResult(c, next, config, result);\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 // RateLimit-Remaining is specified as a whole-number count of requests,\n // not fractional tokens - floor it here, at the presentation layer, so\n // the underlying result (result.remaining) keeps its real precision for\n // any code that reads it directly.\n const remaining = Math.floor(result.remaining ?? 0);\n\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(remaining),\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 { 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\";\n\n/** Algorithm config plus the store the check should run against. */\nexport type RunnableConfig = RateLimitAlgorithmConfig & {\n store: RateLimitStore;\n};\n\n/** Resolves the configured limit/capacity, regardless of which algorithm is active. */\nexport function getLimitValue(config: RateLimitAlgorithmConfig): 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. */\nexport async function runCheck(\n config: RunnableConfig,\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","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 NodeRedisStore 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 UpstashRedisStore 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":["createMiddleware","createMiddleware"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "limitwall",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "1.0
|
|
4
|
+
"version": "1.1.0",
|
|
5
|
+
"packageManager": "pnpm@12.15.0",
|
|
5
6
|
"description": "Rate limiting middleware for Hono, backed by Redis. Fixed window, sliding window, token bucket, and leaky bucket (GCRA) in one package",
|
|
6
7
|
"author": "Kevin Rozario",
|
|
7
8
|
"license": "MIT",
|
|
@@ -47,6 +48,14 @@
|
|
|
47
48
|
"engines": {
|
|
48
49
|
"node": ">=20"
|
|
49
50
|
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup",
|
|
53
|
+
"dev": "tsup --watch",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"lint": "eslint .",
|
|
56
|
+
"lint:fix": "eslint . --fix",
|
|
57
|
+
"prepublishOnly": "pnpm run build"
|
|
58
|
+
},
|
|
50
59
|
"peerDependencies": {
|
|
51
60
|
"@upstash/redis": ">=1",
|
|
52
61
|
"hono": ">=4",
|
|
@@ -72,12 +81,5 @@
|
|
|
72
81
|
"tsup": "^8",
|
|
73
82
|
"typescript": "^5",
|
|
74
83
|
"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
84
|
}
|
|
83
|
-
}
|
|
85
|
+
}
|