Files
pieced-portal/src/lib/rate-limit.ts
2026-04-12 18:13:26 +02:00

72 lines
1.8 KiB
TypeScript

/**
* In-memory sliding-window rate limiter.
*
* Suitable for single-node deployments (pilot scale).
* For multi-replica, replace with Redis-backed store.
*/
interface RateLimitEntry {
timestamps: number[];
}
const store = new Map<string, RateLimitEntry>();
// Cleanup stale entries every 10 minutes
if (typeof globalThis !== "undefined") {
// Use globalThis to survive HMR in dev — only one interval
const key = "__rateLimitCleanup";
if (!(globalThis as any)[key]) {
(globalThis as any)[key] = setInterval(() => {
const now = Date.now();
for (const [k, entry] of store) {
entry.timestamps = entry.timestamps.filter((t) => now - t < 3_600_000);
if (entry.timestamps.length === 0) store.delete(k);
}
}, 600_000);
}
}
export interface RateLimitResult {
allowed: boolean;
remaining: number;
/** Milliseconds until the oldest request in the window expires */
resetMs: number;
}
/**
* Check and record a rate-limited action.
*
* @param key - Unique key, e.g. `register:${ip}`
* @param limit - Max allowed actions in the window
* @param windowMs - Window size in milliseconds
*/
export function rateLimit(
key: string,
limit: number,
windowMs: number,
): RateLimitResult {
const now = Date.now();
const entry = store.get(key) ?? { timestamps: [] };
// Prune expired timestamps
entry.timestamps = entry.timestamps.filter((t) => now - t < windowMs);
if (entry.timestamps.length >= limit) {
const oldest = entry.timestamps[0];
return {
allowed: false,
remaining: 0,
resetMs: oldest + windowMs - now,
};
}
entry.timestamps.push(now);
store.set(key, entry);
return {
allowed: true,
remaining: limit - entry.timestamps.length,
resetMs: entry.timestamps[0] + windowMs - now,
};
}