2026-02-14 10:30:27 +01:00
|
|
|
|
/**
|
|
|
|
|
|
* In-memory sliding-window rate limiter.
|
|
|
|
|
|
* Tracks request counts per IP per minute bucket.
|
|
|
|
|
|
* Auto-evicts expired entries to prevent memory leaks.
|
2026-03-07 09:27:47 +01:00
|
|
|
|
*
|
|
|
|
|
|
* ⚠️ NOTE: This is an in-memory store. In a multi-node or serverless
|
|
|
|
|
|
* environment (Vercel, AWS Lambda, Docker Swarm), each instance will
|
|
|
|
|
|
* have its own independent rate limit. For a global limit across
|
|
|
|
|
|
* multiple nodes, a persistent store like Redis would be required.
|
2026-02-14 10:30:27 +01:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
interface RateLimitEntry {
|
2026-02-14 11:47:26 +01:00
|
|
|
|
count: number;
|
|
|
|
|
|
expiresAt: number;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const store = new Map<string, RateLimitEntry>();
|
|
|
|
|
|
|
|
|
|
|
|
// Evict expired entries periodically (every 60s)
|
|
|
|
|
|
let lastCleanup = Date.now();
|
|
|
|
|
|
const CLEANUP_INTERVAL = 60_000;
|
|
|
|
|
|
|
|
|
|
|
|
function cleanup() {
|
2026-02-14 11:47:26 +01:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
if (now - lastCleanup < CLEANUP_INTERVAL) return;
|
|
|
|
|
|
lastCleanup = now;
|
|
|
|
|
|
for (const [key, entry] of store) {
|
|
|
|
|
|
if (now > entry.expiresAt) store.delete(key);
|
|
|
|
|
|
}
|
2026-02-14 10:30:27 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Check if a request should be allowed.
|
|
|
|
|
|
* @param ip - Client IP address
|
|
|
|
|
|
* @param maxRpm - Maximum requests per minute
|
|
|
|
|
|
* @returns { success, remaining, resetAt (epoch ms) }
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function checkRateLimit(
|
2026-02-14 11:47:26 +01:00
|
|
|
|
ip: string,
|
|
|
|
|
|
maxRpm: number,
|
2026-02-14 10:30:27 +01:00
|
|
|
|
): { success: boolean; remaining: number; resetAt: number } {
|
2026-02-14 11:47:26 +01:00
|
|
|
|
cleanup();
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
const windowMs = 60_000;
|
|
|
|
|
|
const key = `rl:${ip}`;
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
|
const entry = store.get(key);
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
|
// New window or expired
|
|
|
|
|
|
if (!entry || now > entry.expiresAt) {
|
|
|
|
|
|
const resetAt = now + windowMs;
|
|
|
|
|
|
store.set(key, { count: 1, expiresAt: resetAt });
|
|
|
|
|
|
return { success: true, remaining: maxRpm - 1, resetAt };
|
|
|
|
|
|
}
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
|
// Within window
|
|
|
|
|
|
entry.count++;
|
|
|
|
|
|
if (entry.count > maxRpm) {
|
|
|
|
|
|
return { success: false, remaining: 0, resetAt: entry.expiresAt };
|
|
|
|
|
|
}
|
2026-02-14 10:30:27 +01:00
|
|
|
|
|
2026-02-14 11:47:26 +01:00
|
|
|
|
return { success: true, remaining: maxRpm - entry.count, resetAt: entry.expiresAt };
|
2026-02-14 10:30:27 +01:00
|
|
|
|
}
|