import 'server-only' interface RateLimitEntry { count: number resetAt: number } declare global { // eslint-disable-next-line no-var var __rateLimitStore: Map | undefined // eslint-disable-next-line no-var var __rateLimitCleanupStarted: boolean | undefined } const store: Map = (globalThis.__rateLimitStore ??= new Map()) const MAX_KEYS = 5_000 const CLEANUP_INTERVAL_MS = 60_000 function cleanupExpiredEntries(): void { const now = Date.now() for (const [key, entry] of store) { if (entry.resetAt <= now) store.delete(key) } // Last-resort cap for spoofed or unusually high-cardinality keys. Map keeps // insertion order, so this drops the oldest entries first. while (store.size > MAX_KEYS) { const oldest = store.keys().next().value as string | undefined if (!oldest) break store.delete(oldest) } } if (!globalThis.__rateLimitCleanupStarted) { globalThis.__rateLimitCleanupStarted = true setInterval(cleanupExpiredEntries, CLEANUP_INTERVAL_MS).unref?.() } export function checkRateLimit( key: string, options: { limit: number; windowMs: number } ): { allowed: true } | { allowed: false; retryAfterSeconds: number } { if (store.size > MAX_KEYS) cleanupExpiredEntries() const now = Date.now() const current = store.get(key) if (!current || current.resetAt <= now) { store.set(key, { count: 1, resetAt: now + options.windowMs }) return { allowed: true } } if (current.count >= options.limit) { return { allowed: false, retryAfterSeconds: Math.ceil((current.resetAt - now) / 1000), } } current.count++ return { allowed: true } } export function getClientIp(request: Request): string { const forwardedFor = request.headers.get('x-forwarded-for') const forwardedParts = forwardedFor?.split(',').map((part) => part.trim()).filter(Boolean) // In direct-to-app dev the header is absent. Behind a trusted single proxy, // the right-most XFF value is less attacker-controlled than the left-most // value, which clients can often spoof before the proxy appends its hop. return forwardedParts?.at(-1) || request.headers.get('x-real-ip')?.trim() || 'unknown' }