56 lines
1.7 KiB
TypeScript
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";
|
|
}
|