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 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
- RedisStore: () => RedisStore,
24
- UpstashStore: () => UpstashStore,
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 refillRate = tonumber(ARGV[2])
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 * refillRate))
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 / refillRate))
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 \u2014 return as a string to preserve partial-refill precision.
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.refillRate, cost]
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.refillRate
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=${result.remaining ?? 0}, reset=${result.resetSeconds}`
225
+ RateLimit: `limit=${limit}, remaining=${remaining ?? 0}, reset=${result.resetSeconds}`
221
226
  } : {
222
227
  "RateLimit-Limit": String(limit),
223
- "RateLimit-Remaining": String(result.remaining ?? 0),
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 RedisStore = class {
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 UpstashStore = class {
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
- RedisStore,
330
- UpstashStore,
334
+ NodeRedisStore,
335
+ UpstashRedisStore,
336
+ checkFixedWindow,
337
+ checkLeakyBucket,
338
+ checkSlidingWindow,
339
+ checkTokenBucket,
331
340
  rateLimiter
332
341
  });
333
342
  //# sourceMappingURL=index.cjs.map
@@ -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
- refillRate: number;
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
- type HeaderStyle = "draft-6" | "draft-7";
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
- * The minimal contract every storage backend must satisfy.
71
- *
72
- * Deliberately thin: each algorithm owns its own atomic Lua script, so
73
- * a store's only job is "run this script for me, atomically." This is
74
- * what lets RedisStore (persistent connection) and UpstashStore (REST
75
- * over HTTP) work behind the exact same interface.
76
- */
77
- interface RateLimitStore {
78
- /**
79
- * Runs a Lua script atomically and returns its result.
80
- * @param script The raw Lua script text.
81
- * @param keys Redis KEYS[] — the keys the script will touch.
82
- * @param args Redis ARGV[] — plain values the script needs.
83
- */
84
- eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
85
- }
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 — use UpstashStore
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 RedisStore implements RateLimitStore {
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` — every call sends the full
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 UpstashStore implements RateLimitStore {
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, RedisStore, type SlidingWindowConfig, type TokenBucketConfig, UpstashStore, rateLimiter };
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
- refillRate: number;
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
- type HeaderStyle = "draft-6" | "draft-7";
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
- * The minimal contract every storage backend must satisfy.
71
- *
72
- * Deliberately thin: each algorithm owns its own atomic Lua script, so
73
- * a store's only job is "run this script for me, atomically." This is
74
- * what lets RedisStore (persistent connection) and UpstashStore (REST
75
- * over HTTP) work behind the exact same interface.
76
- */
77
- interface RateLimitStore {
78
- /**
79
- * Runs a Lua script atomically and returns its result.
80
- * @param script The raw Lua script text.
81
- * @param keys Redis KEYS[] — the keys the script will touch.
82
- * @param args Redis ARGV[] — plain values the script needs.
83
- */
84
- eval: <T>(script: string, keys: string[], args: (string | number)[]) => Promise<T>;
85
- }
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 — use UpstashStore
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 RedisStore implements RateLimitStore {
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` — every call sends the full
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 UpstashStore implements RateLimitStore {
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, RedisStore, type SlidingWindowConfig, type TokenBucketConfig, UpstashStore, rateLimiter };
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 refillRate = tonumber(ARGV[2])
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 * refillRate))
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 / refillRate))
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 \u2014 return as a string to preserve partial-refill precision.
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.refillRate, cost]
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.refillRate
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=${result.remaining ?? 0}, reset=${result.resetSeconds}`
193
+ RateLimit: `limit=${limit}, remaining=${remaining ?? 0}, reset=${result.resetSeconds}`
193
194
  } : {
194
195
  "RateLimit-Limit": String(limit),
195
- "RateLimit-Remaining": String(result.remaining ?? 0),
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 RedisStore = class {
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 UpstashStore = class {
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
- RedisStore,
301
- UpstashStore,
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": "0.1.0",
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",