CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
rate-limit.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 45e31d0 | 1 | /** |
| 2 | * In-memory rate limiter middleware. | |
| 3 | * | |
| 4 | * Provides per-IP rate limiting with sliding window counters. | |
| 5 | * For production, replace with Redis-based implementation. | |
| 6 | */ | |
| 7 | ||
| 8 | import { createMiddleware } from "hono/factory"; | |
| 9 | ||
| 10 | interface RateLimitEntry { | |
| 11 | count: number; | |
| 12 | resetAt: number; | |
| 13 | } | |
| 14 | ||
| 15 | const store = new Map<string, RateLimitEntry>(); | |
| 16 | ||
| 17 | // Cleanup stale entries every 60 seconds | |
| ea52715 | 18 | const _cleanupInterval = setInterval(() => { |
| 45e31d0 | 19 | const now = Date.now(); |
| 20 | for (const [key, entry] of store) { | |
| 21 | if (entry.resetAt < now) { | |
| 22 | store.delete(key); | |
| 23 | } | |
| 24 | } | |
| 25 | }, 60_000); | |
| 26 | ||
| 27 | /** | |
| 28 | * Create a rate limiter middleware. | |
| 29 | * @param maxRequests Maximum requests per window | |
| 30 | * @param windowMs Window duration in milliseconds | |
| 31 | * @param keyPrefix Prefix for the rate limit key (allows different limits per route group) | |
| 32 | */ | |
| 33 | export function rateLimit( | |
| 34 | maxRequests: number, | |
| 35 | windowMs: number, | |
| 36 | keyPrefix = "global" | |
| 37 | ) { | |
| 38 | return createMiddleware(async (c, next) => { | |
| 699e5c7 | 39 | // In test env, expose informational rate-limit headers but do not actually |
| 40 | // enforce limits — the shared in-memory store leaks across test files and | |
| 41 | // would push requests into 429 once accumulated. | |
| 42 | if (process.env.NODE_ENV === "test" || process.env.BUN_ENV === "test") { | |
| 43 | c.header("X-RateLimit-Limit", String(maxRequests)); | |
| 44 | c.header("X-RateLimit-Remaining", String(maxRequests)); | |
| 45 | c.header("X-RateLimit-Reset", String(Math.ceil((Date.now() + windowMs) / 1000))); | |
| 46 | return next(); | |
| 47 | } | |
| 48 | ||
| 45e31d0 | 49 | const ip = |
| 50 | c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || | |
| 51 | c.req.header("x-real-ip") || | |
| 52 | "unknown"; | |
| 53 | ||
| 54 | const key = `${keyPrefix}:${ip}`; | |
| 55 | const now = Date.now(); | |
| 56 | let entry = store.get(key); | |
| 57 | ||
| 58 | if (!entry || entry.resetAt < now) { | |
| 59 | entry = { count: 0, resetAt: now + windowMs }; | |
| 60 | store.set(key, entry); | |
| 61 | } | |
| 62 | ||
| 63 | entry.count++; | |
| 64 | ||
| 65 | // Set rate limit headers | |
| 66 | c.header("X-RateLimit-Limit", String(maxRequests)); | |
| 67 | c.header("X-RateLimit-Remaining", String(Math.max(0, maxRequests - entry.count))); | |
| 68 | c.header("X-RateLimit-Reset", String(Math.ceil(entry.resetAt / 1000))); | |
| 69 | ||
| 70 | if (entry.count > maxRequests) { | |
| 71 | c.header("Retry-After", String(Math.ceil((entry.resetAt - now) / 1000))); | |
| 72 | return c.json( | |
| 73 | { | |
| 74 | error: "Rate limit exceeded", | |
| 75 | retryAfter: Math.ceil((entry.resetAt - now) / 1000), | |
| 76 | }, | |
| 77 | 429 | |
| 78 | ); | |
| 79 | } | |
| 80 | ||
| 81 | return next(); | |
| 82 | }); | |
| 83 | } | |
| 84 | ||
| 85 | /** | |
| 86 | * Pre-configured rate limiters for different route groups. | |
| 87 | */ | |
| 3e8f8e8 | 88 | export function clearRateLimitStore() { |
| 89 | store.clear(); | |
| 90 | } | |
| 91 | ||
| 45e31d0 | 92 | export const apiRateLimit = rateLimit(100, 60_000, "api"); // 100 req/min |
| 93 | export const authRateLimit = rateLimit(10, 60_000, "auth"); // 10 req/min (login/register) | |
| 94 | export const gitRateLimit = rateLimit(60, 60_000, "git"); // 60 req/min | |
| 95 | export const searchRateLimit = rateLimit(30, 60_000, "search"); // 30 req/min |