trptk/lib/rateLimit.ts
2026-02-24 17:14:07 +01:00

76 lines
2.1 KiB
TypeScript

/**
* Simple in-memory sliding-window rate limiter.
* Each instance tracks a single "scope" (e.g. login, register).
*
* Not shared across serverless instances — if deployed to a
* multi-instance environment, swap this for a Redis-backed limiter.
*/
type Entry = { timestamps: number[] };
/**
* Extract the client IP from request headers reliably.
* Prefers x-real-ip (set by Vercel/nginx), falls back to x-forwarded-for.
* Returns "unknown" only as a last resort.
*/
export function getClientIp(hdrs: Headers): string {
return (
hdrs.get("x-real-ip") ??
hdrs.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"unknown"
);
}
export function createRateLimiter({
windowMs,
maxRequests,
}: {
/** Sliding window size in milliseconds */
windowMs: number;
/** Maximum requests allowed within the window */
maxRequests: number;
}) {
const store = new Map<string, Entry>();
// Periodically clean up stale entries to avoid unbounded memory growth
const CLEANUP_INTERVAL = 60_000;
let lastCleanup = Date.now();
function cleanup() {
const now = Date.now();
if (now - lastCleanup < CLEANUP_INTERVAL) return;
lastCleanup = now;
const cutoff = now - windowMs;
for (const [key, entry] of store) {
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
if (entry.timestamps.length === 0) store.delete(key);
}
}
return {
/**
* Check whether a request from `key` is allowed.
* Returns `{ allowed: true }` or `{ allowed: false, retryAfterMs }`.
*/
check(key: string): { allowed: true } | { allowed: false; retryAfterMs: number } {
cleanup();
const now = Date.now();
const cutoff = now - windowMs;
const entry = store.get(key) ?? { timestamps: [] };
// Drop timestamps outside the window
entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
if (entry.timestamps.length >= maxRequests) {
const oldest = entry.timestamps[0];
return { allowed: false, retryAfterMs: oldest + windowMs - now };
}
entry.timestamps.push(now);
store.set(key, entry);
return { allowed: true };
},
};
}