/** * Tiny in-memory rate limiter. Token-bucket per key (typically client IP). * * Suitable for a single-instance deployment behind Render's load balancer. * State resets on process restart, which is fine for this use case (the * worst that happens is a malicious client gets to retry sooner). * * For multi-instance deployments use Redis or Render's edge rate-limit. */ interface Bucket { tokens: number; refilledAt: number; } const BUCKETS = new Map(); export interface RateLimitOptions { /** Max requests per window. */ capacity: number; /** Window length in milliseconds (tokens fully refill each window). */ windowMs: number; } export function rateLimit(key: string, opts: RateLimitOptions): { allowed: boolean; remaining: number; resetMs: number } { const now = Date.now(); const bucket = BUCKETS.get(key); if (!bucket || now - bucket.refilledAt >= opts.windowMs) { BUCKETS.set(key, { tokens: opts.capacity - 1, refilledAt: now }); return { allowed: true, remaining: opts.capacity - 1, resetMs: opts.windowMs }; } if (bucket.tokens > 0) { bucket.tokens--; return { allowed: true, remaining: bucket.tokens, resetMs: opts.windowMs - (now - bucket.refilledAt), }; } return { allowed: false, remaining: 0, resetMs: opts.windowMs - (now - bucket.refilledAt), }; } /** * Best-effort client-IP extraction from a Next.js Request. Trusts the * left-most x-forwarded-for entry (set by Render's load balancer). */ export function clientIp(req: Request): string { const xff = req.headers.get("x-forwarded-for"); if (xff) return xff.split(",")[0].trim(); return req.headers.get("x-real-ip") ?? "unknown"; }