Files
WebForm-mw/lib/rate-limit.ts

56 lines
1.7 KiB
TypeScript

/**
* 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<string, Bucket>();
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";
}