You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

72 lines
2.3 KiB
TypeScript

/**
* RateLimiter - Token Bucket en memoria por usuario.
* - Límite global por remitente (ID normalizado).
* - Capacidad y tasa configurables por entorno:
* - RATE_LIMIT_PER_MIN: tokens que se recargan por minuto (default 15)
* - RATE_LIMIT_BURST: capacidad del bucket (default = RATE_LIMIT_PER_MIN)
* - Desactivación en tests: la integración en server evita usar el limiter con NODE_ENV='test'.
*/
export class RateLimiter {
private static readonly buckets = new Map<string, { tokens: number; last: number }>();
private static readonly lastNotifyAt = new Map<string, number>();
private static readonly NOTIFY_COOLDOWN_MS = 60_000; // 1 minuto
private static get ratePerMin(): number {
const v = Number(process.env.RATE_LIMIT_PER_MIN);
return Number.isFinite(v) && v > 0 ? v : 15;
}
private static get capacity(): number {
const burst = Number(process.env.RATE_LIMIT_BURST);
if (Number.isFinite(burst) && burst > 0) return burst;
return this.ratePerMin; // por defecto, burst = rate
}
private static get refillPerMs(): number {
return this.ratePerMin / 60_000; // tokens por ms
}
static checkAndConsume(userId: string, now: number = Date.now()): boolean {
if (!userId) return true; // por seguridad, no bloquear IDs vacíos
let bucket = this.buckets.get(userId);
if (!bucket) {
bucket = { tokens: this.capacity, last: now };
this.buckets.set(userId, bucket);
}
// Refill lineal en función del tiempo transcurrido
const elapsed = Math.max(0, now - bucket.last);
if (elapsed > 0) {
bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsed * this.refillPerMs);
bucket.last = now;
}
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
return true;
}
return false;
}
static shouldNotify(userId: string, now: number = Date.now()): boolean {
const last = this.lastNotifyAt.get(userId);
// Si nunca se ha notificado, permitir notificar inmediatamente
if (last === undefined) {
this.lastNotifyAt.set(userId, now);
return true;
}
if (now - last >= this.NOTIFY_COOLDOWN_MS) {
this.lastNotifyAt.set(userId, now);
return true;
}
return false;
}
// Solo para tests
static reset(): void {
this.buckets.clear();
this.lastNotifyAt.clear();
}
}