@jellyfungus/hono-rate-limiter 0.3.0 → 0.4.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 +51 -0
- package/dist/index.cjs +128 -17
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +26 -1
- package/dist/index.d.ts +26 -1
- package/dist/index.js +126 -16
- package/dist/index.js.map +1 -1
- package/dist/websocket.cjs.map +1 -1
- package/dist/websocket.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -55,23 +55,30 @@ var MemoryStore = class {
|
|
|
55
55
|
}
|
|
56
56
|
};
|
|
57
57
|
var defaultStore;
|
|
58
|
+
function shutdownDefaultStore() {
|
|
59
|
+
if (defaultStore) {
|
|
60
|
+
defaultStore.shutdown();
|
|
61
|
+
defaultStore = void 0;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
58
64
|
function setHeaders(c, info, format, windowMs, identifier, quotaUnit) {
|
|
59
65
|
if (format === false) {
|
|
60
66
|
return;
|
|
61
67
|
}
|
|
62
68
|
const windowSeconds = Math.ceil(windowMs / 1e3);
|
|
63
69
|
const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1e3));
|
|
70
|
+
const safeId = sanitizeIdentifier(identifier);
|
|
64
71
|
switch (format) {
|
|
65
72
|
case "standard":
|
|
66
73
|
{
|
|
67
|
-
let policy = `"${
|
|
74
|
+
let policy = `"${safeId}";q=${info.limit};w=${windowSeconds}`;
|
|
68
75
|
if (quotaUnit !== "requests") {
|
|
69
76
|
policy += `;qu="${quotaUnit}"`;
|
|
70
77
|
}
|
|
71
78
|
c.header("RateLimit-Policy", policy);
|
|
72
79
|
c.header(
|
|
73
80
|
"RateLimit",
|
|
74
|
-
`"${
|
|
81
|
+
`"${safeId}";r=${info.remaining};t=${resetSeconds}`
|
|
75
82
|
);
|
|
76
83
|
}
|
|
77
84
|
break;
|
|
@@ -96,6 +103,16 @@ function setHeaders(c, info, format, windowMs, identifier, quotaUnit) {
|
|
|
96
103
|
break;
|
|
97
104
|
}
|
|
98
105
|
}
|
|
106
|
+
function sanitizeIdentifier(id) {
|
|
107
|
+
if (!id || typeof id !== "string") {
|
|
108
|
+
return "default";
|
|
109
|
+
}
|
|
110
|
+
const sanitized = id.replace(/[^a-zA-Z0-9_\-.:*/]/g, "-");
|
|
111
|
+
if (!sanitized || !/^[a-zA-Z]/.test(sanitized)) {
|
|
112
|
+
return "default";
|
|
113
|
+
}
|
|
114
|
+
return sanitized;
|
|
115
|
+
}
|
|
99
116
|
function getClientIP(c) {
|
|
100
117
|
const cfIP = c.req.header("cf-connecting-ip");
|
|
101
118
|
if (cfIP) {
|
|
@@ -111,14 +128,59 @@ function getClientIP(c) {
|
|
|
111
128
|
}
|
|
112
129
|
return "unknown";
|
|
113
130
|
}
|
|
114
|
-
function
|
|
115
|
-
const
|
|
131
|
+
function buildRateLimitHeaders(info, format, windowMs, identifier, quotaUnit) {
|
|
132
|
+
const headers = {
|
|
133
|
+
"Content-Type": "text/plain",
|
|
134
|
+
"Retry-After": String(
|
|
135
|
+
Math.max(0, Math.ceil((info.reset - Date.now()) / 1e3))
|
|
136
|
+
)
|
|
137
|
+
};
|
|
138
|
+
if (format === false) {
|
|
139
|
+
return headers;
|
|
140
|
+
}
|
|
141
|
+
const windowSeconds = Math.ceil(windowMs / 1e3);
|
|
142
|
+
const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1e3));
|
|
143
|
+
const safeId = sanitizeIdentifier(identifier);
|
|
144
|
+
switch (format) {
|
|
145
|
+
case "standard": {
|
|
146
|
+
let policy = `"${safeId}";q=${info.limit};w=${windowSeconds}`;
|
|
147
|
+
if (quotaUnit !== "requests") {
|
|
148
|
+
policy += `;qu="${quotaUnit}"`;
|
|
149
|
+
}
|
|
150
|
+
headers["RateLimit-Policy"] = policy;
|
|
151
|
+
headers["RateLimit"] = `"${safeId}";r=${info.remaining};t=${resetSeconds}`;
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
case "draft-7":
|
|
155
|
+
headers["RateLimit-Policy"] = `${info.limit};w=${windowSeconds}`;
|
|
156
|
+
headers["RateLimit"] = `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`;
|
|
157
|
+
break;
|
|
158
|
+
case "draft-6":
|
|
159
|
+
headers["RateLimit-Policy"] = `${info.limit};w=${windowSeconds}`;
|
|
160
|
+
headers["RateLimit-Limit"] = String(info.limit);
|
|
161
|
+
headers["RateLimit-Remaining"] = String(info.remaining);
|
|
162
|
+
headers["RateLimit-Reset"] = String(resetSeconds);
|
|
163
|
+
break;
|
|
164
|
+
case "legacy":
|
|
165
|
+
default:
|
|
166
|
+
headers["X-RateLimit-Limit"] = String(info.limit);
|
|
167
|
+
headers["X-RateLimit-Remaining"] = String(info.remaining);
|
|
168
|
+
headers["X-RateLimit-Reset"] = String(Math.ceil(info.reset / 1e3));
|
|
169
|
+
break;
|
|
170
|
+
}
|
|
171
|
+
return headers;
|
|
172
|
+
}
|
|
173
|
+
function createDefaultResponse(info, format, windowMs, identifier, quotaUnit) {
|
|
174
|
+
const headers = buildRateLimitHeaders(
|
|
175
|
+
info,
|
|
176
|
+
format,
|
|
177
|
+
windowMs,
|
|
178
|
+
identifier,
|
|
179
|
+
quotaUnit
|
|
180
|
+
);
|
|
116
181
|
return new Response("Rate limit exceeded", {
|
|
117
182
|
status: 429,
|
|
118
|
-
headers
|
|
119
|
-
"Content-Type": "text/plain",
|
|
120
|
-
"Retry-After": String(retryAfter)
|
|
121
|
-
}
|
|
183
|
+
headers
|
|
122
184
|
});
|
|
123
185
|
}
|
|
124
186
|
async function checkSlidingWindow(store, key, limit, windowMs) {
|
|
@@ -171,14 +233,37 @@ var rateLimiter = (options) => {
|
|
|
171
233
|
skipSuccessfulRequests: false,
|
|
172
234
|
skipFailedRequests: false,
|
|
173
235
|
onRateLimited: void 0,
|
|
236
|
+
onStoreError: "allow",
|
|
174
237
|
...options
|
|
175
238
|
};
|
|
176
239
|
const store = opts.store ?? (defaultStore ??= new MemoryStore());
|
|
177
|
-
let
|
|
240
|
+
let initPromise = null;
|
|
241
|
+
async function handleStoreError(error, c) {
|
|
242
|
+
if (typeof opts.onStoreError === "function") {
|
|
243
|
+
return opts.onStoreError(error, c);
|
|
244
|
+
}
|
|
245
|
+
return opts.onStoreError === "allow";
|
|
246
|
+
}
|
|
178
247
|
return async function rateLimiter2(c, next) {
|
|
179
|
-
if (!
|
|
180
|
-
|
|
181
|
-
|
|
248
|
+
if (!initPromise && store.init) {
|
|
249
|
+
const result = store.init(opts.windowMs);
|
|
250
|
+
initPromise = result instanceof Promise ? result : Promise.resolve();
|
|
251
|
+
}
|
|
252
|
+
if (initPromise) {
|
|
253
|
+
try {
|
|
254
|
+
await initPromise;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
const shouldAllow = await handleStoreError(
|
|
257
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
258
|
+
c
|
|
259
|
+
);
|
|
260
|
+
if (shouldAllow) {
|
|
261
|
+
return next();
|
|
262
|
+
}
|
|
263
|
+
return new Response("Rate limiter initialization failed", {
|
|
264
|
+
status: 500
|
|
265
|
+
});
|
|
266
|
+
}
|
|
182
267
|
}
|
|
183
268
|
if (opts.skip) {
|
|
184
269
|
const shouldSkip = await opts.skip(c);
|
|
@@ -188,7 +273,22 @@ var rateLimiter = (options) => {
|
|
|
188
273
|
}
|
|
189
274
|
const key = await opts.keyGenerator(c);
|
|
190
275
|
const limit = typeof opts.limit === "function" ? await opts.limit(c) : opts.limit;
|
|
191
|
-
|
|
276
|
+
let allowed;
|
|
277
|
+
let info;
|
|
278
|
+
try {
|
|
279
|
+
const result = opts.algorithm === "sliding-window" ? await checkSlidingWindow(store, key, limit, opts.windowMs) : await checkFixedWindow(store, key, limit, opts.windowMs);
|
|
280
|
+
allowed = result.allowed;
|
|
281
|
+
info = result.info;
|
|
282
|
+
} catch (error) {
|
|
283
|
+
const shouldAllow = await handleStoreError(
|
|
284
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
285
|
+
c
|
|
286
|
+
);
|
|
287
|
+
if (shouldAllow) {
|
|
288
|
+
return next();
|
|
289
|
+
}
|
|
290
|
+
return new Response("Rate limiter error", { status: 500 });
|
|
291
|
+
}
|
|
192
292
|
c.set("rateLimit", info);
|
|
193
293
|
c.set("rateLimitStore", {
|
|
194
294
|
getKey: store.get?.bind(store) ?? (() => void 0),
|
|
@@ -209,7 +309,13 @@ var rateLimiter = (options) => {
|
|
|
209
309
|
if (opts.handler) {
|
|
210
310
|
return opts.handler(c, info);
|
|
211
311
|
}
|
|
212
|
-
return createDefaultResponse(
|
|
312
|
+
return createDefaultResponse(
|
|
313
|
+
info,
|
|
314
|
+
opts.headers,
|
|
315
|
+
opts.windowMs,
|
|
316
|
+
opts.identifier,
|
|
317
|
+
opts.quotaUnit
|
|
318
|
+
);
|
|
213
319
|
}
|
|
214
320
|
await next();
|
|
215
321
|
if (opts.skipSuccessfulRequests || opts.skipFailedRequests) {
|
|
@@ -218,7 +324,10 @@ var rateLimiter = (options) => {
|
|
|
218
324
|
if (shouldDecrement && store.decrement) {
|
|
219
325
|
const windowStart = Math.floor(Date.now() / opts.windowMs) * opts.windowMs;
|
|
220
326
|
const windowKey = `${key}:${windowStart}`;
|
|
221
|
-
|
|
327
|
+
try {
|
|
328
|
+
await store.decrement(windowKey);
|
|
329
|
+
} catch {
|
|
330
|
+
}
|
|
222
331
|
}
|
|
223
332
|
}
|
|
224
333
|
};
|
|
@@ -251,6 +360,7 @@ export {
|
|
|
251
360
|
MemoryStore,
|
|
252
361
|
cloudflareRateLimiter,
|
|
253
362
|
getClientIP,
|
|
254
|
-
rateLimiter
|
|
363
|
+
rateLimiter,
|
|
364
|
+
shutdownDefaultStore
|
|
255
365
|
};
|
|
256
366
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @module\n * Rate Limit Middleware for Hono.\n */\n\nimport type { Context, Env, MiddlewareHandler } from \"hono\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Rate limit information for a single request\n */\nexport type RateLimitInfo = {\n /** Maximum requests allowed in window */\n limit: number;\n /** Remaining requests in current window */\n remaining: number;\n /** Unix timestamp (ms) when window resets */\n reset: number;\n};\n\n/**\n * Result from store increment operation\n */\nexport type StoreResult = {\n /** Current request count in window */\n count: number;\n /** When the window resets (Unix timestamp ms) */\n reset: number;\n};\n\n/**\n * Quota unit for IETF standard headers.\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type QuotaUnit = \"requests\" | \"content-bytes\" | \"concurrent-requests\";\n\n/**\n * Header format options.\n *\n * ## \"legacy\" (default)\n * Common X-RateLimit-* headers used by GitHub, Twitter, and most APIs:\n * - `X-RateLimit-Limit`: max requests in window\n * - `X-RateLimit-Remaining`: remaining requests\n * - `X-RateLimit-Reset`: Unix timestamp (seconds) when window resets\n *\n * ## \"draft-6\"\n * IETF draft-06 format with individual RateLimit-* headers:\n * - `RateLimit-Policy`: policy description (e.g., `100;w=60`)\n * - `RateLimit-Limit`: max requests\n * - `RateLimit-Remaining`: remaining requests\n * - `RateLimit-Reset`: seconds until reset\n *\n * ## \"draft-7\"\n * IETF draft-07 format with combined RateLimit header:\n * - `RateLimit-Policy`: policy description\n * - `RateLimit`: combined (e.g., `limit=100, remaining=50, reset=30`)\n *\n * ## \"standard\"\n * Current IETF draft-08+ format with structured field values (RFC 9651):\n * - `RateLimit-Policy`: `\"name\";q=100;w=60`\n * - `RateLimit`: `\"name\";r=50;t=30`\n *\n * ## false\n * Disable all rate limit headers.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type HeadersFormat =\n | \"legacy\" // X-RateLimit-* headers (GitHub/Twitter style)\n | \"draft-6\" // IETF draft-06: individual RateLimit-* headers\n | \"draft-7\" // IETF draft-07: combined RateLimit header\n | \"standard\" // IETF draft-08+: structured field format (current)\n | false; // Disable headers\n\n/**\n * Rate limit algorithm\n */\nexport type Algorithm = \"fixed-window\" | \"sliding-window\";\n\n/**\n * Store interface for rate limit state\n */\nexport type RateLimitStore = {\n /**\n * Initialize store. Called once before first use.\n */\n init?: (windowMs: number) => void | Promise<void>;\n\n /**\n * Increment counter for key and return current state.\n */\n increment: (key: string) => StoreResult | Promise<StoreResult>;\n\n /**\n * Decrement counter for key.\n */\n decrement?: (key: string) => void | Promise<void>;\n\n /**\n * Reset a specific key.\n */\n resetKey: (key: string) => void | Promise<void>;\n\n /**\n * Reset all keys.\n */\n resetAll?: () => void | Promise<void>;\n\n /**\n * Get current state for key.\n */\n get?: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n\n /**\n * Graceful shutdown.\n */\n shutdown?: () => void | Promise<void>;\n};\n\n/**\n * Store access interface exposed in context\n */\nexport type RateLimitStoreAccess = {\n /** Get rate limit info for a key */\n getKey: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n /** Reset rate limit for a key */\n resetKey: (key: string) => void | Promise<void>;\n};\n\n/**\n * Options for rate limit middleware\n */\nexport type RateLimitOptions<E extends Env = Env> = {\n /**\n * Maximum requests allowed in the time window.\n * @default 60\n */\n limit?: number | ((c: Context<E>) => number | Promise<number>);\n\n /**\n * Time window in milliseconds.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Rate limiting algorithm.\n * @default 'sliding-window'\n */\n algorithm?: Algorithm;\n\n /**\n * Storage backend for rate limit state.\n * @default MemoryStore\n */\n store?: RateLimitStore;\n\n /**\n * Generate unique key for each client.\n * @default IP address from headers\n */\n keyGenerator?: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (\n c: Context<E>,\n info: RateLimitInfo,\n ) => Response | Promise<Response>;\n\n /**\n * HTTP header format to use.\n *\n * - \"legacy\": X-RateLimit-* headers (GitHub/Twitter style, default)\n * - \"draft-6\": IETF draft-06 individual headers\n * - \"draft-7\": IETF draft-07 combined header\n * - \"standard\": IETF draft-08+ structured fields (current spec)\n * - false: Disable headers\n *\n * @default 'legacy'\n */\n headers?: HeadersFormat;\n\n /**\n * Policy identifier for IETF headers (draft-6+).\n * Used in RateLimit and RateLimit-Policy headers.\n * @default 'default'\n */\n identifier?: string;\n\n /**\n * Quota unit for IETF standard headers.\n * Only included in \"standard\" format when not \"requests\".\n * @default 'requests'\n */\n quotaUnit?: QuotaUnit;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n\n /**\n * Don't count successful (2xx) requests against limit.\n * @default false\n */\n skipSuccessfulRequests?: boolean;\n\n /**\n * Don't count failed (4xx, 5xx) requests against limit.\n * @default false\n */\n skipFailedRequests?: boolean;\n\n /**\n * Callback when a request is rate limited.\n */\n onRateLimited?: (c: Context<E>, info: RateLimitInfo) => void | Promise<void>;\n};\n\n/**\n * Cloudflare Rate Limiting binding interface\n */\nexport type RateLimitBinding = {\n limit: (options: { key: string }) => Promise<{ success: boolean }>;\n};\n\n/**\n * Options for Cloudflare Rate Limiting binding\n */\nexport type CloudflareRateLimitOptions<E extends Env = Env> = {\n /**\n * Cloudflare Rate Limiting binding from env\n */\n binding: RateLimitBinding | ((c: Context<E>) => RateLimitBinding);\n\n /**\n * Generate unique key for each client.\n */\n keyGenerator: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (c: Context<E>) => Response | Promise<Response>;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n};\n\n// ============================================================================\n// Context Variable Type Extension\n// ============================================================================\n\ndeclare module \"hono\" {\n interface ContextVariableMap {\n rateLimit?: RateLimitInfo;\n rateLimitStore?: RateLimitStoreAccess;\n }\n}\n\n// ============================================================================\n// Memory Store\n// ============================================================================\n\ntype MemoryEntry = {\n count: number;\n reset: number;\n};\n\n/**\n * In-memory store for rate limiting.\n * Suitable for single-instance deployments.\n */\nexport class MemoryStore implements RateLimitStore {\n private entries = new Map<string, MemoryEntry>();\n private windowMs = 60_000;\n private cleanupTimer?: ReturnType<typeof setInterval>;\n\n init(windowMs: number): void {\n this.windowMs = windowMs;\n\n // Cleanup expired entries every minute\n this.cleanupTimer = setInterval(() => {\n const now = Date.now();\n for (const [key, entry] of this.entries) {\n if (entry.reset <= now) {\n this.entries.delete(key);\n }\n }\n }, 60_000);\n\n // Don't keep process alive for cleanup\n if (typeof this.cleanupTimer.unref === \"function\") {\n this.cleanupTimer.unref();\n }\n }\n\n increment(key: string): StoreResult {\n const now = Date.now();\n const existing = this.entries.get(key);\n\n if (!existing || existing.reset <= now) {\n // New window\n const reset = now + this.windowMs;\n this.entries.set(key, { count: 1, reset });\n return { count: 1, reset };\n }\n\n // Increment existing\n existing.count++;\n return { count: existing.count, reset: existing.reset };\n }\n\n get(key: string): StoreResult | undefined {\n const entry = this.entries.get(key);\n if (!entry || entry.reset <= Date.now()) {\n return undefined;\n }\n return { count: entry.count, reset: entry.reset };\n }\n\n decrement(key: string): void {\n const entry = this.entries.get(key);\n if (entry && entry.count > 0) {\n entry.count--;\n }\n }\n\n resetKey(key: string): void {\n this.entries.delete(key);\n }\n\n resetAll(): void {\n this.entries.clear();\n }\n\n shutdown(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer);\n }\n this.entries.clear();\n }\n}\n\n// Singleton default store\nlet defaultStore: MemoryStore | undefined;\n\n// ============================================================================\n// Header Generation\n// ============================================================================\n\n/**\n * Set rate limit response headers based on the configured format.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nfunction setHeaders(\n c: Context,\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): void {\n if (format === false) {\n return;\n }\n\n const windowSeconds = Math.ceil(windowMs / 1000);\n const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n\n switch (format) {\n case \"standard\":\n // IETF draft-08+ (current): Structured field values per RFC 9651\n // RateLimit-Policy: describes the quota policy\n // Format: \"name\";q=<quota>;w=<window>[;qu=\"<unit>\"]\n {\n let policy = `\"${identifier}\";q=${info.limit};w=${windowSeconds}`;\n if (quotaUnit !== \"requests\") {\n policy += `;qu=\"${quotaUnit}\"`;\n }\n c.header(\"RateLimit-Policy\", policy);\n // RateLimit: describes current service limits\n // Format: \"name\";r=<remaining>;t=<reset>\n c.header(\n \"RateLimit\",\n `\"${identifier}\";r=${info.remaining};t=${resetSeconds}`,\n );\n }\n break;\n\n case \"draft-7\":\n // IETF draft-07: Combined RateLimit header with comma-separated values\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\n \"RateLimit\",\n `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`,\n );\n break;\n\n case \"draft-6\":\n // IETF draft-06: Individual RateLimit-* headers\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\"RateLimit-Limit\", String(info.limit));\n c.header(\"RateLimit-Remaining\", String(info.remaining));\n c.header(\"RateLimit-Reset\", String(resetSeconds));\n break;\n\n case \"legacy\":\n default:\n // Common X-RateLimit-* headers (GitHub, Twitter, most APIs)\n // Uses Unix timestamp for reset (seconds since epoch)\n c.header(\"X-RateLimit-Limit\", String(info.limit));\n c.header(\"X-RateLimit-Remaining\", String(info.remaining));\n c.header(\"X-RateLimit-Reset\", String(Math.ceil(info.reset / 1000)));\n break;\n }\n}\n\n// ============================================================================\n// Default Key Generator\n// ============================================================================\n\nfunction getClientIP(c: Context): string {\n // Platform-specific headers (most reliable)\n const cfIP = c.req.header(\"cf-connecting-ip\");\n if (cfIP) {\n return cfIP;\n }\n\n const xRealIP = c.req.header(\"x-real-ip\");\n if (xRealIP) {\n return xRealIP;\n }\n\n // X-Forwarded-For - take first IP\n const xff = c.req.header(\"x-forwarded-for\");\n if (xff) {\n return xff.split(\",\")[0].trim();\n }\n\n return \"unknown\";\n}\n\n// ============================================================================\n// Default Handler\n// ============================================================================\n\nfunction createDefaultResponse(info: RateLimitInfo): Response {\n const retryAfter = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers: {\n \"Content-Type\": \"text/plain\",\n \"Retry-After\": String(retryAfter),\n },\n });\n}\n\n// ============================================================================\n// Sliding Window Algorithm\n// ============================================================================\n\nasync function checkSlidingWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const currentWindowStart = Math.floor(now / windowMs) * windowMs;\n const previousWindowStart = currentWindowStart - windowMs;\n\n const previousKey = `${key}:${previousWindowStart}`;\n const currentKey = `${key}:${currentWindowStart}`;\n\n // Increment current window\n const current = await store.increment(currentKey);\n\n // Get previous window (may not exist)\n let previousCount = 0;\n if (store.get) {\n const prev = await store.get(previousKey);\n previousCount = prev?.count ?? 0;\n }\n\n // Cloudflare's weighted formula\n const elapsedMs = now - currentWindowStart;\n const weight = (windowMs - elapsedMs) / windowMs;\n const estimatedCount = Math.floor(previousCount * weight) + current.count;\n\n const remaining = Math.max(0, limit - estimatedCount);\n const allowed = estimatedCount <= limit;\n const reset = currentWindowStart + windowMs;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Fixed Window Algorithm\n// ============================================================================\n\nasync function checkFixedWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const windowStart = Math.floor(now / windowMs) * windowMs;\n const windowKey = `${key}:${windowStart}`;\n\n const { count, reset } = await store.increment(windowKey);\n\n const remaining = Math.max(0, limit - count);\n const allowed = count <= limit;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Main Middleware\n// ============================================================================\n\n/**\n * Rate Limit Middleware for Hono.\n *\n * @param {RateLimitOptions} [options] - Configuration options\n * @returns {MiddlewareHandler} Middleware handler\n *\n * @example\n * ```ts\n * import { Hono } from 'hono'\n * import { rateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * const app = new Hono()\n *\n * // Basic usage - 60 requests per minute\n * app.use(rateLimiter())\n *\n * // Custom configuration\n * app.use('/api/*', rateLimiter({\n * limit: 100,\n * windowMs: 60 * 1000,\n * }))\n * ```\n */\nexport const rateLimiter = <E extends Env = Env>(\n options?: RateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n // Merge with defaults\n const opts = {\n limit: 60 as number | ((c: Context<E>) => number | Promise<number>),\n windowMs: 60_000,\n algorithm: \"sliding-window\" as Algorithm,\n store: undefined as RateLimitStore | undefined,\n keyGenerator: getClientIP as (c: Context<E>) => string | Promise<string>,\n handler: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => Response | Promise<Response>)\n | undefined,\n headers: \"legacy\" as HeadersFormat,\n identifier: \"default\",\n quotaUnit: \"requests\" as QuotaUnit,\n skip: undefined as\n | ((c: Context<E>) => boolean | Promise<boolean>)\n | undefined,\n skipSuccessfulRequests: false,\n skipFailedRequests: false,\n onRateLimited: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => void | Promise<void>)\n | undefined,\n ...options,\n };\n\n // Use default store if none provided\n const store = opts.store ?? (defaultStore ??= new MemoryStore());\n\n // Track initialization\n let initialized = false;\n\n return async function rateLimiter(c, next) {\n // Initialize store on first request\n if (!initialized && store.init) {\n await store.init(opts.windowMs);\n initialized = true;\n }\n\n // Check if should skip\n if (opts.skip) {\n const shouldSkip = await opts.skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Generate key\n const key = await opts.keyGenerator(c);\n\n // Get limit (may be dynamic)\n const limit =\n typeof opts.limit === \"function\" ? await opts.limit(c) : opts.limit;\n\n // Check rate limit\n const { allowed, info } =\n opts.algorithm === \"sliding-window\"\n ? await checkSlidingWindow(store, key, limit, opts.windowMs)\n : await checkFixedWindow(store, key, limit, opts.windowMs);\n\n // Set context variable for downstream middleware\n c.set(\"rateLimit\", info);\n\n // Expose store access in context\n c.set(\"rateLimitStore\", {\n getKey: store.get?.bind(store) ?? (() => undefined),\n resetKey: store.resetKey.bind(store),\n });\n\n // Set headers\n setHeaders(\n c,\n info,\n opts.headers,\n opts.windowMs,\n opts.identifier,\n opts.quotaUnit,\n );\n\n // Handle rate limited\n if (!allowed) {\n // Fire callback\n if (opts.onRateLimited) {\n await opts.onRateLimited(c, info);\n }\n\n // Custom handler or default\n if (opts.handler) {\n return opts.handler(c, info);\n }\n return createDefaultResponse(info);\n }\n\n // Continue\n await next();\n\n // Handle skip options after response\n if (opts.skipSuccessfulRequests || opts.skipFailedRequests) {\n const status = c.res.status;\n const shouldDecrement =\n (opts.skipSuccessfulRequests && status >= 200 && status < 300) ||\n (opts.skipFailedRequests && status >= 400);\n\n if (shouldDecrement && store.decrement) {\n const windowStart =\n Math.floor(Date.now() / opts.windowMs) * opts.windowMs;\n const windowKey = `${key}:${windowStart}`;\n await store.decrement(windowKey);\n }\n }\n };\n};\n\n// ============================================================================\n// Cloudflare Rate Limiting Binding Middleware\n// ============================================================================\n\n/**\n * Rate limiter using Cloudflare's built-in Rate Limiting binding.\n *\n * This uses Cloudflare's globally distributed rate limiting infrastructure,\n * which is ideal for high-traffic applications.\n *\n * @example\n * ```ts\n * import { cloudflareRateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * type Bindings = { RATE_LIMITER: RateLimitBinding }\n *\n * const app = new Hono<{ Bindings: Bindings }>()\n *\n * app.use(cloudflareRateLimiter({\n * binding: (c) => c.env.RATE_LIMITER,\n * keyGenerator: (c) => c.req.header('cf-connecting-ip') ?? 'unknown',\n * }))\n * ```\n */\nexport const cloudflareRateLimiter = <E extends Env = Env>(\n options: CloudflareRateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n const { binding, keyGenerator, handler, skip } = options;\n\n return async function cloudflareRateLimiter(c, next) {\n // Check if should skip\n if (skip) {\n const shouldSkip = await skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Get binding (may be dynamic)\n const rateLimitBinding =\n typeof binding === \"function\" ? binding(c) : binding;\n\n // Generate key\n const key = await keyGenerator(c);\n\n // Check rate limit\n const { success } = await rateLimitBinding.limit({ key });\n\n if (!success) {\n if (handler) {\n return handler(c);\n }\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers: { \"Content-Type\": \"text/plain\" },\n });\n }\n\n return next();\n };\n};\n\n// ============================================================================\n// Exports\n// ============================================================================\n\nexport { getClientIP };\n"],"mappings":"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","names":["rateLimiter","cloudflareRateLimiter"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * @module\n * Rate Limit Middleware for Hono.\n */\n\nimport type { Context, Env, MiddlewareHandler } from \"hono\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Rate limit information for a single request\n */\nexport type RateLimitInfo = {\n /** Maximum requests allowed in window */\n limit: number;\n /** Remaining requests in current window */\n remaining: number;\n /** Unix timestamp (ms) when window resets */\n reset: number;\n};\n\n/**\n * Result from store increment operation\n */\nexport type StoreResult = {\n /** Current request count in window */\n count: number;\n /** When the window resets (Unix timestamp ms) */\n reset: number;\n};\n\n/**\n * Quota unit for IETF standard headers.\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type QuotaUnit = \"requests\" | \"content-bytes\" | \"concurrent-requests\";\n\n/**\n * Header format options.\n *\n * ## \"legacy\" (default)\n * Common X-RateLimit-* headers used by GitHub, Twitter, and most APIs:\n * - `X-RateLimit-Limit`: max requests in window\n * - `X-RateLimit-Remaining`: remaining requests\n * - `X-RateLimit-Reset`: Unix timestamp (seconds) when window resets\n *\n * ## \"draft-6\"\n * IETF draft-06 format with individual RateLimit-* headers:\n * - `RateLimit-Policy`: policy description (e.g., `100;w=60`)\n * - `RateLimit-Limit`: max requests\n * - `RateLimit-Remaining`: remaining requests\n * - `RateLimit-Reset`: seconds until reset\n *\n * ## \"draft-7\"\n * IETF draft-07 format with combined RateLimit header:\n * - `RateLimit-Policy`: policy description\n * - `RateLimit`: combined (e.g., `limit=100, remaining=50, reset=30`)\n *\n * ## \"standard\"\n * Current IETF draft-08+ format with structured field values (RFC 9651):\n * - `RateLimit-Policy`: `\"name\";q=100;w=60`\n * - `RateLimit`: `\"name\";r=50;t=30`\n *\n * ## false\n * Disable all rate limit headers.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type HeadersFormat =\n | \"legacy\" // X-RateLimit-* headers (GitHub/Twitter style)\n | \"draft-6\" // IETF draft-06: individual RateLimit-* headers\n | \"draft-7\" // IETF draft-07: combined RateLimit header\n | \"standard\" // IETF draft-08+: structured field format (current)\n | false; // Disable headers\n\n/**\n * Rate limit algorithm\n */\nexport type Algorithm = \"fixed-window\" | \"sliding-window\";\n\n/**\n * Store interface for rate limit state\n */\nexport type RateLimitStore = {\n /**\n * Initialize store. Called once before first use.\n */\n init?: (windowMs: number) => void | Promise<void>;\n\n /**\n * Increment counter for key and return current state.\n */\n increment: (key: string) => StoreResult | Promise<StoreResult>;\n\n /**\n * Decrement counter for key.\n */\n decrement?: (key: string) => void | Promise<void>;\n\n /**\n * Reset a specific key.\n */\n resetKey: (key: string) => void | Promise<void>;\n\n /**\n * Reset all keys.\n */\n resetAll?: () => void | Promise<void>;\n\n /**\n * Get current state for key.\n */\n get?: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n\n /**\n * Graceful shutdown.\n */\n shutdown?: () => void | Promise<void>;\n};\n\n/**\n * Store access interface exposed in context\n */\nexport type RateLimitStoreAccess = {\n /** Get rate limit info for a key */\n getKey: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n /** Reset rate limit for a key */\n resetKey: (key: string) => void | Promise<void>;\n};\n\n/**\n * Options for rate limit middleware\n */\nexport type RateLimitOptions<E extends Env = Env> = {\n /**\n * Maximum requests allowed in the time window.\n * @default 60\n */\n limit?: number | ((c: Context<E>) => number | Promise<number>);\n\n /**\n * Time window in milliseconds.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Rate limiting algorithm.\n * @default 'sliding-window'\n */\n algorithm?: Algorithm;\n\n /**\n * Storage backend for rate limit state.\n * @default MemoryStore\n */\n store?: RateLimitStore;\n\n /**\n * Generate unique key for each client.\n * @default IP address from headers\n */\n keyGenerator?: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (\n c: Context<E>,\n info: RateLimitInfo,\n ) => Response | Promise<Response>;\n\n /**\n * HTTP header format to use.\n *\n * - \"legacy\": X-RateLimit-* headers (GitHub/Twitter style, default)\n * - \"draft-6\": IETF draft-06 individual headers\n * - \"draft-7\": IETF draft-07 combined header\n * - \"standard\": IETF draft-08+ structured fields (current spec)\n * - false: Disable headers\n *\n * @default 'legacy'\n */\n headers?: HeadersFormat;\n\n /**\n * Policy identifier for IETF headers (draft-6+).\n * Used in RateLimit and RateLimit-Policy headers.\n * @default 'default'\n */\n identifier?: string;\n\n /**\n * Quota unit for IETF standard headers.\n * Only included in \"standard\" format when not \"requests\".\n * @default 'requests'\n */\n quotaUnit?: QuotaUnit;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n\n /**\n * Don't count successful (2xx) requests against limit.\n * @default false\n */\n skipSuccessfulRequests?: boolean;\n\n /**\n * Don't count failed (4xx, 5xx) requests against limit.\n * @default false\n */\n skipFailedRequests?: boolean;\n\n /**\n * Callback when a request is rate limited.\n */\n onRateLimited?: (c: Context<E>, info: RateLimitInfo) => void | Promise<void>;\n\n /**\n * Behavior when store operations fail.\n *\n * - 'allow': Allow the request through (fail-open, default)\n * - 'deny': Block the request with 500 error (fail-closed)\n * - Function: Custom handler returning true to allow, false to deny\n *\n * @default 'allow'\n */\n onStoreError?:\n | \"allow\"\n | \"deny\"\n | ((error: Error, c: Context<E>) => boolean | Promise<boolean>);\n};\n\n/**\n * Cloudflare Rate Limiting binding interface\n */\nexport type RateLimitBinding = {\n limit: (options: { key: string }) => Promise<{ success: boolean }>;\n};\n\n/**\n * Options for Cloudflare Rate Limiting binding\n */\nexport type CloudflareRateLimitOptions<E extends Env = Env> = {\n /**\n * Cloudflare Rate Limiting binding from env\n */\n binding: RateLimitBinding | ((c: Context<E>) => RateLimitBinding);\n\n /**\n * Generate unique key for each client.\n */\n keyGenerator: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (c: Context<E>) => Response | Promise<Response>;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n};\n\n// ============================================================================\n// Context Variable Type Extension\n// ============================================================================\n\ndeclare module \"hono\" {\n interface ContextVariableMap {\n rateLimit?: RateLimitInfo;\n rateLimitStore?: RateLimitStoreAccess;\n }\n}\n\n// ============================================================================\n// Memory Store\n// ============================================================================\n\ntype MemoryEntry = {\n count: number;\n reset: number;\n};\n\n/**\n * In-memory store for rate limiting.\n * Suitable for single-instance deployments.\n */\nexport class MemoryStore implements RateLimitStore {\n private entries = new Map<string, MemoryEntry>();\n private windowMs = 60_000;\n private cleanupTimer?: ReturnType<typeof setInterval>;\n\n init(windowMs: number): void {\n this.windowMs = windowMs;\n\n // Cleanup expired entries every minute\n this.cleanupTimer = setInterval(() => {\n const now = Date.now();\n for (const [key, entry] of this.entries) {\n if (entry.reset <= now) {\n this.entries.delete(key);\n }\n }\n }, 60_000);\n\n // Don't keep process alive for cleanup\n if (typeof this.cleanupTimer.unref === \"function\") {\n this.cleanupTimer.unref();\n }\n }\n\n increment(key: string): StoreResult {\n const now = Date.now();\n const existing = this.entries.get(key);\n\n if (!existing || existing.reset <= now) {\n // New window\n const reset = now + this.windowMs;\n this.entries.set(key, { count: 1, reset });\n return { count: 1, reset };\n }\n\n // Increment existing\n existing.count++;\n return { count: existing.count, reset: existing.reset };\n }\n\n get(key: string): StoreResult | undefined {\n const entry = this.entries.get(key);\n if (!entry || entry.reset <= Date.now()) {\n return undefined;\n }\n return { count: entry.count, reset: entry.reset };\n }\n\n decrement(key: string): void {\n const entry = this.entries.get(key);\n if (entry && entry.count > 0) {\n entry.count--;\n }\n }\n\n resetKey(key: string): void {\n this.entries.delete(key);\n }\n\n resetAll(): void {\n this.entries.clear();\n }\n\n shutdown(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer);\n }\n this.entries.clear();\n }\n}\n\n// Singleton default store\nlet defaultStore: MemoryStore | undefined;\n\n/**\n * Shutdown the default memory store.\n * Call this during graceful shutdown to clean up timers.\n *\n * @example\n * ```ts\n * import { shutdownDefaultStore } from '@jellyfungus/hono-rate-limiter'\n *\n * process.on('SIGTERM', () => {\n * shutdownDefaultStore()\n * process.exit(0)\n * })\n * ```\n */\nexport function shutdownDefaultStore(): void {\n if (defaultStore) {\n defaultStore.shutdown();\n defaultStore = undefined;\n }\n}\n\n// ============================================================================\n// Header Generation\n// ============================================================================\n\n/**\n * Set rate limit response headers based on the configured format.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nfunction setHeaders(\n c: Context,\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): void {\n if (format === false) {\n return;\n }\n\n const windowSeconds = Math.ceil(windowMs / 1000);\n const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n const safeId = sanitizeIdentifier(identifier);\n\n switch (format) {\n case \"standard\":\n // IETF draft-08+ (current): Structured field values per RFC 9651\n // RateLimit-Policy: describes the quota policy\n // Format: \"name\";q=<quota>;w=<window>[;qu=\"<unit>\"]\n {\n let policy = `\"${safeId}\";q=${info.limit};w=${windowSeconds}`;\n if (quotaUnit !== \"requests\") {\n policy += `;qu=\"${quotaUnit}\"`;\n }\n c.header(\"RateLimit-Policy\", policy);\n // RateLimit: describes current service limits\n // Format: \"name\";r=<remaining>;t=<reset>\n c.header(\n \"RateLimit\",\n `\"${safeId}\";r=${info.remaining};t=${resetSeconds}`,\n );\n }\n break;\n\n case \"draft-7\":\n // IETF draft-07: Combined RateLimit header with comma-separated values\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\n \"RateLimit\",\n `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`,\n );\n break;\n\n case \"draft-6\":\n // IETF draft-06: Individual RateLimit-* headers\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\"RateLimit-Limit\", String(info.limit));\n c.header(\"RateLimit-Remaining\", String(info.remaining));\n c.header(\"RateLimit-Reset\", String(resetSeconds));\n break;\n\n case \"legacy\":\n default:\n // Common X-RateLimit-* headers (GitHub, Twitter, most APIs)\n // Uses Unix timestamp for reset (seconds since epoch)\n c.header(\"X-RateLimit-Limit\", String(info.limit));\n c.header(\"X-RateLimit-Remaining\", String(info.remaining));\n c.header(\"X-RateLimit-Reset\", String(Math.ceil(info.reset / 1000)));\n break;\n }\n}\n\n// ============================================================================\n// Identifier Sanitization\n// ============================================================================\n\n/**\n * Sanitize identifier for RFC 9651 structured field compliance.\n * Identifiers are used in the RateLimit and RateLimit-Policy headers.\n */\nfunction sanitizeIdentifier(id: string): string {\n if (!id || typeof id !== \"string\") {\n return \"default\";\n }\n // RFC 9651 tokens: Only allow alphanumeric, underscore, hyphen, dot, colon, asterisk, slash\n // Must start with a letter\n const sanitized = id.replace(/[^a-zA-Z0-9_\\-.:*/]/g, \"-\");\n if (!sanitized || !/^[a-zA-Z]/.test(sanitized)) {\n return \"default\";\n }\n return sanitized;\n}\n\n// ============================================================================\n// Default Key Generator\n// ============================================================================\n\nfunction getClientIP(c: Context): string {\n // Platform-specific headers (most reliable)\n const cfIP = c.req.header(\"cf-connecting-ip\");\n if (cfIP) {\n return cfIP;\n }\n\n const xRealIP = c.req.header(\"x-real-ip\");\n if (xRealIP) {\n return xRealIP;\n }\n\n // X-Forwarded-For - take first IP\n const xff = c.req.header(\"x-forwarded-for\");\n if (xff) {\n return xff.split(\",\")[0].trim();\n }\n\n return \"unknown\";\n}\n\n// ============================================================================\n// Default Handler\n// ============================================================================\n\n/**\n * Build headers object for rate limit responses.\n * This ensures headers are included in both custom and default responses.\n */\nfunction buildRateLimitHeaders(\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): Record<string, string> {\n const headers: Record<string, string> = {\n \"Content-Type\": \"text/plain\",\n \"Retry-After\": String(\n Math.max(0, Math.ceil((info.reset - Date.now()) / 1000)),\n ),\n };\n\n if (format === false) {\n return headers;\n }\n\n const windowSeconds = Math.ceil(windowMs / 1000);\n const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n const safeId = sanitizeIdentifier(identifier);\n\n switch (format) {\n case \"standard\": {\n let policy = `\"${safeId}\";q=${info.limit};w=${windowSeconds}`;\n if (quotaUnit !== \"requests\") {\n policy += `;qu=\"${quotaUnit}\"`;\n }\n headers[\"RateLimit-Policy\"] = policy;\n headers[\"RateLimit\"] =\n `\"${safeId}\";r=${info.remaining};t=${resetSeconds}`;\n break;\n }\n case \"draft-7\":\n headers[\"RateLimit-Policy\"] = `${info.limit};w=${windowSeconds}`;\n headers[\"RateLimit\"] =\n `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`;\n break;\n case \"draft-6\":\n headers[\"RateLimit-Policy\"] = `${info.limit};w=${windowSeconds}`;\n headers[\"RateLimit-Limit\"] = String(info.limit);\n headers[\"RateLimit-Remaining\"] = String(info.remaining);\n headers[\"RateLimit-Reset\"] = String(resetSeconds);\n break;\n case \"legacy\":\n default:\n headers[\"X-RateLimit-Limit\"] = String(info.limit);\n headers[\"X-RateLimit-Remaining\"] = String(info.remaining);\n headers[\"X-RateLimit-Reset\"] = String(Math.ceil(info.reset / 1000));\n break;\n }\n\n return headers;\n}\n\nfunction createDefaultResponse(\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): Response {\n const headers = buildRateLimitHeaders(\n info,\n format,\n windowMs,\n identifier,\n quotaUnit,\n );\n\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers,\n });\n}\n\n// ============================================================================\n// Sliding Window Algorithm\n// ============================================================================\n\nasync function checkSlidingWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const currentWindowStart = Math.floor(now / windowMs) * windowMs;\n const previousWindowStart = currentWindowStart - windowMs;\n\n const previousKey = `${key}:${previousWindowStart}`;\n const currentKey = `${key}:${currentWindowStart}`;\n\n // Increment current window\n const current = await store.increment(currentKey);\n\n // Get previous window (may not exist)\n let previousCount = 0;\n if (store.get) {\n const prev = await store.get(previousKey);\n previousCount = prev?.count ?? 0;\n }\n\n // Cloudflare's weighted formula\n const elapsedMs = now - currentWindowStart;\n const weight = (windowMs - elapsedMs) / windowMs;\n const estimatedCount = Math.floor(previousCount * weight) + current.count;\n\n const remaining = Math.max(0, limit - estimatedCount);\n const allowed = estimatedCount <= limit;\n const reset = currentWindowStart + windowMs;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Fixed Window Algorithm\n// ============================================================================\n\nasync function checkFixedWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const windowStart = Math.floor(now / windowMs) * windowMs;\n const windowKey = `${key}:${windowStart}`;\n\n const { count, reset } = await store.increment(windowKey);\n\n const remaining = Math.max(0, limit - count);\n const allowed = count <= limit;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Main Middleware\n// ============================================================================\n\n/**\n * Rate Limit Middleware for Hono.\n *\n * @param {RateLimitOptions} [options] - Configuration options\n * @returns {MiddlewareHandler} Middleware handler\n *\n * @example\n * ```ts\n * import { Hono } from 'hono'\n * import { rateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * const app = new Hono()\n *\n * // Basic usage - 60 requests per minute\n * app.use(rateLimiter())\n *\n * // Custom configuration\n * app.use('/api/*', rateLimiter({\n * limit: 100,\n * windowMs: 60 * 1000,\n * }))\n * ```\n */\nexport const rateLimiter = <E extends Env = Env>(\n options?: RateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n // Merge with defaults\n const opts = {\n limit: 60 as number | ((c: Context<E>) => number | Promise<number>),\n windowMs: 60_000,\n algorithm: \"sliding-window\" as Algorithm,\n store: undefined as RateLimitStore | undefined,\n keyGenerator: getClientIP as (c: Context<E>) => string | Promise<string>,\n handler: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => Response | Promise<Response>)\n | undefined,\n headers: \"legacy\" as HeadersFormat,\n identifier: \"default\",\n quotaUnit: \"requests\" as QuotaUnit,\n skip: undefined as\n | ((c: Context<E>) => boolean | Promise<boolean>)\n | undefined,\n skipSuccessfulRequests: false,\n skipFailedRequests: false,\n onRateLimited: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => void | Promise<void>)\n | undefined,\n onStoreError: \"allow\" as\n | \"allow\"\n | \"deny\"\n | ((error: Error, c: Context<E>) => boolean | Promise<boolean>),\n ...options,\n };\n\n // Use default store if none provided\n const store = opts.store ?? (defaultStore ??= new MemoryStore());\n\n // Track initialization\n let initPromise: Promise<void> | null = null;\n\n /**\n * Handle store errors based on configuration.\n * @returns true to allow request, false to deny\n */\n async function handleStoreError(\n error: Error,\n c: Context<E>,\n ): Promise<boolean> {\n if (typeof opts.onStoreError === \"function\") {\n return opts.onStoreError(error, c);\n }\n // Default: fail-open (allow request through)\n return opts.onStoreError === \"allow\";\n }\n\n return async function rateLimiter(c, next) {\n // Initialize store on first request (with proper locking)\n if (!initPromise && store.init) {\n const result = store.init(opts.windowMs);\n // Handle both sync and async init\n initPromise = result instanceof Promise ? result : Promise.resolve();\n }\n if (initPromise) {\n try {\n await initPromise;\n } catch (error) {\n const shouldAllow = await handleStoreError(\n error instanceof Error ? error : new Error(String(error)),\n c,\n );\n if (shouldAllow) {\n return next();\n }\n return new Response(\"Rate limiter initialization failed\", {\n status: 500,\n });\n }\n }\n\n // Check if should skip\n if (opts.skip) {\n const shouldSkip = await opts.skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Generate key\n const key = await opts.keyGenerator(c);\n\n // Get limit (may be dynamic)\n const limit =\n typeof opts.limit === \"function\" ? await opts.limit(c) : opts.limit;\n\n // Check rate limit with error handling\n let allowed: boolean;\n let info: RateLimitInfo;\n\n try {\n const result =\n opts.algorithm === \"sliding-window\"\n ? await checkSlidingWindow(store, key, limit, opts.windowMs)\n : await checkFixedWindow(store, key, limit, opts.windowMs);\n allowed = result.allowed;\n info = result.info;\n } catch (error) {\n const shouldAllow = await handleStoreError(\n error instanceof Error ? error : new Error(String(error)),\n c,\n );\n if (shouldAllow) {\n return next();\n }\n return new Response(\"Rate limiter error\", { status: 500 });\n }\n\n // Set context variable for downstream middleware\n c.set(\"rateLimit\", info);\n\n // Expose store access in context\n c.set(\"rateLimitStore\", {\n getKey: store.get?.bind(store) ?? (() => undefined),\n resetKey: store.resetKey.bind(store),\n });\n\n // Set headers\n setHeaders(\n c,\n info,\n opts.headers,\n opts.windowMs,\n opts.identifier,\n opts.quotaUnit,\n );\n\n // Handle rate limited\n if (!allowed) {\n // Fire callback\n if (opts.onRateLimited) {\n await opts.onRateLimited(c, info);\n }\n\n // Custom handler or default\n if (opts.handler) {\n return opts.handler(c, info);\n }\n return createDefaultResponse(\n info,\n opts.headers,\n opts.windowMs,\n opts.identifier,\n opts.quotaUnit,\n );\n }\n\n // Continue\n await next();\n\n // Handle skip options after response\n if (opts.skipSuccessfulRequests || opts.skipFailedRequests) {\n const status = c.res.status;\n const shouldDecrement =\n (opts.skipSuccessfulRequests && status >= 200 && status < 300) ||\n (opts.skipFailedRequests && status >= 400);\n\n if (shouldDecrement && store.decrement) {\n const windowStart =\n Math.floor(Date.now() / opts.windowMs) * opts.windowMs;\n const windowKey = `${key}:${windowStart}`;\n try {\n await store.decrement(windowKey);\n } catch {\n // Ignore decrement errors - request already processed\n }\n }\n }\n };\n};\n\n// ============================================================================\n// Cloudflare Rate Limiting Binding Middleware\n// ============================================================================\n\n/**\n * Rate limiter using Cloudflare's built-in Rate Limiting binding.\n *\n * This uses Cloudflare's globally distributed rate limiting infrastructure,\n * which is ideal for high-traffic applications.\n *\n * @example\n * ```ts\n * import { cloudflareRateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * type Bindings = { RATE_LIMITER: RateLimitBinding }\n *\n * const app = new Hono<{ Bindings: Bindings }>()\n *\n * app.use(cloudflareRateLimiter({\n * binding: (c) => c.env.RATE_LIMITER,\n * keyGenerator: (c) => c.req.header('cf-connecting-ip') ?? 'unknown',\n * }))\n * ```\n */\nexport const cloudflareRateLimiter = <E extends Env = Env>(\n options: CloudflareRateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n const { binding, keyGenerator, handler, skip } = options;\n\n return async function cloudflareRateLimiter(c, next) {\n // Check if should skip\n if (skip) {\n const shouldSkip = await skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Get binding (may be dynamic)\n const rateLimitBinding =\n typeof binding === \"function\" ? binding(c) : binding;\n\n // Generate key\n const key = await keyGenerator(c);\n\n // Check rate limit\n const { success } = await rateLimitBinding.limit({ key });\n\n if (!success) {\n if (handler) {\n return handler(c);\n }\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers: { \"Content-Type\": \"text/plain\" },\n });\n }\n\n return next();\n };\n};\n\n// ============================================================================\n// Exports\n// ============================================================================\n\nexport { getClientIP };\n"],"mappings":"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","names":["rateLimiter","cloudflareRateLimiter"]}
|
package/dist/websocket.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/websocket.ts","../src/index.ts"],"sourcesContent":["/**\n * @module\n * WebSocket Rate Limiting for Hono.\n */\n\nimport type { Context, Env } from \"hono\";\nimport type { WSContext, WSEvents } from \"hono/ws\";\nimport {\n MemoryStore,\n type RateLimitStore,\n type RateLimitInfo,\n type StoreResult,\n type Algorithm,\n} from \"./index\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Options for WebSocket rate limiting\n */\nexport type WSRateLimitOptions<E extends Env = Env> = {\n /**\n * Maximum messages allowed in the time window.\n * @default 100\n */\n limit?: number | ((c: Context<E>) => number | Promise<number>);\n\n /**\n * Time window in milliseconds.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Rate limiting algorithm.\n * @default 'sliding-window'\n */\n algorithm?: Algorithm;\n\n /**\n * Storage backend for rate limit state.\n * @default MemoryStore\n */\n store?: RateLimitStore;\n\n /**\n * Generate unique key for each client.\n * Required for WebSocket rate limiting.\n */\n keyGenerator: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n * Default: closes connection with code 1008.\n */\n handler?: (ws: WSContext, info: RateLimitInfo) => void;\n\n /**\n * Skip rate limiting for certain messages.\n */\n skip?: (event: MessageEvent, ws: WSContext) => boolean | Promise<boolean>;\n};\n\n// ============================================================================\n// Sliding Window Algorithm (adapted for WS)\n// ============================================================================\n\nasync function checkSlidingWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const currentWindowStart = Math.floor(now / windowMs) * windowMs;\n const previousWindowStart = currentWindowStart - windowMs;\n\n const previousKey = `ws:${key}:${previousWindowStart}`;\n const currentKey = `ws:${key}:${currentWindowStart}`;\n\n // Increment current window\n const current = await store.increment(currentKey);\n\n // Get previous window (may not exist)\n let previousCount = 0;\n if (store.get) {\n const prev = await store.get(previousKey);\n previousCount = prev?.count ?? 0;\n }\n\n // Cloudflare's weighted formula\n const elapsedMs = now - currentWindowStart;\n const weight = (windowMs - elapsedMs) / windowMs;\n const estimatedCount = Math.floor(previousCount * weight) + current.count;\n\n const remaining = Math.max(0, limit - estimatedCount);\n const allowed = estimatedCount <= limit;\n const reset = currentWindowStart + windowMs;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Fixed Window Algorithm (adapted for WS)\n// ============================================================================\n\nasync function checkFixedWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const windowStart = Math.floor(now / windowMs) * windowMs;\n const windowKey = `ws:${key}:${windowStart}`;\n\n const { count, reset } = await store.increment(windowKey);\n\n const remaining = Math.max(0, limit - count);\n const allowed = count <= limit;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// WebSocket Rate Limiter\n// ============================================================================\n\n// Singleton default store for WebSocket\nlet wsDefaultStore: MemoryStore | undefined;\n\n/**\n * WebSocket rate limiting middleware for Hono.\n *\n * Wraps your WebSocket event handlers to add rate limiting on messages.\n *\n * @example\n * ```ts\n * import { Hono } from 'hono'\n * import { createBunWebSocket } from 'hono/bun'\n * import { webSocketLimiter } from '@jellyfungus/hono-rate-limiter/websocket'\n *\n * const { upgradeWebSocket, websocket } = createBunWebSocket()\n *\n * const app = new Hono()\n *\n * const wsLimiter = webSocketLimiter({\n * limit: 100,\n * windowMs: 60_000,\n * keyGenerator: (c) => c.req.header('cf-connecting-ip') ?? 'unknown',\n * })\n *\n * app.get('/ws', upgradeWebSocket(wsLimiter((c) => ({\n * onMessage(event, ws) {\n * ws.send('Hello!')\n * },\n * }))))\n *\n * export default { port: 3000, fetch: app.fetch, websocket }\n * ```\n */\nexport function webSocketLimiter<E extends Env = Env>(\n options: WSRateLimitOptions<E>,\n): (\n createEvents: (c: Context<E>) => WSEvents | Promise<WSEvents>,\n) => (c: Context<E>) => Promise<WSEvents> {\n const {\n limit = 100,\n windowMs = 60_000,\n algorithm = \"sliding-window\",\n store = (wsDefaultStore ??= new MemoryStore()),\n keyGenerator,\n handler = (ws, info) => {\n const retryAfter = Math.ceil((info.reset - Date.now()) / 1000);\n ws.close(1008, `Rate limit exceeded. Retry after ${retryAfter}s`);\n },\n skip,\n } = options;\n\n // Track initialization\n let initialized = false;\n\n return (createEvents: (c: Context<E>) => WSEvents | Promise<WSEvents>) => {\n return async (c: Context<E>): Promise<WSEvents> => {\n // Initialize store on first use\n if (!initialized && store.init) {\n await store.init(windowMs);\n initialized = true;\n }\n\n // Get the key for this connection\n const key = await keyGenerator(c);\n\n // Get the limit (may be dynamic)\n const currentLimit = typeof limit === \"function\" ? await limit(c) : limit;\n\n // Get the original events\n const events = await createEvents(c);\n\n return {\n ...events,\n\n onMessage: async (event, ws) => {\n // Check if should skip\n if (skip) {\n const shouldSkip = await skip(event, ws);\n if (shouldSkip) {\n await events.onMessage?.(event, ws);\n return;\n }\n }\n\n // Check rate limit\n const { allowed, info } =\n algorithm === \"sliding-window\"\n ? await checkSlidingWindow(store, key, currentLimit, windowMs)\n : await checkFixedWindow(store, key, currentLimit, windowMs);\n\n if (!allowed) {\n handler(ws, info);\n return;\n }\n\n // Call original handler\n await events.onMessage?.(event, ws);\n },\n\n onOpen: async (event, ws) => {\n await events.onOpen?.(event, ws);\n },\n\n onClose: async (event, ws) => {\n await events.onClose?.(event, ws);\n },\n\n onError: async (event, ws) => {\n await events.onError?.(event, ws);\n },\n };\n };\n };\n}\n","/**\n * @module\n * Rate Limit Middleware for Hono.\n */\n\nimport type { Context, Env, MiddlewareHandler } from \"hono\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Rate limit information for a single request\n */\nexport type RateLimitInfo = {\n /** Maximum requests allowed in window */\n limit: number;\n /** Remaining requests in current window */\n remaining: number;\n /** Unix timestamp (ms) when window resets */\n reset: number;\n};\n\n/**\n * Result from store increment operation\n */\nexport type StoreResult = {\n /** Current request count in window */\n count: number;\n /** When the window resets (Unix timestamp ms) */\n reset: number;\n};\n\n/**\n * Quota unit for IETF standard headers.\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type QuotaUnit = \"requests\" | \"content-bytes\" | \"concurrent-requests\";\n\n/**\n * Header format options.\n *\n * ## \"legacy\" (default)\n * Common X-RateLimit-* headers used by GitHub, Twitter, and most APIs:\n * - `X-RateLimit-Limit`: max requests in window\n * - `X-RateLimit-Remaining`: remaining requests\n * - `X-RateLimit-Reset`: Unix timestamp (seconds) when window resets\n *\n * ## \"draft-6\"\n * IETF draft-06 format with individual RateLimit-* headers:\n * - `RateLimit-Policy`: policy description (e.g., `100;w=60`)\n * - `RateLimit-Limit`: max requests\n * - `RateLimit-Remaining`: remaining requests\n * - `RateLimit-Reset`: seconds until reset\n *\n * ## \"draft-7\"\n * IETF draft-07 format with combined RateLimit header:\n * - `RateLimit-Policy`: policy description\n * - `RateLimit`: combined (e.g., `limit=100, remaining=50, reset=30`)\n *\n * ## \"standard\"\n * Current IETF draft-08+ format with structured field values (RFC 9651):\n * - `RateLimit-Policy`: `\"name\";q=100;w=60`\n * - `RateLimit`: `\"name\";r=50;t=30`\n *\n * ## false\n * Disable all rate limit headers.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type HeadersFormat =\n | \"legacy\" // X-RateLimit-* headers (GitHub/Twitter style)\n | \"draft-6\" // IETF draft-06: individual RateLimit-* headers\n | \"draft-7\" // IETF draft-07: combined RateLimit header\n | \"standard\" // IETF draft-08+: structured field format (current)\n | false; // Disable headers\n\n/**\n * Rate limit algorithm\n */\nexport type Algorithm = \"fixed-window\" | \"sliding-window\";\n\n/**\n * Store interface for rate limit state\n */\nexport type RateLimitStore = {\n /**\n * Initialize store. Called once before first use.\n */\n init?: (windowMs: number) => void | Promise<void>;\n\n /**\n * Increment counter for key and return current state.\n */\n increment: (key: string) => StoreResult | Promise<StoreResult>;\n\n /**\n * Decrement counter for key.\n */\n decrement?: (key: string) => void | Promise<void>;\n\n /**\n * Reset a specific key.\n */\n resetKey: (key: string) => void | Promise<void>;\n\n /**\n * Reset all keys.\n */\n resetAll?: () => void | Promise<void>;\n\n /**\n * Get current state for key.\n */\n get?: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n\n /**\n * Graceful shutdown.\n */\n shutdown?: () => void | Promise<void>;\n};\n\n/**\n * Store access interface exposed in context\n */\nexport type RateLimitStoreAccess = {\n /** Get rate limit info for a key */\n getKey: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n /** Reset rate limit for a key */\n resetKey: (key: string) => void | Promise<void>;\n};\n\n/**\n * Options for rate limit middleware\n */\nexport type RateLimitOptions<E extends Env = Env> = {\n /**\n * Maximum requests allowed in the time window.\n * @default 60\n */\n limit?: number | ((c: Context<E>) => number | Promise<number>);\n\n /**\n * Time window in milliseconds.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Rate limiting algorithm.\n * @default 'sliding-window'\n */\n algorithm?: Algorithm;\n\n /**\n * Storage backend for rate limit state.\n * @default MemoryStore\n */\n store?: RateLimitStore;\n\n /**\n * Generate unique key for each client.\n * @default IP address from headers\n */\n keyGenerator?: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (\n c: Context<E>,\n info: RateLimitInfo,\n ) => Response | Promise<Response>;\n\n /**\n * HTTP header format to use.\n *\n * - \"legacy\": X-RateLimit-* headers (GitHub/Twitter style, default)\n * - \"draft-6\": IETF draft-06 individual headers\n * - \"draft-7\": IETF draft-07 combined header\n * - \"standard\": IETF draft-08+ structured fields (current spec)\n * - false: Disable headers\n *\n * @default 'legacy'\n */\n headers?: HeadersFormat;\n\n /**\n * Policy identifier for IETF headers (draft-6+).\n * Used in RateLimit and RateLimit-Policy headers.\n * @default 'default'\n */\n identifier?: string;\n\n /**\n * Quota unit for IETF standard headers.\n * Only included in \"standard\" format when not \"requests\".\n * @default 'requests'\n */\n quotaUnit?: QuotaUnit;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n\n /**\n * Don't count successful (2xx) requests against limit.\n * @default false\n */\n skipSuccessfulRequests?: boolean;\n\n /**\n * Don't count failed (4xx, 5xx) requests against limit.\n * @default false\n */\n skipFailedRequests?: boolean;\n\n /**\n * Callback when a request is rate limited.\n */\n onRateLimited?: (c: Context<E>, info: RateLimitInfo) => void | Promise<void>;\n};\n\n/**\n * Cloudflare Rate Limiting binding interface\n */\nexport type RateLimitBinding = {\n limit: (options: { key: string }) => Promise<{ success: boolean }>;\n};\n\n/**\n * Options for Cloudflare Rate Limiting binding\n */\nexport type CloudflareRateLimitOptions<E extends Env = Env> = {\n /**\n * Cloudflare Rate Limiting binding from env\n */\n binding: RateLimitBinding | ((c: Context<E>) => RateLimitBinding);\n\n /**\n * Generate unique key for each client.\n */\n keyGenerator: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (c: Context<E>) => Response | Promise<Response>;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n};\n\n// ============================================================================\n// Context Variable Type Extension\n// ============================================================================\n\ndeclare module \"hono\" {\n interface ContextVariableMap {\n rateLimit?: RateLimitInfo;\n rateLimitStore?: RateLimitStoreAccess;\n }\n}\n\n// ============================================================================\n// Memory Store\n// ============================================================================\n\ntype MemoryEntry = {\n count: number;\n reset: number;\n};\n\n/**\n * In-memory store for rate limiting.\n * Suitable for single-instance deployments.\n */\nexport class MemoryStore implements RateLimitStore {\n private entries = new Map<string, MemoryEntry>();\n private windowMs = 60_000;\n private cleanupTimer?: ReturnType<typeof setInterval>;\n\n init(windowMs: number): void {\n this.windowMs = windowMs;\n\n // Cleanup expired entries every minute\n this.cleanupTimer = setInterval(() => {\n const now = Date.now();\n for (const [key, entry] of this.entries) {\n if (entry.reset <= now) {\n this.entries.delete(key);\n }\n }\n }, 60_000);\n\n // Don't keep process alive for cleanup\n if (typeof this.cleanupTimer.unref === \"function\") {\n this.cleanupTimer.unref();\n }\n }\n\n increment(key: string): StoreResult {\n const now = Date.now();\n const existing = this.entries.get(key);\n\n if (!existing || existing.reset <= now) {\n // New window\n const reset = now + this.windowMs;\n this.entries.set(key, { count: 1, reset });\n return { count: 1, reset };\n }\n\n // Increment existing\n existing.count++;\n return { count: existing.count, reset: existing.reset };\n }\n\n get(key: string): StoreResult | undefined {\n const entry = this.entries.get(key);\n if (!entry || entry.reset <= Date.now()) {\n return undefined;\n }\n return { count: entry.count, reset: entry.reset };\n }\n\n decrement(key: string): void {\n const entry = this.entries.get(key);\n if (entry && entry.count > 0) {\n entry.count--;\n }\n }\n\n resetKey(key: string): void {\n this.entries.delete(key);\n }\n\n resetAll(): void {\n this.entries.clear();\n }\n\n shutdown(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer);\n }\n this.entries.clear();\n }\n}\n\n// Singleton default store\nlet defaultStore: MemoryStore | undefined;\n\n// ============================================================================\n// Header Generation\n// ============================================================================\n\n/**\n * Set rate limit response headers based on the configured format.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nfunction setHeaders(\n c: Context,\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): void {\n if (format === false) {\n return;\n }\n\n const windowSeconds = Math.ceil(windowMs / 1000);\n const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n\n switch (format) {\n case \"standard\":\n // IETF draft-08+ (current): Structured field values per RFC 9651\n // RateLimit-Policy: describes the quota policy\n // Format: \"name\";q=<quota>;w=<window>[;qu=\"<unit>\"]\n {\n let policy = `\"${identifier}\";q=${info.limit};w=${windowSeconds}`;\n if (quotaUnit !== \"requests\") {\n policy += `;qu=\"${quotaUnit}\"`;\n }\n c.header(\"RateLimit-Policy\", policy);\n // RateLimit: describes current service limits\n // Format: \"name\";r=<remaining>;t=<reset>\n c.header(\n \"RateLimit\",\n `\"${identifier}\";r=${info.remaining};t=${resetSeconds}`,\n );\n }\n break;\n\n case \"draft-7\":\n // IETF draft-07: Combined RateLimit header with comma-separated values\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\n \"RateLimit\",\n `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`,\n );\n break;\n\n case \"draft-6\":\n // IETF draft-06: Individual RateLimit-* headers\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\"RateLimit-Limit\", String(info.limit));\n c.header(\"RateLimit-Remaining\", String(info.remaining));\n c.header(\"RateLimit-Reset\", String(resetSeconds));\n break;\n\n case \"legacy\":\n default:\n // Common X-RateLimit-* headers (GitHub, Twitter, most APIs)\n // Uses Unix timestamp for reset (seconds since epoch)\n c.header(\"X-RateLimit-Limit\", String(info.limit));\n c.header(\"X-RateLimit-Remaining\", String(info.remaining));\n c.header(\"X-RateLimit-Reset\", String(Math.ceil(info.reset / 1000)));\n break;\n }\n}\n\n// ============================================================================\n// Default Key Generator\n// ============================================================================\n\nfunction getClientIP(c: Context): string {\n // Platform-specific headers (most reliable)\n const cfIP = c.req.header(\"cf-connecting-ip\");\n if (cfIP) {\n return cfIP;\n }\n\n const xRealIP = c.req.header(\"x-real-ip\");\n if (xRealIP) {\n return xRealIP;\n }\n\n // X-Forwarded-For - take first IP\n const xff = c.req.header(\"x-forwarded-for\");\n if (xff) {\n return xff.split(\",\")[0].trim();\n }\n\n return \"unknown\";\n}\n\n// ============================================================================\n// Default Handler\n// ============================================================================\n\nfunction createDefaultResponse(info: RateLimitInfo): Response {\n const retryAfter = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers: {\n \"Content-Type\": \"text/plain\",\n \"Retry-After\": String(retryAfter),\n },\n });\n}\n\n// ============================================================================\n// Sliding Window Algorithm\n// ============================================================================\n\nasync function checkSlidingWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const currentWindowStart = Math.floor(now / windowMs) * windowMs;\n const previousWindowStart = currentWindowStart - windowMs;\n\n const previousKey = `${key}:${previousWindowStart}`;\n const currentKey = `${key}:${currentWindowStart}`;\n\n // Increment current window\n const current = await store.increment(currentKey);\n\n // Get previous window (may not exist)\n let previousCount = 0;\n if (store.get) {\n const prev = await store.get(previousKey);\n previousCount = prev?.count ?? 0;\n }\n\n // Cloudflare's weighted formula\n const elapsedMs = now - currentWindowStart;\n const weight = (windowMs - elapsedMs) / windowMs;\n const estimatedCount = Math.floor(previousCount * weight) + current.count;\n\n const remaining = Math.max(0, limit - estimatedCount);\n const allowed = estimatedCount <= limit;\n const reset = currentWindowStart + windowMs;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Fixed Window Algorithm\n// ============================================================================\n\nasync function checkFixedWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const windowStart = Math.floor(now / windowMs) * windowMs;\n const windowKey = `${key}:${windowStart}`;\n\n const { count, reset } = await store.increment(windowKey);\n\n const remaining = Math.max(0, limit - count);\n const allowed = count <= limit;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Main Middleware\n// ============================================================================\n\n/**\n * Rate Limit Middleware for Hono.\n *\n * @param {RateLimitOptions} [options] - Configuration options\n * @returns {MiddlewareHandler} Middleware handler\n *\n * @example\n * ```ts\n * import { Hono } from 'hono'\n * import { rateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * const app = new Hono()\n *\n * // Basic usage - 60 requests per minute\n * app.use(rateLimiter())\n *\n * // Custom configuration\n * app.use('/api/*', rateLimiter({\n * limit: 100,\n * windowMs: 60 * 1000,\n * }))\n * ```\n */\nexport const rateLimiter = <E extends Env = Env>(\n options?: RateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n // Merge with defaults\n const opts = {\n limit: 60 as number | ((c: Context<E>) => number | Promise<number>),\n windowMs: 60_000,\n algorithm: \"sliding-window\" as Algorithm,\n store: undefined as RateLimitStore | undefined,\n keyGenerator: getClientIP as (c: Context<E>) => string | Promise<string>,\n handler: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => Response | Promise<Response>)\n | undefined,\n headers: \"legacy\" as HeadersFormat,\n identifier: \"default\",\n quotaUnit: \"requests\" as QuotaUnit,\n skip: undefined as\n | ((c: Context<E>) => boolean | Promise<boolean>)\n | undefined,\n skipSuccessfulRequests: false,\n skipFailedRequests: false,\n onRateLimited: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => void | Promise<void>)\n | undefined,\n ...options,\n };\n\n // Use default store if none provided\n const store = opts.store ?? (defaultStore ??= new MemoryStore());\n\n // Track initialization\n let initialized = false;\n\n return async function rateLimiter(c, next) {\n // Initialize store on first request\n if (!initialized && store.init) {\n await store.init(opts.windowMs);\n initialized = true;\n }\n\n // Check if should skip\n if (opts.skip) {\n const shouldSkip = await opts.skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Generate key\n const key = await opts.keyGenerator(c);\n\n // Get limit (may be dynamic)\n const limit =\n typeof opts.limit === \"function\" ? await opts.limit(c) : opts.limit;\n\n // Check rate limit\n const { allowed, info } =\n opts.algorithm === \"sliding-window\"\n ? await checkSlidingWindow(store, key, limit, opts.windowMs)\n : await checkFixedWindow(store, key, limit, opts.windowMs);\n\n // Set context variable for downstream middleware\n c.set(\"rateLimit\", info);\n\n // Expose store access in context\n c.set(\"rateLimitStore\", {\n getKey: store.get?.bind(store) ?? (() => undefined),\n resetKey: store.resetKey.bind(store),\n });\n\n // Set headers\n setHeaders(\n c,\n info,\n opts.headers,\n opts.windowMs,\n opts.identifier,\n opts.quotaUnit,\n );\n\n // Handle rate limited\n if (!allowed) {\n // Fire callback\n if (opts.onRateLimited) {\n await opts.onRateLimited(c, info);\n }\n\n // Custom handler or default\n if (opts.handler) {\n return opts.handler(c, info);\n }\n return createDefaultResponse(info);\n }\n\n // Continue\n await next();\n\n // Handle skip options after response\n if (opts.skipSuccessfulRequests || opts.skipFailedRequests) {\n const status = c.res.status;\n const shouldDecrement =\n (opts.skipSuccessfulRequests && status >= 200 && status < 300) ||\n (opts.skipFailedRequests && status >= 400);\n\n if (shouldDecrement && store.decrement) {\n const windowStart =\n Math.floor(Date.now() / opts.windowMs) * opts.windowMs;\n const windowKey = `${key}:${windowStart}`;\n await store.decrement(windowKey);\n }\n }\n };\n};\n\n// ============================================================================\n// Cloudflare Rate Limiting Binding Middleware\n// ============================================================================\n\n/**\n * Rate limiter using Cloudflare's built-in Rate Limiting binding.\n *\n * This uses Cloudflare's globally distributed rate limiting infrastructure,\n * which is ideal for high-traffic applications.\n *\n * @example\n * ```ts\n * import { cloudflareRateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * type Bindings = { RATE_LIMITER: RateLimitBinding }\n *\n * const app = new Hono<{ Bindings: Bindings }>()\n *\n * app.use(cloudflareRateLimiter({\n * binding: (c) => c.env.RATE_LIMITER,\n * keyGenerator: (c) => c.req.header('cf-connecting-ip') ?? 'unknown',\n * }))\n * ```\n */\nexport const cloudflareRateLimiter = <E extends Env = Env>(\n options: CloudflareRateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n const { binding, keyGenerator, handler, skip } = options;\n\n return async function cloudflareRateLimiter(c, next) {\n // Check if should skip\n if (skip) {\n const shouldSkip = await skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Get binding (may be dynamic)\n const rateLimitBinding =\n typeof binding === \"function\" ? binding(c) : binding;\n\n // Generate key\n const key = await keyGenerator(c);\n\n // Check rate limit\n const { success } = await rateLimitBinding.limit({ key });\n\n if (!success) {\n if (handler) {\n return handler(c);\n }\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers: { \"Content-Type\": \"text/plain\" },\n });\n }\n\n return next();\n };\n};\n\n// ============================================================================\n// Exports\n// ============================================================================\n\nexport { getClientIP };\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/websocket.ts","../src/index.ts"],"sourcesContent":["/**\n * @module\n * WebSocket Rate Limiting for Hono.\n */\n\nimport type { Context, Env } from \"hono\";\nimport type { WSContext, WSEvents } from \"hono/ws\";\nimport {\n MemoryStore,\n type RateLimitStore,\n type RateLimitInfo,\n type StoreResult,\n type Algorithm,\n} from \"./index\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Options for WebSocket rate limiting\n */\nexport type WSRateLimitOptions<E extends Env = Env> = {\n /**\n * Maximum messages allowed in the time window.\n * @default 100\n */\n limit?: number | ((c: Context<E>) => number | Promise<number>);\n\n /**\n * Time window in milliseconds.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Rate limiting algorithm.\n * @default 'sliding-window'\n */\n algorithm?: Algorithm;\n\n /**\n * Storage backend for rate limit state.\n * @default MemoryStore\n */\n store?: RateLimitStore;\n\n /**\n * Generate unique key for each client.\n * Required for WebSocket rate limiting.\n */\n keyGenerator: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n * Default: closes connection with code 1008.\n */\n handler?: (ws: WSContext, info: RateLimitInfo) => void;\n\n /**\n * Skip rate limiting for certain messages.\n */\n skip?: (event: MessageEvent, ws: WSContext) => boolean | Promise<boolean>;\n};\n\n// ============================================================================\n// Sliding Window Algorithm (adapted for WS)\n// ============================================================================\n\nasync function checkSlidingWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const currentWindowStart = Math.floor(now / windowMs) * windowMs;\n const previousWindowStart = currentWindowStart - windowMs;\n\n const previousKey = `ws:${key}:${previousWindowStart}`;\n const currentKey = `ws:${key}:${currentWindowStart}`;\n\n // Increment current window\n const current = await store.increment(currentKey);\n\n // Get previous window (may not exist)\n let previousCount = 0;\n if (store.get) {\n const prev = await store.get(previousKey);\n previousCount = prev?.count ?? 0;\n }\n\n // Cloudflare's weighted formula\n const elapsedMs = now - currentWindowStart;\n const weight = (windowMs - elapsedMs) / windowMs;\n const estimatedCount = Math.floor(previousCount * weight) + current.count;\n\n const remaining = Math.max(0, limit - estimatedCount);\n const allowed = estimatedCount <= limit;\n const reset = currentWindowStart + windowMs;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Fixed Window Algorithm (adapted for WS)\n// ============================================================================\n\nasync function checkFixedWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const windowStart = Math.floor(now / windowMs) * windowMs;\n const windowKey = `ws:${key}:${windowStart}`;\n\n const { count, reset } = await store.increment(windowKey);\n\n const remaining = Math.max(0, limit - count);\n const allowed = count <= limit;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// WebSocket Rate Limiter\n// ============================================================================\n\n// Singleton default store for WebSocket\nlet wsDefaultStore: MemoryStore | undefined;\n\n/**\n * WebSocket rate limiting middleware for Hono.\n *\n * Wraps your WebSocket event handlers to add rate limiting on messages.\n *\n * @example\n * ```ts\n * import { Hono } from 'hono'\n * import { createBunWebSocket } from 'hono/bun'\n * import { webSocketLimiter } from '@jellyfungus/hono-rate-limiter/websocket'\n *\n * const { upgradeWebSocket, websocket } = createBunWebSocket()\n *\n * const app = new Hono()\n *\n * const wsLimiter = webSocketLimiter({\n * limit: 100,\n * windowMs: 60_000,\n * keyGenerator: (c) => c.req.header('cf-connecting-ip') ?? 'unknown',\n * })\n *\n * app.get('/ws', upgradeWebSocket(wsLimiter((c) => ({\n * onMessage(event, ws) {\n * ws.send('Hello!')\n * },\n * }))))\n *\n * export default { port: 3000, fetch: app.fetch, websocket }\n * ```\n */\nexport function webSocketLimiter<E extends Env = Env>(\n options: WSRateLimitOptions<E>,\n): (\n createEvents: (c: Context<E>) => WSEvents | Promise<WSEvents>,\n) => (c: Context<E>) => Promise<WSEvents> {\n const {\n limit = 100,\n windowMs = 60_000,\n algorithm = \"sliding-window\",\n store = (wsDefaultStore ??= new MemoryStore()),\n keyGenerator,\n handler = (ws, info) => {\n const retryAfter = Math.ceil((info.reset - Date.now()) / 1000);\n ws.close(1008, `Rate limit exceeded. Retry after ${retryAfter}s`);\n },\n skip,\n } = options;\n\n // Track initialization\n let initialized = false;\n\n return (createEvents: (c: Context<E>) => WSEvents | Promise<WSEvents>) => {\n return async (c: Context<E>): Promise<WSEvents> => {\n // Initialize store on first use\n if (!initialized && store.init) {\n await store.init(windowMs);\n initialized = true;\n }\n\n // Get the key for this connection\n const key = await keyGenerator(c);\n\n // Get the limit (may be dynamic)\n const currentLimit = typeof limit === \"function\" ? await limit(c) : limit;\n\n // Get the original events\n const events = await createEvents(c);\n\n return {\n ...events,\n\n onMessage: async (event, ws) => {\n // Check if should skip\n if (skip) {\n const shouldSkip = await skip(event, ws);\n if (shouldSkip) {\n await events.onMessage?.(event, ws);\n return;\n }\n }\n\n // Check rate limit\n const { allowed, info } =\n algorithm === \"sliding-window\"\n ? await checkSlidingWindow(store, key, currentLimit, windowMs)\n : await checkFixedWindow(store, key, currentLimit, windowMs);\n\n if (!allowed) {\n handler(ws, info);\n return;\n }\n\n // Call original handler\n await events.onMessage?.(event, ws);\n },\n\n onOpen: async (event, ws) => {\n await events.onOpen?.(event, ws);\n },\n\n onClose: async (event, ws) => {\n await events.onClose?.(event, ws);\n },\n\n onError: async (event, ws) => {\n await events.onError?.(event, ws);\n },\n };\n };\n };\n}\n","/**\n * @module\n * Rate Limit Middleware for Hono.\n */\n\nimport type { Context, Env, MiddlewareHandler } from \"hono\";\n\n// ============================================================================\n// Types\n// ============================================================================\n\n/**\n * Rate limit information for a single request\n */\nexport type RateLimitInfo = {\n /** Maximum requests allowed in window */\n limit: number;\n /** Remaining requests in current window */\n remaining: number;\n /** Unix timestamp (ms) when window resets */\n reset: number;\n};\n\n/**\n * Result from store increment operation\n */\nexport type StoreResult = {\n /** Current request count in window */\n count: number;\n /** When the window resets (Unix timestamp ms) */\n reset: number;\n};\n\n/**\n * Quota unit for IETF standard headers.\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type QuotaUnit = \"requests\" | \"content-bytes\" | \"concurrent-requests\";\n\n/**\n * Header format options.\n *\n * ## \"legacy\" (default)\n * Common X-RateLimit-* headers used by GitHub, Twitter, and most APIs:\n * - `X-RateLimit-Limit`: max requests in window\n * - `X-RateLimit-Remaining`: remaining requests\n * - `X-RateLimit-Reset`: Unix timestamp (seconds) when window resets\n *\n * ## \"draft-6\"\n * IETF draft-06 format with individual RateLimit-* headers:\n * - `RateLimit-Policy`: policy description (e.g., `100;w=60`)\n * - `RateLimit-Limit`: max requests\n * - `RateLimit-Remaining`: remaining requests\n * - `RateLimit-Reset`: seconds until reset\n *\n * ## \"draft-7\"\n * IETF draft-07 format with combined RateLimit header:\n * - `RateLimit-Policy`: policy description\n * - `RateLimit`: combined (e.g., `limit=100, remaining=50, reset=30`)\n *\n * ## \"standard\"\n * Current IETF draft-08+ format with structured field values (RFC 9651):\n * - `RateLimit-Policy`: `\"name\";q=100;w=60`\n * - `RateLimit`: `\"name\";r=50;t=30`\n *\n * ## false\n * Disable all rate limit headers.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nexport type HeadersFormat =\n | \"legacy\" // X-RateLimit-* headers (GitHub/Twitter style)\n | \"draft-6\" // IETF draft-06: individual RateLimit-* headers\n | \"draft-7\" // IETF draft-07: combined RateLimit header\n | \"standard\" // IETF draft-08+: structured field format (current)\n | false; // Disable headers\n\n/**\n * Rate limit algorithm\n */\nexport type Algorithm = \"fixed-window\" | \"sliding-window\";\n\n/**\n * Store interface for rate limit state\n */\nexport type RateLimitStore = {\n /**\n * Initialize store. Called once before first use.\n */\n init?: (windowMs: number) => void | Promise<void>;\n\n /**\n * Increment counter for key and return current state.\n */\n increment: (key: string) => StoreResult | Promise<StoreResult>;\n\n /**\n * Decrement counter for key.\n */\n decrement?: (key: string) => void | Promise<void>;\n\n /**\n * Reset a specific key.\n */\n resetKey: (key: string) => void | Promise<void>;\n\n /**\n * Reset all keys.\n */\n resetAll?: () => void | Promise<void>;\n\n /**\n * Get current state for key.\n */\n get?: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n\n /**\n * Graceful shutdown.\n */\n shutdown?: () => void | Promise<void>;\n};\n\n/**\n * Store access interface exposed in context\n */\nexport type RateLimitStoreAccess = {\n /** Get rate limit info for a key */\n getKey: (\n key: string,\n ) => StoreResult | Promise<StoreResult | undefined> | undefined;\n /** Reset rate limit for a key */\n resetKey: (key: string) => void | Promise<void>;\n};\n\n/**\n * Options for rate limit middleware\n */\nexport type RateLimitOptions<E extends Env = Env> = {\n /**\n * Maximum requests allowed in the time window.\n * @default 60\n */\n limit?: number | ((c: Context<E>) => number | Promise<number>);\n\n /**\n * Time window in milliseconds.\n * @default 60000 (1 minute)\n */\n windowMs?: number;\n\n /**\n * Rate limiting algorithm.\n * @default 'sliding-window'\n */\n algorithm?: Algorithm;\n\n /**\n * Storage backend for rate limit state.\n * @default MemoryStore\n */\n store?: RateLimitStore;\n\n /**\n * Generate unique key for each client.\n * @default IP address from headers\n */\n keyGenerator?: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (\n c: Context<E>,\n info: RateLimitInfo,\n ) => Response | Promise<Response>;\n\n /**\n * HTTP header format to use.\n *\n * - \"legacy\": X-RateLimit-* headers (GitHub/Twitter style, default)\n * - \"draft-6\": IETF draft-06 individual headers\n * - \"draft-7\": IETF draft-07 combined header\n * - \"standard\": IETF draft-08+ structured fields (current spec)\n * - false: Disable headers\n *\n * @default 'legacy'\n */\n headers?: HeadersFormat;\n\n /**\n * Policy identifier for IETF headers (draft-6+).\n * Used in RateLimit and RateLimit-Policy headers.\n * @default 'default'\n */\n identifier?: string;\n\n /**\n * Quota unit for IETF standard headers.\n * Only included in \"standard\" format when not \"requests\".\n * @default 'requests'\n */\n quotaUnit?: QuotaUnit;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n\n /**\n * Don't count successful (2xx) requests against limit.\n * @default false\n */\n skipSuccessfulRequests?: boolean;\n\n /**\n * Don't count failed (4xx, 5xx) requests against limit.\n * @default false\n */\n skipFailedRequests?: boolean;\n\n /**\n * Callback when a request is rate limited.\n */\n onRateLimited?: (c: Context<E>, info: RateLimitInfo) => void | Promise<void>;\n\n /**\n * Behavior when store operations fail.\n *\n * - 'allow': Allow the request through (fail-open, default)\n * - 'deny': Block the request with 500 error (fail-closed)\n * - Function: Custom handler returning true to allow, false to deny\n *\n * @default 'allow'\n */\n onStoreError?:\n | \"allow\"\n | \"deny\"\n | ((error: Error, c: Context<E>) => boolean | Promise<boolean>);\n};\n\n/**\n * Cloudflare Rate Limiting binding interface\n */\nexport type RateLimitBinding = {\n limit: (options: { key: string }) => Promise<{ success: boolean }>;\n};\n\n/**\n * Options for Cloudflare Rate Limiting binding\n */\nexport type CloudflareRateLimitOptions<E extends Env = Env> = {\n /**\n * Cloudflare Rate Limiting binding from env\n */\n binding: RateLimitBinding | ((c: Context<E>) => RateLimitBinding);\n\n /**\n * Generate unique key for each client.\n */\n keyGenerator: (c: Context<E>) => string | Promise<string>;\n\n /**\n * Handler called when rate limit is exceeded.\n */\n handler?: (c: Context<E>) => Response | Promise<Response>;\n\n /**\n * Skip rate limiting for certain requests.\n */\n skip?: (c: Context<E>) => boolean | Promise<boolean>;\n};\n\n// ============================================================================\n// Context Variable Type Extension\n// ============================================================================\n\ndeclare module \"hono\" {\n interface ContextVariableMap {\n rateLimit?: RateLimitInfo;\n rateLimitStore?: RateLimitStoreAccess;\n }\n}\n\n// ============================================================================\n// Memory Store\n// ============================================================================\n\ntype MemoryEntry = {\n count: number;\n reset: number;\n};\n\n/**\n * In-memory store for rate limiting.\n * Suitable for single-instance deployments.\n */\nexport class MemoryStore implements RateLimitStore {\n private entries = new Map<string, MemoryEntry>();\n private windowMs = 60_000;\n private cleanupTimer?: ReturnType<typeof setInterval>;\n\n init(windowMs: number): void {\n this.windowMs = windowMs;\n\n // Cleanup expired entries every minute\n this.cleanupTimer = setInterval(() => {\n const now = Date.now();\n for (const [key, entry] of this.entries) {\n if (entry.reset <= now) {\n this.entries.delete(key);\n }\n }\n }, 60_000);\n\n // Don't keep process alive for cleanup\n if (typeof this.cleanupTimer.unref === \"function\") {\n this.cleanupTimer.unref();\n }\n }\n\n increment(key: string): StoreResult {\n const now = Date.now();\n const existing = this.entries.get(key);\n\n if (!existing || existing.reset <= now) {\n // New window\n const reset = now + this.windowMs;\n this.entries.set(key, { count: 1, reset });\n return { count: 1, reset };\n }\n\n // Increment existing\n existing.count++;\n return { count: existing.count, reset: existing.reset };\n }\n\n get(key: string): StoreResult | undefined {\n const entry = this.entries.get(key);\n if (!entry || entry.reset <= Date.now()) {\n return undefined;\n }\n return { count: entry.count, reset: entry.reset };\n }\n\n decrement(key: string): void {\n const entry = this.entries.get(key);\n if (entry && entry.count > 0) {\n entry.count--;\n }\n }\n\n resetKey(key: string): void {\n this.entries.delete(key);\n }\n\n resetAll(): void {\n this.entries.clear();\n }\n\n shutdown(): void {\n if (this.cleanupTimer) {\n clearInterval(this.cleanupTimer);\n }\n this.entries.clear();\n }\n}\n\n// Singleton default store\nlet defaultStore: MemoryStore | undefined;\n\n/**\n * Shutdown the default memory store.\n * Call this during graceful shutdown to clean up timers.\n *\n * @example\n * ```ts\n * import { shutdownDefaultStore } from '@jellyfungus/hono-rate-limiter'\n *\n * process.on('SIGTERM', () => {\n * shutdownDefaultStore()\n * process.exit(0)\n * })\n * ```\n */\nexport function shutdownDefaultStore(): void {\n if (defaultStore) {\n defaultStore.shutdown();\n defaultStore = undefined;\n }\n}\n\n// ============================================================================\n// Header Generation\n// ============================================================================\n\n/**\n * Set rate limit response headers based on the configured format.\n *\n * @see https://datatracker.ietf.org/doc/draft-ietf-httpapi-ratelimit-headers/\n */\nfunction setHeaders(\n c: Context,\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): void {\n if (format === false) {\n return;\n }\n\n const windowSeconds = Math.ceil(windowMs / 1000);\n const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n const safeId = sanitizeIdentifier(identifier);\n\n switch (format) {\n case \"standard\":\n // IETF draft-08+ (current): Structured field values per RFC 9651\n // RateLimit-Policy: describes the quota policy\n // Format: \"name\";q=<quota>;w=<window>[;qu=\"<unit>\"]\n {\n let policy = `\"${safeId}\";q=${info.limit};w=${windowSeconds}`;\n if (quotaUnit !== \"requests\") {\n policy += `;qu=\"${quotaUnit}\"`;\n }\n c.header(\"RateLimit-Policy\", policy);\n // RateLimit: describes current service limits\n // Format: \"name\";r=<remaining>;t=<reset>\n c.header(\n \"RateLimit\",\n `\"${safeId}\";r=${info.remaining};t=${resetSeconds}`,\n );\n }\n break;\n\n case \"draft-7\":\n // IETF draft-07: Combined RateLimit header with comma-separated values\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\n \"RateLimit\",\n `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`,\n );\n break;\n\n case \"draft-6\":\n // IETF draft-06: Individual RateLimit-* headers\n c.header(\"RateLimit-Policy\", `${info.limit};w=${windowSeconds}`);\n c.header(\"RateLimit-Limit\", String(info.limit));\n c.header(\"RateLimit-Remaining\", String(info.remaining));\n c.header(\"RateLimit-Reset\", String(resetSeconds));\n break;\n\n case \"legacy\":\n default:\n // Common X-RateLimit-* headers (GitHub, Twitter, most APIs)\n // Uses Unix timestamp for reset (seconds since epoch)\n c.header(\"X-RateLimit-Limit\", String(info.limit));\n c.header(\"X-RateLimit-Remaining\", String(info.remaining));\n c.header(\"X-RateLimit-Reset\", String(Math.ceil(info.reset / 1000)));\n break;\n }\n}\n\n// ============================================================================\n// Identifier Sanitization\n// ============================================================================\n\n/**\n * Sanitize identifier for RFC 9651 structured field compliance.\n * Identifiers are used in the RateLimit and RateLimit-Policy headers.\n */\nfunction sanitizeIdentifier(id: string): string {\n if (!id || typeof id !== \"string\") {\n return \"default\";\n }\n // RFC 9651 tokens: Only allow alphanumeric, underscore, hyphen, dot, colon, asterisk, slash\n // Must start with a letter\n const sanitized = id.replace(/[^a-zA-Z0-9_\\-.:*/]/g, \"-\");\n if (!sanitized || !/^[a-zA-Z]/.test(sanitized)) {\n return \"default\";\n }\n return sanitized;\n}\n\n// ============================================================================\n// Default Key Generator\n// ============================================================================\n\nfunction getClientIP(c: Context): string {\n // Platform-specific headers (most reliable)\n const cfIP = c.req.header(\"cf-connecting-ip\");\n if (cfIP) {\n return cfIP;\n }\n\n const xRealIP = c.req.header(\"x-real-ip\");\n if (xRealIP) {\n return xRealIP;\n }\n\n // X-Forwarded-For - take first IP\n const xff = c.req.header(\"x-forwarded-for\");\n if (xff) {\n return xff.split(\",\")[0].trim();\n }\n\n return \"unknown\";\n}\n\n// ============================================================================\n// Default Handler\n// ============================================================================\n\n/**\n * Build headers object for rate limit responses.\n * This ensures headers are included in both custom and default responses.\n */\nfunction buildRateLimitHeaders(\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): Record<string, string> {\n const headers: Record<string, string> = {\n \"Content-Type\": \"text/plain\",\n \"Retry-After\": String(\n Math.max(0, Math.ceil((info.reset - Date.now()) / 1000)),\n ),\n };\n\n if (format === false) {\n return headers;\n }\n\n const windowSeconds = Math.ceil(windowMs / 1000);\n const resetSeconds = Math.max(0, Math.ceil((info.reset - Date.now()) / 1000));\n const safeId = sanitizeIdentifier(identifier);\n\n switch (format) {\n case \"standard\": {\n let policy = `\"${safeId}\";q=${info.limit};w=${windowSeconds}`;\n if (quotaUnit !== \"requests\") {\n policy += `;qu=\"${quotaUnit}\"`;\n }\n headers[\"RateLimit-Policy\"] = policy;\n headers[\"RateLimit\"] =\n `\"${safeId}\";r=${info.remaining};t=${resetSeconds}`;\n break;\n }\n case \"draft-7\":\n headers[\"RateLimit-Policy\"] = `${info.limit};w=${windowSeconds}`;\n headers[\"RateLimit\"] =\n `limit=${info.limit}, remaining=${info.remaining}, reset=${resetSeconds}`;\n break;\n case \"draft-6\":\n headers[\"RateLimit-Policy\"] = `${info.limit};w=${windowSeconds}`;\n headers[\"RateLimit-Limit\"] = String(info.limit);\n headers[\"RateLimit-Remaining\"] = String(info.remaining);\n headers[\"RateLimit-Reset\"] = String(resetSeconds);\n break;\n case \"legacy\":\n default:\n headers[\"X-RateLimit-Limit\"] = String(info.limit);\n headers[\"X-RateLimit-Remaining\"] = String(info.remaining);\n headers[\"X-RateLimit-Reset\"] = String(Math.ceil(info.reset / 1000));\n break;\n }\n\n return headers;\n}\n\nfunction createDefaultResponse(\n info: RateLimitInfo,\n format: HeadersFormat,\n windowMs: number,\n identifier: string,\n quotaUnit: QuotaUnit,\n): Response {\n const headers = buildRateLimitHeaders(\n info,\n format,\n windowMs,\n identifier,\n quotaUnit,\n );\n\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers,\n });\n}\n\n// ============================================================================\n// Sliding Window Algorithm\n// ============================================================================\n\nasync function checkSlidingWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const currentWindowStart = Math.floor(now / windowMs) * windowMs;\n const previousWindowStart = currentWindowStart - windowMs;\n\n const previousKey = `${key}:${previousWindowStart}`;\n const currentKey = `${key}:${currentWindowStart}`;\n\n // Increment current window\n const current = await store.increment(currentKey);\n\n // Get previous window (may not exist)\n let previousCount = 0;\n if (store.get) {\n const prev = await store.get(previousKey);\n previousCount = prev?.count ?? 0;\n }\n\n // Cloudflare's weighted formula\n const elapsedMs = now - currentWindowStart;\n const weight = (windowMs - elapsedMs) / windowMs;\n const estimatedCount = Math.floor(previousCount * weight) + current.count;\n\n const remaining = Math.max(0, limit - estimatedCount);\n const allowed = estimatedCount <= limit;\n const reset = currentWindowStart + windowMs;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Fixed Window Algorithm\n// ============================================================================\n\nasync function checkFixedWindow(\n store: RateLimitStore,\n key: string,\n limit: number,\n windowMs: number,\n): Promise<{ allowed: boolean; info: RateLimitInfo }> {\n const now = Date.now();\n const windowStart = Math.floor(now / windowMs) * windowMs;\n const windowKey = `${key}:${windowStart}`;\n\n const { count, reset } = await store.increment(windowKey);\n\n const remaining = Math.max(0, limit - count);\n const allowed = count <= limit;\n\n return {\n allowed,\n info: { limit, remaining, reset },\n };\n}\n\n// ============================================================================\n// Main Middleware\n// ============================================================================\n\n/**\n * Rate Limit Middleware for Hono.\n *\n * @param {RateLimitOptions} [options] - Configuration options\n * @returns {MiddlewareHandler} Middleware handler\n *\n * @example\n * ```ts\n * import { Hono } from 'hono'\n * import { rateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * const app = new Hono()\n *\n * // Basic usage - 60 requests per minute\n * app.use(rateLimiter())\n *\n * // Custom configuration\n * app.use('/api/*', rateLimiter({\n * limit: 100,\n * windowMs: 60 * 1000,\n * }))\n * ```\n */\nexport const rateLimiter = <E extends Env = Env>(\n options?: RateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n // Merge with defaults\n const opts = {\n limit: 60 as number | ((c: Context<E>) => number | Promise<number>),\n windowMs: 60_000,\n algorithm: \"sliding-window\" as Algorithm,\n store: undefined as RateLimitStore | undefined,\n keyGenerator: getClientIP as (c: Context<E>) => string | Promise<string>,\n handler: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => Response | Promise<Response>)\n | undefined,\n headers: \"legacy\" as HeadersFormat,\n identifier: \"default\",\n quotaUnit: \"requests\" as QuotaUnit,\n skip: undefined as\n | ((c: Context<E>) => boolean | Promise<boolean>)\n | undefined,\n skipSuccessfulRequests: false,\n skipFailedRequests: false,\n onRateLimited: undefined as\n | ((c: Context<E>, info: RateLimitInfo) => void | Promise<void>)\n | undefined,\n onStoreError: \"allow\" as\n | \"allow\"\n | \"deny\"\n | ((error: Error, c: Context<E>) => boolean | Promise<boolean>),\n ...options,\n };\n\n // Use default store if none provided\n const store = opts.store ?? (defaultStore ??= new MemoryStore());\n\n // Track initialization\n let initPromise: Promise<void> | null = null;\n\n /**\n * Handle store errors based on configuration.\n * @returns true to allow request, false to deny\n */\n async function handleStoreError(\n error: Error,\n c: Context<E>,\n ): Promise<boolean> {\n if (typeof opts.onStoreError === \"function\") {\n return opts.onStoreError(error, c);\n }\n // Default: fail-open (allow request through)\n return opts.onStoreError === \"allow\";\n }\n\n return async function rateLimiter(c, next) {\n // Initialize store on first request (with proper locking)\n if (!initPromise && store.init) {\n const result = store.init(opts.windowMs);\n // Handle both sync and async init\n initPromise = result instanceof Promise ? result : Promise.resolve();\n }\n if (initPromise) {\n try {\n await initPromise;\n } catch (error) {\n const shouldAllow = await handleStoreError(\n error instanceof Error ? error : new Error(String(error)),\n c,\n );\n if (shouldAllow) {\n return next();\n }\n return new Response(\"Rate limiter initialization failed\", {\n status: 500,\n });\n }\n }\n\n // Check if should skip\n if (opts.skip) {\n const shouldSkip = await opts.skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Generate key\n const key = await opts.keyGenerator(c);\n\n // Get limit (may be dynamic)\n const limit =\n typeof opts.limit === \"function\" ? await opts.limit(c) : opts.limit;\n\n // Check rate limit with error handling\n let allowed: boolean;\n let info: RateLimitInfo;\n\n try {\n const result =\n opts.algorithm === \"sliding-window\"\n ? await checkSlidingWindow(store, key, limit, opts.windowMs)\n : await checkFixedWindow(store, key, limit, opts.windowMs);\n allowed = result.allowed;\n info = result.info;\n } catch (error) {\n const shouldAllow = await handleStoreError(\n error instanceof Error ? error : new Error(String(error)),\n c,\n );\n if (shouldAllow) {\n return next();\n }\n return new Response(\"Rate limiter error\", { status: 500 });\n }\n\n // Set context variable for downstream middleware\n c.set(\"rateLimit\", info);\n\n // Expose store access in context\n c.set(\"rateLimitStore\", {\n getKey: store.get?.bind(store) ?? (() => undefined),\n resetKey: store.resetKey.bind(store),\n });\n\n // Set headers\n setHeaders(\n c,\n info,\n opts.headers,\n opts.windowMs,\n opts.identifier,\n opts.quotaUnit,\n );\n\n // Handle rate limited\n if (!allowed) {\n // Fire callback\n if (opts.onRateLimited) {\n await opts.onRateLimited(c, info);\n }\n\n // Custom handler or default\n if (opts.handler) {\n return opts.handler(c, info);\n }\n return createDefaultResponse(\n info,\n opts.headers,\n opts.windowMs,\n opts.identifier,\n opts.quotaUnit,\n );\n }\n\n // Continue\n await next();\n\n // Handle skip options after response\n if (opts.skipSuccessfulRequests || opts.skipFailedRequests) {\n const status = c.res.status;\n const shouldDecrement =\n (opts.skipSuccessfulRequests && status >= 200 && status < 300) ||\n (opts.skipFailedRequests && status >= 400);\n\n if (shouldDecrement && store.decrement) {\n const windowStart =\n Math.floor(Date.now() / opts.windowMs) * opts.windowMs;\n const windowKey = `${key}:${windowStart}`;\n try {\n await store.decrement(windowKey);\n } catch {\n // Ignore decrement errors - request already processed\n }\n }\n }\n };\n};\n\n// ============================================================================\n// Cloudflare Rate Limiting Binding Middleware\n// ============================================================================\n\n/**\n * Rate limiter using Cloudflare's built-in Rate Limiting binding.\n *\n * This uses Cloudflare's globally distributed rate limiting infrastructure,\n * which is ideal for high-traffic applications.\n *\n * @example\n * ```ts\n * import { cloudflareRateLimiter } from '@jellyfungus/hono-rate-limiter'\n *\n * type Bindings = { RATE_LIMITER: RateLimitBinding }\n *\n * const app = new Hono<{ Bindings: Bindings }>()\n *\n * app.use(cloudflareRateLimiter({\n * binding: (c) => c.env.RATE_LIMITER,\n * keyGenerator: (c) => c.req.header('cf-connecting-ip') ?? 'unknown',\n * }))\n * ```\n */\nexport const cloudflareRateLimiter = <E extends Env = Env>(\n options: CloudflareRateLimitOptions<E>,\n): MiddlewareHandler<E> => {\n const { binding, keyGenerator, handler, skip } = options;\n\n return async function cloudflareRateLimiter(c, next) {\n // Check if should skip\n if (skip) {\n const shouldSkip = await skip(c);\n if (shouldSkip) {\n return next();\n }\n }\n\n // Get binding (may be dynamic)\n const rateLimitBinding =\n typeof binding === \"function\" ? binding(c) : binding;\n\n // Generate key\n const key = await keyGenerator(c);\n\n // Check rate limit\n const { success } = await rateLimitBinding.limit({ key });\n\n if (!success) {\n if (handler) {\n return handler(c);\n }\n return new Response(\"Rate limit exceeded\", {\n status: 429,\n headers: { \"Content-Type\": \"text/plain\" },\n });\n }\n\n return next();\n };\n};\n\n// ============================================================================\n// Exports\n// ============================================================================\n\nexport { getClientIP };\n"],"mappings":"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","names":[]}
|