60 lines
1.5 KiB
TypeScript
60 lines
1.5 KiB
TypeScript
/**
|
|
* In-memory rate limiter (per IP, per minute window).
|
|
* For production at scale, replace with Redis-backed solution.
|
|
*/
|
|
|
|
interface RateLimitEntry {
|
|
count: number;
|
|
windowStart: number;
|
|
}
|
|
|
|
const store = new Map<string, RateLimitEntry>();
|
|
const WINDOW_MS = 60_000; // 1 minute
|
|
|
|
// Cleanup stale entries every 5 minutes to prevent memory leaks
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [key, entry] of store.entries()) {
|
|
if (now - entry.windowStart > WINDOW_MS * 2) {
|
|
store.delete(key);
|
|
}
|
|
}
|
|
}, 300_000);
|
|
|
|
export function checkRateLimit(
|
|
ip: string,
|
|
limit: number = parseInt(process.env.RATE_LIMIT_RPM ?? "100"),
|
|
): { allowed: boolean; remaining: number; resetAt: number } {
|
|
const now = Date.now();
|
|
const entry = store.get(ip);
|
|
|
|
if (!entry || now - entry.windowStart > WINDOW_MS) {
|
|
store.set(ip, { count: 1, windowStart: now });
|
|
return { allowed: true, remaining: limit - 1, resetAt: now + WINDOW_MS };
|
|
}
|
|
|
|
if (entry.count >= limit) {
|
|
return {
|
|
allowed: false,
|
|
remaining: 0,
|
|
resetAt: entry.windowStart + WINDOW_MS,
|
|
};
|
|
}
|
|
|
|
entry.count++;
|
|
return {
|
|
allowed: true,
|
|
remaining: limit - entry.count,
|
|
resetAt: entry.windowStart + WINDOW_MS,
|
|
};
|
|
}
|
|
|
|
/** Extract real IP from request (handles proxies). */
|
|
export function getClientIp(request: Request): string {
|
|
return (
|
|
request.headers.get("x-real-ip") ??
|
|
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
"unknown"
|
|
);
|
|
}
|