76 lines
2.1 KiB
TypeScript
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 };
|
|
},
|
|
};
|
|
}
|