limitwall 0.1.0 → 1.0.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 +31 -7
- package/dist/index.cjs +26 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -24
- package/dist/index.d.ts +36 -24
- package/dist/index.js +20 -15
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,15 +84,17 @@ export default app;
|
|
|
84
84
|
|
|
85
85
|
## Algorithms
|
|
86
86
|
|
|
87
|
-
| Algorithm | Config fields | Behavior
|
|
88
|
-
| --------------- | ------------------------ |
|
|
89
|
-
| `fixedWindow` | `limit`, `windowSeconds` | Simplest. Resets on the dot every window; allows a burst right at the boundary.
|
|
90
|
-
| `slidingWindow` | `limit`, `windowSeconds` | Blends the current and previous window's counts, avoiding the boundary-burst problem.
|
|
91
|
-
| `tokenBucket` | `capacity`, `refillRate` | Bucket refills continuously; allows saved-up bursts up to `capacity`.
|
|
92
|
-
| `leakyBucket` | `capacity`, `rate` | Implemented as GCRA. Smooths traffic into an even pace rather than allowing bursts.
|
|
87
|
+
| Algorithm | Config fields | Behavior |
|
|
88
|
+
| --------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
89
|
+
| `fixedWindow` | `limit`, `windowSeconds` | Simplest. Resets on the dot every window; allows a burst right at the boundary. |
|
|
90
|
+
| `slidingWindow` | `limit`, `windowSeconds` | Blends the current and previous window's counts, avoiding the boundary-burst problem. |
|
|
91
|
+
| `tokenBucket` | `capacity`, `refillRate` | Bucket refills continuously; allows saved-up bursts up to `capacity`. |
|
|
92
|
+
| `leakyBucket` | `capacity`, `rate` | Implemented as GCRA. Smooths traffic into an even pace rather than allowing bursts. See note below on `remaining`/`reset` values. |
|
|
93
93
|
|
|
94
94
|
Every algorithm also accepts `ruleName` (required - namespaces its Redis keys) and `cost` (optional, defaults to `1`).
|
|
95
95
|
|
|
96
|
+
> **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
|
+
|
|
96
98
|
## Config reference
|
|
97
99
|
|
|
98
100
|
```ts
|
|
@@ -117,7 +119,29 @@ On every request:
|
|
|
117
119
|
- `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` (draft-6, default), or
|
|
118
120
|
- `RateLimit: limit=..., remaining=..., reset=...` (draft-7, if configured)
|
|
119
121
|
|
|
120
|
-
On a rejected leaky-bucket request, `Retry-After` is also set, telling the client exactly how long to wait.
|
|
122
|
+
On a rejected leaky-bucket request, `Retry-After` is also set, telling the client exactly how long to wait. See the note under [Algorithms](#algorithms) - `RateLimit-Remaining`/`RateLimit-Reset` are not meaningful for `leakyBucket` outside of that rejection case.
|
|
123
|
+
|
|
124
|
+
## Using the core without Hono
|
|
125
|
+
|
|
126
|
+
`rateLimiter()` is a thin Hono wrapper around four plain async functions that have no framework dependency - only a store and an identifier string. They're exported directly for use in plain Node scripts, background jobs, or other frameworks:
|
|
127
|
+
|
|
128
|
+
```ts
|
|
129
|
+
import { checkTokenBucket, NodeRedisStore } from "limitwall";
|
|
130
|
+
|
|
131
|
+
const store = new NodeRedisStore({ client: new Redis() });
|
|
132
|
+
|
|
133
|
+
const result = await checkTokenBucket(store, "user-123", {
|
|
134
|
+
ruleName: "api",
|
|
135
|
+
capacity: 10,
|
|
136
|
+
rate: 0.5,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
if (!result.allowed) {
|
|
140
|
+
// handle rejection yourself - no Hono context required
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The other three follow the same shape: `checkFixedWindow`, `checkSlidingWindow`, `checkLeakyBucket`.
|
|
121
145
|
|
|
122
146
|
## Development
|
|
123
147
|
|
package/dist/index.cjs
CHANGED
|
@@ -20,15 +20,16 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
20
20
|
// src/index.ts
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
NodeRedisStore: () => NodeRedisStore,
|
|
24
|
+
UpstashRedisStore: () => UpstashRedisStore,
|
|
25
|
+
checkFixedWindow: () => checkFixedWindow,
|
|
26
|
+
checkLeakyBucket: () => checkLeakyBucket,
|
|
27
|
+
checkSlidingWindow: () => checkSlidingWindow,
|
|
28
|
+
checkTokenBucket: () => checkTokenBucket,
|
|
25
29
|
rateLimiter: () => rateLimiter
|
|
26
30
|
});
|
|
27
31
|
module.exports = __toCommonJS(index_exports);
|
|
28
32
|
|
|
29
|
-
// src/middleware.ts
|
|
30
|
-
var import_factory = require("hono/factory");
|
|
31
|
-
|
|
32
33
|
// src/key.ts
|
|
33
34
|
async function buildKey(ruleName, algorithm, identifier) {
|
|
34
35
|
const hashed = await hashIdentifier(identifier);
|
|
@@ -165,7 +166,7 @@ async function checkSlidingWindow(store, identifier, config) {
|
|
|
165
166
|
// src/algorithms/token-bucket.ts
|
|
166
167
|
var TOKEN_BUCKET_SCRIPT = `
|
|
167
168
|
local capacity = tonumber(ARGV[1])
|
|
168
|
-
local
|
|
169
|
+
local rate = tonumber(ARGV[2])
|
|
169
170
|
local cost = tonumber(ARGV[3])
|
|
170
171
|
|
|
171
172
|
local time = redis.call('TIME')
|
|
@@ -181,7 +182,7 @@ if tokens == nil then
|
|
|
181
182
|
end
|
|
182
183
|
|
|
183
184
|
local elapsed = now - lastRefill
|
|
184
|
-
tokens = math.min(capacity, tokens + (elapsed *
|
|
185
|
+
tokens = math.min(capacity, tokens + (elapsed * rate))
|
|
185
186
|
|
|
186
187
|
local allowed = tokens >= cost
|
|
187
188
|
if allowed then
|
|
@@ -189,10 +190,10 @@ if allowed then
|
|
|
189
190
|
end
|
|
190
191
|
|
|
191
192
|
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
|
|
192
|
-
redis.call('EXPIRE', KEYS[1], math.ceil(capacity /
|
|
193
|
+
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate))
|
|
193
194
|
|
|
194
195
|
-- Redis truncates Lua numbers to integers on return, silently dropping
|
|
195
|
-
-- the fractional part
|
|
196
|
+
-- the fractional part - return as a string to preserve partial-refill precision.
|
|
196
197
|
return { allowed and 1 or 0, tostring(tokens) }
|
|
197
198
|
`;
|
|
198
199
|
async function checkTokenBucket(store, identifier, config) {
|
|
@@ -201,11 +202,11 @@ async function checkTokenBucket(store, identifier, config) {
|
|
|
201
202
|
const [allowed, tokensRemainingRaw] = await store.eval(
|
|
202
203
|
TOKEN_BUCKET_SCRIPT,
|
|
203
204
|
[key],
|
|
204
|
-
[config.capacity, config.
|
|
205
|
+
[config.capacity, config.rate, cost]
|
|
205
206
|
);
|
|
206
207
|
const tokensRemaining = Number.parseFloat(tokensRemainingRaw);
|
|
207
208
|
const resetSeconds = Math.ceil(
|
|
208
|
-
(config.capacity - tokensRemaining) / config.
|
|
209
|
+
(config.capacity - tokensRemaining) / config.rate
|
|
209
210
|
);
|
|
210
211
|
return {
|
|
211
212
|
allowed: allowed === 1,
|
|
@@ -214,13 +215,17 @@ async function checkTokenBucket(store, identifier, config) {
|
|
|
214
215
|
};
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
// src/middleware.ts
|
|
219
|
+
var import_factory = require("hono/factory");
|
|
220
|
+
|
|
217
221
|
// src/headers.ts
|
|
218
222
|
function buildRateLimitHeaders(limit, result, style = "draft-6") {
|
|
223
|
+
const remaining = Math.floor(result.remaining ?? 0);
|
|
219
224
|
const headers = style === "draft-7" ? {
|
|
220
|
-
RateLimit: `limit=${limit}, remaining=${
|
|
225
|
+
RateLimit: `limit=${limit}, remaining=${remaining ?? 0}, reset=${result.resetSeconds}`
|
|
221
226
|
} : {
|
|
222
227
|
"RateLimit-Limit": String(limit),
|
|
223
|
-
"RateLimit-Remaining": String(
|
|
228
|
+
"RateLimit-Remaining": String(remaining),
|
|
224
229
|
"RateLimit-Reset": String(result.resetSeconds)
|
|
225
230
|
};
|
|
226
231
|
if (result.retryAfterSeconds !== void 0) {
|
|
@@ -285,7 +290,7 @@ function rateLimiter(config) {
|
|
|
285
290
|
}
|
|
286
291
|
|
|
287
292
|
// src/store/redis-store.ts
|
|
288
|
-
var
|
|
293
|
+
var NodeRedisStore = class {
|
|
289
294
|
client;
|
|
290
295
|
definedCommands = /* @__PURE__ */ new Set();
|
|
291
296
|
constructor(options) {
|
|
@@ -314,7 +319,7 @@ function hashScript(script) {
|
|
|
314
319
|
}
|
|
315
320
|
|
|
316
321
|
// src/store/upstash-store.ts
|
|
317
|
-
var
|
|
322
|
+
var UpstashRedisStore = class {
|
|
318
323
|
client;
|
|
319
324
|
constructor(options) {
|
|
320
325
|
this.client = options.client;
|
|
@@ -326,8 +331,12 @@ var UpstashStore = class {
|
|
|
326
331
|
};
|
|
327
332
|
// Annotate the CommonJS export names for ESM import in node:
|
|
328
333
|
0 && (module.exports = {
|
|
329
|
-
|
|
330
|
-
|
|
334
|
+
NodeRedisStore,
|
|
335
|
+
UpstashRedisStore,
|
|
336
|
+
checkFixedWindow,
|
|
337
|
+
checkLeakyBucket,
|
|
338
|
+
checkSlidingWindow,
|
|
339
|
+
checkTokenBucket,
|
|
331
340
|
rateLimiter
|
|
332
341
|
});
|
|
333
342
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/middleware.ts","../src/key.ts","../src/algorithms/fixed-window.ts","../src/algorithms/leaky-bucket.ts","../src/algorithms/sliding-window.ts","../src/algorithms/token-bucket.ts","../src/headers.ts","../src/store/redis-store.ts","../src/store/upstash-store.ts"],"sourcesContent":["/**\n * limitwall - rate limiting middleware for Hono, backed by Redis.\n * Supports fixed window, sliding window, token bucket, and leaky\n * bucket (GCRA) algorithms behind one config-driven middleware.\n */\n\nexport type { HeaderStyle } from \"./headers\";\nexport { rateLimiter } from \"./middleware\";\n\nexport type { RateLimiterConfig } from \"./middleware\";\nexport { RedisStore } from \"./store/redis-store\";\nexport type { RateLimitStore } from \"./store/types\";\n\nexport { UpstashStore } from \"./store/upstash-store\";\n\nexport type {\n FixedWindowConfig,\n LeakyBucketConfig,\n RateLimitAlgorithmConfig,\n RateLimitResult,\n SlidingWindowConfig,\n TokenBucketConfig,\n} from \"./types\";\n","import type { Context } from \"hono\";\n\nimport { createMiddleware } from \"hono/factory\";\n\nimport type { RateLimitStore } from \"./store/types\";\nimport type { RateLimitAlgorithmConfig, RateLimitResult } from \"./types\";\n\nimport { checkFixedWindow } from \"./algorithms/fixed-window\";\nimport { checkLeakyBucket } from \"./algorithms/leaky-bucket\";\nimport { checkSlidingWindow } from \"./algorithms/sliding-window\";\nimport { checkTokenBucket } from \"./algorithms/token-bucket\";\nimport { buildRateLimitHeaders, type HeaderStyle } from \"./headers\";\n\n/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */\nexport type RateLimiterConfig = RateLimitAlgorithmConfig & {\n /** Extracts the identifier to rate limit by (IP, user ID, API key, etc.). */\n identifier: (c: Context) => string;\n store: RateLimitStore;\n /** Required, explicit behavior when the store is unreachable — no silent default. */\n onError: \"fail-open\" | \"fail-closed\";\n headerStyle?: HeaderStyle;\n /** Overrides the default 429 JSON body. */\n message?: (c: Context) => Response;\n};\n\n/** Resolves the configured limit/capacity, regardless of which algorithm is active. */\nfunction getLimitValue(config: RateLimiterConfig): number {\n switch (config.algorithm) {\n case \"tokenBucket\":\n case \"leakyBucket\":\n return config.capacity;\n case \"fixedWindow\":\n case \"slidingWindow\":\n return config.limit;\n }\n}\n\n/** Runs the configured algorithm's check function. */\nasync function runCheck(\n config: RateLimiterConfig,\n identifier: string,\n): Promise<RateLimitResult> {\n switch (config.algorithm) {\n case \"tokenBucket\":\n return checkTokenBucket(config.store, identifier, config);\n case \"fixedWindow\":\n return checkFixedWindow(config.store, identifier, config);\n case \"slidingWindow\":\n return checkSlidingWindow(config.store, identifier, config);\n case \"leakyBucket\":\n return checkLeakyBucket(config.store, identifier, config);\n }\n}\n\n/** Creates a Hono rate-limiting middleware for the given config. */\nexport function rateLimiter(config: RateLimiterConfig) {\n return createMiddleware(async (c, next) => {\n const identifier = config.identifier(c);\n\n let result: RateLimitResult;\n try {\n result = await runCheck(config, identifier);\n }\n catch {\n // Store unreachable - do exactly what was explicitly configured.\n if (config.onError === \"fail-open\") {\n await next();\n return;\n }\n c.status(503);\n return c.json({ error: \"Rate limiter unavailable\" });\n }\n\n const headers = buildRateLimitHeaders(\n getLimitValue(config),\n result,\n config.headerStyle,\n );\n for (const [key, value] of Object.entries(headers)) {\n c.header(key, value);\n }\n\n if (!result.allowed) {\n if (config.message)\n return config.message(c);\n c.status(429);\n return c.json({ error: \"Too many requests\" });\n }\n\n await next();\n });\n}\n","/**\n * Builds the Redis key for a given rule + algorithm + identifier.\n * The identifier is always hashed before being stored, so raw IPs,\n * emails, or user IDs never sit in Redis in plain text.\n *\n * Uses the Web Crypto API (`crypto.subtle`) instead of Node's\n * `node:crypto`, since Node's module doesn't exist on edge runtimes\n * like Cloudflare Workers - Web Crypto works identically everywhere.\n */\nexport async function buildKey(\n ruleName: string,\n algorithm: string,\n identifier: string,\n): Promise<string> {\n const hashed = await hashIdentifier(identifier);\n return `ratelimit:${ruleName}:${algorithm}:${hashed}`;\n}\n\n/** SHA-256 hashes a string and returns it as lowercase hex. */\nasync function hashIdentifier(identifier: string): Promise<string> {\n const encoded = new TextEncoder().encode(identifier);\n const digest = await crypto.subtle.digest(\"SHA-256\", encoded);\n return Array.from(new Uint8Array(digest))\n .map(byte => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { FixedWindowConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Counts requests within a fixed time slot and resets when the slot's\n * TTL expires. Simplest algorithm; allows a burst right at the window\n * boundary - use sliding window if that's a problem for your use case.\n */\nconst FIXED_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal current = redis.call('INCR', KEYS[1])\nif current == 1 then\n redis.call('EXPIRE', KEYS[1], windowSeconds)\nend\n\nlocal ttl = redis.call('TTL', KEYS[1])\nlocal allowed = current <= limit\nlocal remaining = math.max(0, limit - current)\n\nreturn { allowed and 1 or 0, remaining, ttl }\n`;\n\n/** Runs a fixed window check for one identifier under one rule. */\nexport async function checkFixedWindow(\n store: RateLimitStore,\n identifier: string,\n config: FixedWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"fixedwindow\", identifier);\n\n const [allowed, remaining, ttl] = await store.eval<[number, number, number]>(\n FIXED_WINDOW_SCRIPT,\n [key],\n [config.limit, config.windowSeconds],\n );\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds: ttl,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { LeakyBucketConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Implements leaky bucket as GCRA (Generic Cell Rate Algorithm): tracks\n * one \"theoretical arrival time\" per identifier instead of a token\n * count, producing smooth, evenly-paced traffic rather than the bursty\n * traffic token bucket allows.\n */\nconst LEAKY_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal rate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal emissionInterval = 1 / rate\nlocal burstOffset = emissionInterval * capacity\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1]) + (tonumber(time[2]) / 1000000)\n\nlocal tat = tonumber(redis.call('GET', KEYS[1]))\nif tat == nil or tat < now then\n tat = now\nend\n\nlocal newTat = tat + (emissionInterval * cost)\nlocal allowAt = newTat - burstOffset\nlocal allowed = allowAt <= now\n\nif allowed then\n redis.call('SET', KEYS[1], newTat, 'EX', math.ceil(burstOffset + emissionInterval))\n return { 1, \"0\" }\nelse\n -- Redis truncates Lua numbers to integers on return, silently dropping\n -- the fractional part — return as a string to preserve sub-second precision.\n return { 0, tostring(allowAt - now) }\nend\n`;\n\n/** Runs a leaky bucket (GCRA) check for one identifier under one rule. */\nexport async function checkLeakyBucket(\n store: RateLimitStore,\n identifier: string,\n config: LeakyBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"leakybucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, waitSecondsRaw] = await store.eval<[number, string]>(\n LEAKY_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.rate, cost],\n );\n\n const waitSeconds = Number.parseFloat(waitSecondsRaw);\n const isAllowed = allowed === 1;\n\n return {\n allowed: isAllowed,\n resetSeconds: Math.ceil(waitSeconds),\n retryAfterSeconds: isAllowed ? undefined : Math.ceil(waitSeconds),\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, SlidingWindowConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Blends the current window's count with a weighted portion of the\n * previous window's count, so traffic can't double up right at a\n * fixed-window boundary.\n */\nconst SLIDING_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal currentWindow = math.floor(now / windowSeconds)\nlocal previousWindow = currentWindow - 1\n\nlocal currentKey = KEYS[1] .. ':' .. currentWindow\nlocal previousKey = KEYS[1] .. ':' .. previousWindow\n\nlocal previousCount = tonumber(redis.call('GET', previousKey)) or 0\nlocal elapsedInWindow = now % windowSeconds\nlocal weight = (windowSeconds - elapsedInWindow) / windowSeconds\n\nlocal currentCount = redis.call('INCR', currentKey)\nif currentCount == 1 then\n redis.call('EXPIRE', currentKey, windowSeconds * 2)\nend\n\nlocal estimatedTotal = (previousCount * weight) + currentCount\nlocal allowed = estimatedTotal <= limit\n\nif not allowed then\n redis.call('DECR', currentKey)\nend\n\nlocal remaining = math.max(0, math.floor(limit - estimatedTotal))\nlocal resetSeconds = math.ceil(windowSeconds - elapsedInWindow)\n\nreturn { allowed and 1 or 0, remaining, resetSeconds }\n`;\n\n/** Runs a sliding window check for one identifier under one rule. */\nexport async function checkSlidingWindow(\n store: RateLimitStore,\n identifier: string,\n config: SlidingWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"slidingwindow\", identifier);\n\n const [allowed, remaining, resetSeconds] = await store.eval<\n [number, number, number]\n >(SLIDING_WINDOW_SCRIPT, [key], [config.limit, config.windowSeconds]);\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, TokenBucketConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Lazily refills a bucket of tokens based on elapsed time, entirely\n * inside one atomic Redis operation. See the module README for the\n * plain-language walkthrough of the algorithm.\n */\nconst TOKEN_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal refillRate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')\nlocal tokens = tonumber(bucket[1])\nlocal lastRefill = tonumber(bucket[2])\n\nif tokens == nil then\n tokens = capacity\n lastRefill = now\nend\n\nlocal elapsed = now - lastRefill\ntokens = math.min(capacity, tokens + (elapsed * refillRate))\n\nlocal allowed = tokens >= cost\nif allowed then\n tokens = tokens - cost\nend\n\nredis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)\nredis.call('EXPIRE', KEYS[1], math.ceil(capacity / refillRate))\n\n-- Redis truncates Lua numbers to integers on return, silently dropping\n-- the fractional part — return as a string to preserve partial-refill precision.\nreturn { allowed and 1 or 0, tostring(tokens) }\n`;\n\n/** Runs a token bucket check for one identifier under one rule. */\nexport async function checkTokenBucket(\n store: RateLimitStore,\n identifier: string,\n config: TokenBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"tokenbucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, tokensRemainingRaw] = await store.eval<[number, string]>(\n TOKEN_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.refillRate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.refillRate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\n","import type { RateLimitResult } from \"./types\";\n\nexport type HeaderStyle = \"draft-6\" | \"draft-7\";\n\n/**\n * Builds response headers for a rate limit result, following one of the\n * two IETF RateLimit header drafts, plus the standard HTTP `Retry-After`\n * header when the result carries a `retryAfterSeconds` (leaky bucket).\n *\n * draft-6 uses three separate `RateLimit-*` headers.\n * draft-7 combines them into a single `RateLimit` header.\n */\nexport function buildRateLimitHeaders(\n limit: number,\n result: RateLimitResult,\n style: HeaderStyle = \"draft-6\",\n): Record<string, string> {\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n result.remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(result.remaining ?? 0),\n \"RateLimit-Reset\": String(result.resetSeconds),\n };\n\n if (result.retryAfterSeconds !== undefined) {\n headers[\"Retry-After\"] = String(result.retryAfterSeconds);\n }\n\n return headers;\n}\n","import type { Redis } from \"ioredis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for regular Node.js servers, backed by a persistent\n * connection via `ioredis`. Not for edge runtimes — use UpstashStore\n * there instead, since ioredis depends on Node's networking APIs.\n *\n * Registers each unique script once via `defineCommand`, so ioredis\n * automatically uses the cached `EVALSHA` form after the first call\n * instead of resending the full script text every request.\n */\nexport class RedisStore implements RateLimitStore {\n private readonly client: Redis;\n private readonly definedCommands = new Set<string>();\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const commandName = `limitwall_${hashScript(script)}`;\n\n if (!this.definedCommands.has(commandName)) {\n this.client.defineCommand(commandName, {\n numberOfKeys: keys.length,\n lua: script,\n });\n this.definedCommands.add(commandName);\n }\n\n // @ts-expect-error - commandName is registered dynamically above\n const result = await this.client[commandName](...keys, ...args.map(String));\n return result as T;\n }\n}\n\n/** Cheap, stable identifier for a script's text - not cryptographic, just needs to be unique per script. */\nfunction hashScript(script: string): string {\n let hash = 0;\n for (let i = 0; i < script.length; i++) {\n hash = (hash << 5) - hash + script.charCodeAt(i);\n hash |= 0;\n }\n return Math.abs(hash).toString(36);\n}\n","import type { Redis } from \"@upstash/redis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for edge/serverless runtimes (Cloudflare Workers, Deno,\n * Vercel Edge) via Upstash's REST API, which needs no persistent\n * connection. Use RedisStore instead on regular Node servers.\n *\n * Note: unlike ioredis, the Upstash SDK has no built-in script-caching\n * mechanism equivalent to `defineCommand` — every call sends the full\n * script text over HTTP. This is an accepted tradeoff for edge runtimes,\n * not a bug: REST-over-HTTP has per-call overhead regardless.\n */\nexport class UpstashStore implements RateLimitStore {\n private readonly client: Redis;\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const result = await this.client.eval(script, keys, args);\n return result as T;\n }\n}\n"],"mappings":"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","names":[]}
|
|
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":"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","names":[]}
|
package/dist/index.d.cts
CHANGED
|
@@ -3,6 +3,24 @@ import { Context } from 'hono';
|
|
|
3
3
|
import { Redis } from 'ioredis';
|
|
4
4
|
import { Redis as Redis$1 } from '@upstash/redis';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The minimal contract every storage backend must satisfy.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately thin: each algorithm owns its own atomic Lua script, so
|
|
10
|
+
* a store's only job is "run this script for me, atomically." This is
|
|
11
|
+
* what lets RedisStore (persistent connection) and UpstashStore (REST
|
|
12
|
+
* over HTTP) work behind the exact same interface.
|
|
13
|
+
*/
|
|
14
|
+
interface RateLimitStore {
|
|
15
|
+
/**
|
|
16
|
+
* Runs a Lua script atomically and returns its result.
|
|
17
|
+
* @param script The raw Lua script text.
|
|
18
|
+
* @param keys Redis KEYS[] — the keys the script will touch.
|
|
19
|
+
* @param args Redis ARGV[] — plain values the script needs.
|
|
20
|
+
*/
|
|
21
|
+
eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
/**
|
|
7
25
|
* Result of a single rate limit check, normalized across all algorithms.
|
|
8
26
|
*
|
|
@@ -34,7 +52,7 @@ interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
|
34
52
|
/** Maximum tokens the bucket can hold (max burst size). */
|
|
35
53
|
capacity: number;
|
|
36
54
|
/** Tokens added back to the bucket per second. */
|
|
37
|
-
|
|
55
|
+
rate: number;
|
|
38
56
|
}
|
|
39
57
|
interface FixedWindowConfig extends BaseAlgorithmConfig {
|
|
40
58
|
algorithm: "fixedWindow";
|
|
@@ -64,25 +82,19 @@ interface LeakyBucketConfig extends BaseAlgorithmConfig {
|
|
|
64
82
|
*/
|
|
65
83
|
type RateLimitAlgorithmConfig = TokenBucketConfig | FixedWindowConfig | SlidingWindowConfig | LeakyBucketConfig;
|
|
66
84
|
|
|
67
|
-
|
|
85
|
+
/** Runs a fixed window check for one identifier under one rule. */
|
|
86
|
+
declare function checkFixedWindow(store: RateLimitStore, identifier: string, config: FixedWindowConfig): Promise<RateLimitResult>;
|
|
68
87
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
* Runs a Lua script atomically and returns its result.
|
|
80
|
-
* @param script The raw Lua script text.
|
|
81
|
-
* @param keys Redis KEYS[] — the keys the script will touch.
|
|
82
|
-
* @param args Redis ARGV[] — plain values the script needs.
|
|
83
|
-
*/
|
|
84
|
-
eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
|
|
85
|
-
}
|
|
88
|
+
/** Runs a leaky bucket (GCRA) check for one identifier under one rule. */
|
|
89
|
+
declare function checkLeakyBucket(store: RateLimitStore, identifier: string, config: LeakyBucketConfig): Promise<RateLimitResult>;
|
|
90
|
+
|
|
91
|
+
/** Runs a sliding window check for one identifier under one rule. */
|
|
92
|
+
declare function checkSlidingWindow(store: RateLimitStore, identifier: string, config: SlidingWindowConfig): Promise<RateLimitResult>;
|
|
93
|
+
|
|
94
|
+
/** Runs a token bucket check for one identifier under one rule. */
|
|
95
|
+
declare function checkTokenBucket(store: RateLimitStore, identifier: string, config: TokenBucketConfig): Promise<RateLimitResult>;
|
|
96
|
+
|
|
97
|
+
type HeaderStyle = "draft-6" | "draft-7";
|
|
86
98
|
|
|
87
99
|
/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */
|
|
88
100
|
type RateLimiterConfig = RateLimitAlgorithmConfig & {
|
|
@@ -100,14 +112,14 @@ declare function rateLimiter(config: RateLimiterConfig): hono.MiddlewareHandler<
|
|
|
100
112
|
|
|
101
113
|
/**
|
|
102
114
|
* Store backend for regular Node.js servers, backed by a persistent
|
|
103
|
-
* connection via `ioredis`. Not for edge runtimes
|
|
115
|
+
* connection via `ioredis`. Not for edge runtimes - use UpstashStore
|
|
104
116
|
* there instead, since ioredis depends on Node's networking APIs.
|
|
105
117
|
*
|
|
106
118
|
* Registers each unique script once via `defineCommand`, so ioredis
|
|
107
119
|
* automatically uses the cached `EVALSHA` form after the first call
|
|
108
120
|
* instead of resending the full script text every request.
|
|
109
121
|
*/
|
|
110
|
-
declare class
|
|
122
|
+
declare class NodeRedisStore implements RateLimitStore {
|
|
111
123
|
private readonly client;
|
|
112
124
|
private readonly definedCommands;
|
|
113
125
|
constructor(options: {
|
|
@@ -122,11 +134,11 @@ declare class RedisStore implements RateLimitStore {
|
|
|
122
134
|
* connection. Use RedisStore instead on regular Node servers.
|
|
123
135
|
*
|
|
124
136
|
* Note: unlike ioredis, the Upstash SDK has no built-in script-caching
|
|
125
|
-
* mechanism equivalent to `defineCommand`
|
|
137
|
+
* mechanism equivalent to `defineCommand` - every call sends the full
|
|
126
138
|
* script text over HTTP. This is an accepted tradeoff for edge runtimes,
|
|
127
139
|
* not a bug: REST-over-HTTP has per-call overhead regardless.
|
|
128
140
|
*/
|
|
129
|
-
declare class
|
|
141
|
+
declare class UpstashRedisStore implements RateLimitStore {
|
|
130
142
|
private readonly client;
|
|
131
143
|
constructor(options: {
|
|
132
144
|
client: Redis$1;
|
|
@@ -134,4 +146,4 @@ declare class UpstashStore implements RateLimitStore {
|
|
|
134
146
|
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig,
|
|
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 };
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,24 @@ import { Context } from 'hono';
|
|
|
3
3
|
import { Redis } from 'ioredis';
|
|
4
4
|
import { Redis as Redis$1 } from '@upstash/redis';
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The minimal contract every storage backend must satisfy.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately thin: each algorithm owns its own atomic Lua script, so
|
|
10
|
+
* a store's only job is "run this script for me, atomically." This is
|
|
11
|
+
* what lets RedisStore (persistent connection) and UpstashStore (REST
|
|
12
|
+
* over HTTP) work behind the exact same interface.
|
|
13
|
+
*/
|
|
14
|
+
interface RateLimitStore {
|
|
15
|
+
/**
|
|
16
|
+
* Runs a Lua script atomically and returns its result.
|
|
17
|
+
* @param script The raw Lua script text.
|
|
18
|
+
* @param keys Redis KEYS[] — the keys the script will touch.
|
|
19
|
+
* @param args Redis ARGV[] — plain values the script needs.
|
|
20
|
+
*/
|
|
21
|
+
eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
|
|
22
|
+
}
|
|
23
|
+
|
|
6
24
|
/**
|
|
7
25
|
* Result of a single rate limit check, normalized across all algorithms.
|
|
8
26
|
*
|
|
@@ -34,7 +52,7 @@ interface TokenBucketConfig extends BaseAlgorithmConfig {
|
|
|
34
52
|
/** Maximum tokens the bucket can hold (max burst size). */
|
|
35
53
|
capacity: number;
|
|
36
54
|
/** Tokens added back to the bucket per second. */
|
|
37
|
-
|
|
55
|
+
rate: number;
|
|
38
56
|
}
|
|
39
57
|
interface FixedWindowConfig extends BaseAlgorithmConfig {
|
|
40
58
|
algorithm: "fixedWindow";
|
|
@@ -64,25 +82,19 @@ interface LeakyBucketConfig extends BaseAlgorithmConfig {
|
|
|
64
82
|
*/
|
|
65
83
|
type RateLimitAlgorithmConfig = TokenBucketConfig | FixedWindowConfig | SlidingWindowConfig | LeakyBucketConfig;
|
|
66
84
|
|
|
67
|
-
|
|
85
|
+
/** Runs a fixed window check for one identifier under one rule. */
|
|
86
|
+
declare function checkFixedWindow(store: RateLimitStore, identifier: string, config: FixedWindowConfig): Promise<RateLimitResult>;
|
|
68
87
|
|
|
69
|
-
/**
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
* Runs a Lua script atomically and returns its result.
|
|
80
|
-
* @param script The raw Lua script text.
|
|
81
|
-
* @param keys Redis KEYS[] — the keys the script will touch.
|
|
82
|
-
* @param args Redis ARGV[] — plain values the script needs.
|
|
83
|
-
*/
|
|
84
|
-
eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
|
|
85
|
-
}
|
|
88
|
+
/** Runs a leaky bucket (GCRA) check for one identifier under one rule. */
|
|
89
|
+
declare function checkLeakyBucket(store: RateLimitStore, identifier: string, config: LeakyBucketConfig): Promise<RateLimitResult>;
|
|
90
|
+
|
|
91
|
+
/** Runs a sliding window check for one identifier under one rule. */
|
|
92
|
+
declare function checkSlidingWindow(store: RateLimitStore, identifier: string, config: SlidingWindowConfig): Promise<RateLimitResult>;
|
|
93
|
+
|
|
94
|
+
/** Runs a token bucket check for one identifier under one rule. */
|
|
95
|
+
declare function checkTokenBucket(store: RateLimitStore, identifier: string, config: TokenBucketConfig): Promise<RateLimitResult>;
|
|
96
|
+
|
|
97
|
+
type HeaderStyle = "draft-6" | "draft-7";
|
|
86
98
|
|
|
87
99
|
/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */
|
|
88
100
|
type RateLimiterConfig = RateLimitAlgorithmConfig & {
|
|
@@ -100,14 +112,14 @@ declare function rateLimiter(config: RateLimiterConfig): hono.MiddlewareHandler<
|
|
|
100
112
|
|
|
101
113
|
/**
|
|
102
114
|
* Store backend for regular Node.js servers, backed by a persistent
|
|
103
|
-
* connection via `ioredis`. Not for edge runtimes
|
|
115
|
+
* connection via `ioredis`. Not for edge runtimes - use UpstashStore
|
|
104
116
|
* there instead, since ioredis depends on Node's networking APIs.
|
|
105
117
|
*
|
|
106
118
|
* Registers each unique script once via `defineCommand`, so ioredis
|
|
107
119
|
* automatically uses the cached `EVALSHA` form after the first call
|
|
108
120
|
* instead of resending the full script text every request.
|
|
109
121
|
*/
|
|
110
|
-
declare class
|
|
122
|
+
declare class NodeRedisStore implements RateLimitStore {
|
|
111
123
|
private readonly client;
|
|
112
124
|
private readonly definedCommands;
|
|
113
125
|
constructor(options: {
|
|
@@ -122,11 +134,11 @@ declare class RedisStore implements RateLimitStore {
|
|
|
122
134
|
* connection. Use RedisStore instead on regular Node servers.
|
|
123
135
|
*
|
|
124
136
|
* Note: unlike ioredis, the Upstash SDK has no built-in script-caching
|
|
125
|
-
* mechanism equivalent to `defineCommand`
|
|
137
|
+
* mechanism equivalent to `defineCommand` - every call sends the full
|
|
126
138
|
* script text over HTTP. This is an accepted tradeoff for edge runtimes,
|
|
127
139
|
* not a bug: REST-over-HTTP has per-call overhead regardless.
|
|
128
140
|
*/
|
|
129
|
-
declare class
|
|
141
|
+
declare class UpstashRedisStore implements RateLimitStore {
|
|
130
142
|
private readonly client;
|
|
131
143
|
constructor(options: {
|
|
132
144
|
client: Redis$1;
|
|
@@ -134,4 +146,4 @@ declare class UpstashStore implements RateLimitStore {
|
|
|
134
146
|
eval<T>(script: string, keys: string[], args: (string | number)[]): Promise<T>;
|
|
135
147
|
}
|
|
136
148
|
|
|
137
|
-
export { type FixedWindowConfig, type HeaderStyle, type LeakyBucketConfig, type RateLimitAlgorithmConfig, type RateLimitResult, type RateLimitStore, type RateLimiterConfig,
|
|
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 };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// src/middleware.ts
|
|
2
|
-
import { createMiddleware } from "hono/factory";
|
|
3
|
-
|
|
4
1
|
// src/key.ts
|
|
5
2
|
async function buildKey(ruleName, algorithm, identifier) {
|
|
6
3
|
const hashed = await hashIdentifier(identifier);
|
|
@@ -137,7 +134,7 @@ async function checkSlidingWindow(store, identifier, config) {
|
|
|
137
134
|
// src/algorithms/token-bucket.ts
|
|
138
135
|
var TOKEN_BUCKET_SCRIPT = `
|
|
139
136
|
local capacity = tonumber(ARGV[1])
|
|
140
|
-
local
|
|
137
|
+
local rate = tonumber(ARGV[2])
|
|
141
138
|
local cost = tonumber(ARGV[3])
|
|
142
139
|
|
|
143
140
|
local time = redis.call('TIME')
|
|
@@ -153,7 +150,7 @@ if tokens == nil then
|
|
|
153
150
|
end
|
|
154
151
|
|
|
155
152
|
local elapsed = now - lastRefill
|
|
156
|
-
tokens = math.min(capacity, tokens + (elapsed *
|
|
153
|
+
tokens = math.min(capacity, tokens + (elapsed * rate))
|
|
157
154
|
|
|
158
155
|
local allowed = tokens >= cost
|
|
159
156
|
if allowed then
|
|
@@ -161,10 +158,10 @@ if allowed then
|
|
|
161
158
|
end
|
|
162
159
|
|
|
163
160
|
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)
|
|
164
|
-
redis.call('EXPIRE', KEYS[1], math.ceil(capacity /
|
|
161
|
+
redis.call('EXPIRE', KEYS[1], math.ceil(capacity / rate))
|
|
165
162
|
|
|
166
163
|
-- Redis truncates Lua numbers to integers on return, silently dropping
|
|
167
|
-
-- the fractional part
|
|
164
|
+
-- the fractional part - return as a string to preserve partial-refill precision.
|
|
168
165
|
return { allowed and 1 or 0, tostring(tokens) }
|
|
169
166
|
`;
|
|
170
167
|
async function checkTokenBucket(store, identifier, config) {
|
|
@@ -173,11 +170,11 @@ async function checkTokenBucket(store, identifier, config) {
|
|
|
173
170
|
const [allowed, tokensRemainingRaw] = await store.eval(
|
|
174
171
|
TOKEN_BUCKET_SCRIPT,
|
|
175
172
|
[key],
|
|
176
|
-
[config.capacity, config.
|
|
173
|
+
[config.capacity, config.rate, cost]
|
|
177
174
|
);
|
|
178
175
|
const tokensRemaining = Number.parseFloat(tokensRemainingRaw);
|
|
179
176
|
const resetSeconds = Math.ceil(
|
|
180
|
-
(config.capacity - tokensRemaining) / config.
|
|
177
|
+
(config.capacity - tokensRemaining) / config.rate
|
|
181
178
|
);
|
|
182
179
|
return {
|
|
183
180
|
allowed: allowed === 1,
|
|
@@ -186,13 +183,17 @@ async function checkTokenBucket(store, identifier, config) {
|
|
|
186
183
|
};
|
|
187
184
|
}
|
|
188
185
|
|
|
186
|
+
// src/middleware.ts
|
|
187
|
+
import { createMiddleware } from "hono/factory";
|
|
188
|
+
|
|
189
189
|
// src/headers.ts
|
|
190
190
|
function buildRateLimitHeaders(limit, result, style = "draft-6") {
|
|
191
|
+
const remaining = Math.floor(result.remaining ?? 0);
|
|
191
192
|
const headers = style === "draft-7" ? {
|
|
192
|
-
RateLimit: `limit=${limit}, remaining=${
|
|
193
|
+
RateLimit: `limit=${limit}, remaining=${remaining ?? 0}, reset=${result.resetSeconds}`
|
|
193
194
|
} : {
|
|
194
195
|
"RateLimit-Limit": String(limit),
|
|
195
|
-
"RateLimit-Remaining": String(
|
|
196
|
+
"RateLimit-Remaining": String(remaining),
|
|
196
197
|
"RateLimit-Reset": String(result.resetSeconds)
|
|
197
198
|
};
|
|
198
199
|
if (result.retryAfterSeconds !== void 0) {
|
|
@@ -257,7 +258,7 @@ function rateLimiter(config) {
|
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
// src/store/redis-store.ts
|
|
260
|
-
var
|
|
261
|
+
var NodeRedisStore = class {
|
|
261
262
|
client;
|
|
262
263
|
definedCommands = /* @__PURE__ */ new Set();
|
|
263
264
|
constructor(options) {
|
|
@@ -286,7 +287,7 @@ function hashScript(script) {
|
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
// src/store/upstash-store.ts
|
|
289
|
-
var
|
|
290
|
+
var UpstashRedisStore = class {
|
|
290
291
|
client;
|
|
291
292
|
constructor(options) {
|
|
292
293
|
this.client = options.client;
|
|
@@ -297,8 +298,12 @@ var UpstashStore = class {
|
|
|
297
298
|
}
|
|
298
299
|
};
|
|
299
300
|
export {
|
|
300
|
-
|
|
301
|
-
|
|
301
|
+
NodeRedisStore,
|
|
302
|
+
UpstashRedisStore,
|
|
303
|
+
checkFixedWindow,
|
|
304
|
+
checkLeakyBucket,
|
|
305
|
+
checkSlidingWindow,
|
|
306
|
+
checkTokenBucket,
|
|
302
307
|
rateLimiter
|
|
303
308
|
};
|
|
304
309
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/middleware.ts","../src/key.ts","../src/algorithms/fixed-window.ts","../src/algorithms/leaky-bucket.ts","../src/algorithms/sliding-window.ts","../src/algorithms/token-bucket.ts","../src/headers.ts","../src/store/redis-store.ts","../src/store/upstash-store.ts"],"sourcesContent":["import type { Context } from \"hono\";\n\nimport { createMiddleware } from \"hono/factory\";\n\nimport type { RateLimitStore } from \"./store/types\";\nimport type { RateLimitAlgorithmConfig, RateLimitResult } from \"./types\";\n\nimport { checkFixedWindow } from \"./algorithms/fixed-window\";\nimport { checkLeakyBucket } from \"./algorithms/leaky-bucket\";\nimport { checkSlidingWindow } from \"./algorithms/sliding-window\";\nimport { checkTokenBucket } from \"./algorithms/token-bucket\";\nimport { buildRateLimitHeaders, type HeaderStyle } from \"./headers\";\n\n/** Full config for `rateLimiter()`: one algorithm's config, plus shared options. */\nexport type RateLimiterConfig = RateLimitAlgorithmConfig & {\n /** Extracts the identifier to rate limit by (IP, user ID, API key, etc.). */\n identifier: (c: Context) => string;\n store: RateLimitStore;\n /** Required, explicit behavior when the store is unreachable — no silent default. */\n onError: \"fail-open\" | \"fail-closed\";\n headerStyle?: HeaderStyle;\n /** Overrides the default 429 JSON body. */\n message?: (c: Context) => Response;\n};\n\n/** Resolves the configured limit/capacity, regardless of which algorithm is active. */\nfunction getLimitValue(config: RateLimiterConfig): number {\n switch (config.algorithm) {\n case \"tokenBucket\":\n case \"leakyBucket\":\n return config.capacity;\n case \"fixedWindow\":\n case \"slidingWindow\":\n return config.limit;\n }\n}\n\n/** Runs the configured algorithm's check function. */\nasync function runCheck(\n config: RateLimiterConfig,\n identifier: string,\n): Promise<RateLimitResult> {\n switch (config.algorithm) {\n case \"tokenBucket\":\n return checkTokenBucket(config.store, identifier, config);\n case \"fixedWindow\":\n return checkFixedWindow(config.store, identifier, config);\n case \"slidingWindow\":\n return checkSlidingWindow(config.store, identifier, config);\n case \"leakyBucket\":\n return checkLeakyBucket(config.store, identifier, config);\n }\n}\n\n/** Creates a Hono rate-limiting middleware for the given config. */\nexport function rateLimiter(config: RateLimiterConfig) {\n return createMiddleware(async (c, next) => {\n const identifier = config.identifier(c);\n\n let result: RateLimitResult;\n try {\n result = await runCheck(config, identifier);\n }\n catch {\n // Store unreachable - do exactly what was explicitly configured.\n if (config.onError === \"fail-open\") {\n await next();\n return;\n }\n c.status(503);\n return c.json({ error: \"Rate limiter unavailable\" });\n }\n\n const headers = buildRateLimitHeaders(\n getLimitValue(config),\n result,\n config.headerStyle,\n );\n for (const [key, value] of Object.entries(headers)) {\n c.header(key, value);\n }\n\n if (!result.allowed) {\n if (config.message)\n return config.message(c);\n c.status(429);\n return c.json({ error: \"Too many requests\" });\n }\n\n await next();\n });\n}\n","/**\n * Builds the Redis key for a given rule + algorithm + identifier.\n * The identifier is always hashed before being stored, so raw IPs,\n * emails, or user IDs never sit in Redis in plain text.\n *\n * Uses the Web Crypto API (`crypto.subtle`) instead of Node's\n * `node:crypto`, since Node's module doesn't exist on edge runtimes\n * like Cloudflare Workers - Web Crypto works identically everywhere.\n */\nexport async function buildKey(\n ruleName: string,\n algorithm: string,\n identifier: string,\n): Promise<string> {\n const hashed = await hashIdentifier(identifier);\n return `ratelimit:${ruleName}:${algorithm}:${hashed}`;\n}\n\n/** SHA-256 hashes a string and returns it as lowercase hex. */\nasync function hashIdentifier(identifier: string): Promise<string> {\n const encoded = new TextEncoder().encode(identifier);\n const digest = await crypto.subtle.digest(\"SHA-256\", encoded);\n return Array.from(new Uint8Array(digest))\n .map(byte => byte.toString(16).padStart(2, \"0\"))\n .join(\"\");\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { FixedWindowConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Counts requests within a fixed time slot and resets when the slot's\n * TTL expires. Simplest algorithm; allows a burst right at the window\n * boundary - use sliding window if that's a problem for your use case.\n */\nconst FIXED_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal current = redis.call('INCR', KEYS[1])\nif current == 1 then\n redis.call('EXPIRE', KEYS[1], windowSeconds)\nend\n\nlocal ttl = redis.call('TTL', KEYS[1])\nlocal allowed = current <= limit\nlocal remaining = math.max(0, limit - current)\n\nreturn { allowed and 1 or 0, remaining, ttl }\n`;\n\n/** Runs a fixed window check for one identifier under one rule. */\nexport async function checkFixedWindow(\n store: RateLimitStore,\n identifier: string,\n config: FixedWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"fixedwindow\", identifier);\n\n const [allowed, remaining, ttl] = await store.eval<[number, number, number]>(\n FIXED_WINDOW_SCRIPT,\n [key],\n [config.limit, config.windowSeconds],\n );\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds: ttl,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { LeakyBucketConfig, RateLimitResult } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Implements leaky bucket as GCRA (Generic Cell Rate Algorithm): tracks\n * one \"theoretical arrival time\" per identifier instead of a token\n * count, producing smooth, evenly-paced traffic rather than the bursty\n * traffic token bucket allows.\n */\nconst LEAKY_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal rate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal emissionInterval = 1 / rate\nlocal burstOffset = emissionInterval * capacity\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1]) + (tonumber(time[2]) / 1000000)\n\nlocal tat = tonumber(redis.call('GET', KEYS[1]))\nif tat == nil or tat < now then\n tat = now\nend\n\nlocal newTat = tat + (emissionInterval * cost)\nlocal allowAt = newTat - burstOffset\nlocal allowed = allowAt <= now\n\nif allowed then\n redis.call('SET', KEYS[1], newTat, 'EX', math.ceil(burstOffset + emissionInterval))\n return { 1, \"0\" }\nelse\n -- Redis truncates Lua numbers to integers on return, silently dropping\n -- the fractional part — return as a string to preserve sub-second precision.\n return { 0, tostring(allowAt - now) }\nend\n`;\n\n/** Runs a leaky bucket (GCRA) check for one identifier under one rule. */\nexport async function checkLeakyBucket(\n store: RateLimitStore,\n identifier: string,\n config: LeakyBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"leakybucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, waitSecondsRaw] = await store.eval<[number, string]>(\n LEAKY_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.rate, cost],\n );\n\n const waitSeconds = Number.parseFloat(waitSecondsRaw);\n const isAllowed = allowed === 1;\n\n return {\n allowed: isAllowed,\n resetSeconds: Math.ceil(waitSeconds),\n retryAfterSeconds: isAllowed ? undefined : Math.ceil(waitSeconds),\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, SlidingWindowConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Blends the current window's count with a weighted portion of the\n * previous window's count, so traffic can't double up right at a\n * fixed-window boundary.\n */\nconst SLIDING_WINDOW_SCRIPT = `\nlocal limit = tonumber(ARGV[1])\nlocal windowSeconds = tonumber(ARGV[2])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal currentWindow = math.floor(now / windowSeconds)\nlocal previousWindow = currentWindow - 1\n\nlocal currentKey = KEYS[1] .. ':' .. currentWindow\nlocal previousKey = KEYS[1] .. ':' .. previousWindow\n\nlocal previousCount = tonumber(redis.call('GET', previousKey)) or 0\nlocal elapsedInWindow = now % windowSeconds\nlocal weight = (windowSeconds - elapsedInWindow) / windowSeconds\n\nlocal currentCount = redis.call('INCR', currentKey)\nif currentCount == 1 then\n redis.call('EXPIRE', currentKey, windowSeconds * 2)\nend\n\nlocal estimatedTotal = (previousCount * weight) + currentCount\nlocal allowed = estimatedTotal <= limit\n\nif not allowed then\n redis.call('DECR', currentKey)\nend\n\nlocal remaining = math.max(0, math.floor(limit - estimatedTotal))\nlocal resetSeconds = math.ceil(windowSeconds - elapsedInWindow)\n\nreturn { allowed and 1 or 0, remaining, resetSeconds }\n`;\n\n/** Runs a sliding window check for one identifier under one rule. */\nexport async function checkSlidingWindow(\n store: RateLimitStore,\n identifier: string,\n config: SlidingWindowConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"slidingwindow\", identifier);\n\n const [allowed, remaining, resetSeconds] = await store.eval<\n [number, number, number]\n >(SLIDING_WINDOW_SCRIPT, [key], [config.limit, config.windowSeconds]);\n\n return {\n allowed: allowed === 1,\n remaining,\n resetSeconds,\n };\n}\n","import type { RateLimitStore } from \"../store/types\";\nimport type { RateLimitResult, TokenBucketConfig } from \"../types\";\n\nimport { buildKey } from \"../key\";\n\n/**\n * Lazily refills a bucket of tokens based on elapsed time, entirely\n * inside one atomic Redis operation. See the module README for the\n * plain-language walkthrough of the algorithm.\n */\nconst TOKEN_BUCKET_SCRIPT = `\nlocal capacity = tonumber(ARGV[1])\nlocal refillRate = tonumber(ARGV[2])\nlocal cost = tonumber(ARGV[3])\n\nlocal time = redis.call('TIME')\nlocal now = tonumber(time[1])\n\nlocal bucket = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')\nlocal tokens = tonumber(bucket[1])\nlocal lastRefill = tonumber(bucket[2])\n\nif tokens == nil then\n tokens = capacity\n lastRefill = now\nend\n\nlocal elapsed = now - lastRefill\ntokens = math.min(capacity, tokens + (elapsed * refillRate))\n\nlocal allowed = tokens >= cost\nif allowed then\n tokens = tokens - cost\nend\n\nredis.call('HMSET', KEYS[1], 'tokens', tokens, 'lastRefill', now)\nredis.call('EXPIRE', KEYS[1], math.ceil(capacity / refillRate))\n\n-- Redis truncates Lua numbers to integers on return, silently dropping\n-- the fractional part — return as a string to preserve partial-refill precision.\nreturn { allowed and 1 or 0, tostring(tokens) }\n`;\n\n/** Runs a token bucket check for one identifier under one rule. */\nexport async function checkTokenBucket(\n store: RateLimitStore,\n identifier: string,\n config: TokenBucketConfig,\n): Promise<RateLimitResult> {\n const key = await buildKey(config.ruleName, \"tokenbucket\", identifier);\n const cost = config.cost ?? 1;\n\n const [allowed, tokensRemainingRaw] = await store.eval<[number, string]>(\n TOKEN_BUCKET_SCRIPT,\n [key],\n [config.capacity, config.refillRate, cost],\n );\n\n const tokensRemaining = Number.parseFloat(tokensRemainingRaw);\n\n const resetSeconds = Math.ceil(\n (config.capacity - tokensRemaining) / config.refillRate,\n );\n\n return {\n allowed: allowed === 1,\n remaining: tokensRemaining,\n resetSeconds: Math.max(0, resetSeconds),\n };\n}\n","import type { RateLimitResult } from \"./types\";\n\nexport type HeaderStyle = \"draft-6\" | \"draft-7\";\n\n/**\n * Builds response headers for a rate limit result, following one of the\n * two IETF RateLimit header drafts, plus the standard HTTP `Retry-After`\n * header when the result carries a `retryAfterSeconds` (leaky bucket).\n *\n * draft-6 uses three separate `RateLimit-*` headers.\n * draft-7 combines them into a single `RateLimit` header.\n */\nexport function buildRateLimitHeaders(\n limit: number,\n result: RateLimitResult,\n style: HeaderStyle = \"draft-6\",\n): Record<string, string> {\n const headers: Record<string, string>\n = style === \"draft-7\"\n ? {\n RateLimit: `limit=${limit}, remaining=${\n result.remaining ?? 0\n }, reset=${result.resetSeconds}`,\n }\n : {\n \"RateLimit-Limit\": String(limit),\n \"RateLimit-Remaining\": String(result.remaining ?? 0),\n \"RateLimit-Reset\": String(result.resetSeconds),\n };\n\n if (result.retryAfterSeconds !== undefined) {\n headers[\"Retry-After\"] = String(result.retryAfterSeconds);\n }\n\n return headers;\n}\n","import type { Redis } from \"ioredis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for regular Node.js servers, backed by a persistent\n * connection via `ioredis`. Not for edge runtimes — use UpstashStore\n * there instead, since ioredis depends on Node's networking APIs.\n *\n * Registers each unique script once via `defineCommand`, so ioredis\n * automatically uses the cached `EVALSHA` form after the first call\n * instead of resending the full script text every request.\n */\nexport class RedisStore implements RateLimitStore {\n private readonly client: Redis;\n private readonly definedCommands = new Set<string>();\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const commandName = `limitwall_${hashScript(script)}`;\n\n if (!this.definedCommands.has(commandName)) {\n this.client.defineCommand(commandName, {\n numberOfKeys: keys.length,\n lua: script,\n });\n this.definedCommands.add(commandName);\n }\n\n // @ts-expect-error - commandName is registered dynamically above\n const result = await this.client[commandName](...keys, ...args.map(String));\n return result as T;\n }\n}\n\n/** Cheap, stable identifier for a script's text - not cryptographic, just needs to be unique per script. */\nfunction hashScript(script: string): string {\n let hash = 0;\n for (let i = 0; i < script.length; i++) {\n hash = (hash << 5) - hash + script.charCodeAt(i);\n hash |= 0;\n }\n return Math.abs(hash).toString(36);\n}\n","import type { Redis } from \"@upstash/redis\";\n\nimport type { RateLimitStore } from \"./types\";\n\n/**\n * Store backend for edge/serverless runtimes (Cloudflare Workers, Deno,\n * Vercel Edge) via Upstash's REST API, which needs no persistent\n * connection. Use RedisStore instead on regular Node servers.\n *\n * Note: unlike ioredis, the Upstash SDK has no built-in script-caching\n * mechanism equivalent to `defineCommand` — every call sends the full\n * script text over HTTP. This is an accepted tradeoff for edge runtimes,\n * not a bug: REST-over-HTTP has per-call overhead regardless.\n */\nexport class UpstashStore implements RateLimitStore {\n private readonly client: Redis;\n\n constructor(options: { client: Redis }) {\n this.client = options.client;\n }\n\n async eval<T>(\n script: string,\n keys: string[],\n args: (string | number)[],\n ): Promise<T> {\n const result = await this.client.eval(script, keys, args);\n return result as T;\n }\n}\n"],"mappings":"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","names":[]}
|
|
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":[]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "limitwall",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "1.0.0",
|
|
5
5
|
"description": "Rate limiting middleware for Hono, backed by Redis. Fixed window, sliding window, token bucket, and leaky bucket (GCRA) in one package",
|
|
6
6
|
"author": "Kevin Rozario",
|
|
7
7
|
"license": "MIT",
|