/** * Simple in-memory sliding-window rate limiter. * Each instance tracks a single "scope" (e.g. login, register). * * Not shared across serverless instances — if deployed to a * multi-instance environment, swap this for a Redis-backed limiter. */ type Entry = { timestamps: number[] }; /** * Extract the client IP from request headers reliably. * Prefers x-real-ip (set by Vercel/nginx), falls back to x-forwarded-for. * Returns "unknown" only as a last resort. */ export function getClientIp(hdrs: Headers): string { return ( hdrs.get("x-real-ip") ?? hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown" ); } export function createRateLimiter({ windowMs, maxRequests, }: { /** Sliding window size in milliseconds */ windowMs: number; /** Maximum requests allowed within the window */ maxRequests: number; }) { const store = new Map(); // Periodically clean up stale entries to avoid unbounded memory growth const CLEANUP_INTERVAL = 60_000; let lastCleanup = Date.now(); function cleanup() { const now = Date.now(); if (now - lastCleanup < CLEANUP_INTERVAL) return; lastCleanup = now; const cutoff = now - windowMs; for (const [key, entry] of store) { entry.timestamps = entry.timestamps.filter((t) => t > cutoff); if (entry.timestamps.length === 0) store.delete(key); } } return { /** * Check whether a request from `key` is allowed. * Returns `{ allowed: true }` or `{ allowed: false, retryAfterMs }`. */ check(key: string): { allowed: true } | { allowed: false; retryAfterMs: number } { cleanup(); const now = Date.now(); const cutoff = now - windowMs; const entry = store.get(key) ?? { timestamps: [] }; // Drop timestamps outside the window entry.timestamps = entry.timestamps.filter((t) => t > cutoff); if (entry.timestamps.length >= maxRequests) { const oldest = entry.timestamps[0]; return { allowed: false, retryAfterMs: oldest + windowMs - now }; } entry.timestamps.push(now); store.set(key, entry); return { allowed: true }; }, }; }