@dudousxd/nestjs-telescope
Version:
Laravel Telescope-style observability console for NestJS — core: watchers, recorder, correlation, SQLite store, headless API.
81 lines • 3.63 kB
JavaScript
// packages/core/src/nest/client-error-rate-limiter.ts
/**
* Default ceiling on the number of distinct IPs tracked at once. The limiter is
* the only state the public client-error endpoint keeps, so it MUST stay bounded
* — an attacker rotating source IPs (or sitting behind a large CGNAT/proxy pool)
* could otherwise grow the map without limit. At the cap we evict the OLDEST
* inserted IP, the one least likely to still be mid-burst, which is the right
* bias for a short-window rate limiter.
*/
const DEFAULT_MAX_TRACKED_IPS = 10_000;
/**
* A bounded, in-memory, per-IP token-bucket rate limiter for the PUBLIC
* client-error endpoint. Per-pod by design (no cross-process coordination): in a
* multi-replica deployment the same client may get up to `perMinute` requests
* PER POD — see the caveat in {@link ClientErrorsOptions.rateLimit}. The intent
* is cheap abuse-dampening on a public surface, not a hard global quota.
*
* Each IP gets a bucket holding at most `perMinute` tokens that refills linearly
* at `perMinute / 60_000` tokens per ms. A request consumes one token; an empty
* bucket means "over the limit". The bucket map is capped (insertion-order
* eviction of the oldest IP) so memory can't grow without bound.
*/
export class ClientErrorRateLimiter {
buckets = new Map();
perMinute;
maxTrackedIps;
refillPerMs;
now;
constructor(options) {
// Guard against a misconfigured zero/negative rate turning into a divide
// that never refills; treat anything <= 0 as "allow 1/min" so the endpoint
// still functions rather than locking out every caller forever.
this.perMinute = options.perMinute > 0 ? options.perMinute : 1;
this.maxTrackedIps = options.maxTrackedIps ?? DEFAULT_MAX_TRACKED_IPS;
this.refillPerMs = this.perMinute / 60_000;
this.now = options.now ?? Date.now;
}
/**
* Try to spend one token for `ip`. Returns `true` when the request is allowed
* (a token was available and consumed), `false` when the bucket is empty (over
* the limit). Refills the bucket continuously based on elapsed time so the
* effective rate is exactly `perMinute` without a coarse fixed window.
*/
tryConsume(ip) {
const nowMs = this.now();
const existing = this.buckets.get(ip);
if (existing === undefined) {
// First request from this IP: start full, immediately spend one token.
this.insert(ip, { tokens: this.perMinute - 1, lastRefillMs: nowMs });
return true;
}
const elapsedMs = nowMs - existing.lastRefillMs;
const refilled = Math.min(this.perMinute, existing.tokens + elapsedMs * this.refillPerMs);
existing.lastRefillMs = nowMs;
if (refilled < 1) {
existing.tokens = refilled;
return false;
}
existing.tokens = refilled - 1;
return true;
}
/** Number of tracked IPs (test/observability seam). */
get size() {
return this.buckets.size;
}
/**
* Insert a fresh bucket, evicting the oldest tracked IP first when at the cap.
* `Map` iterates in insertion order, so the first key is the oldest inserted.
*/
insert(ip, bucket) {
while (this.buckets.size >= this.maxTrackedIps) {
const oldest = this.buckets.keys().next().value;
if (oldest === undefined)
break;
this.buckets.delete(oldest);
}
this.buckets.set(ip, bucket);
}
}
export { DEFAULT_MAX_TRACKED_IPS };
//# sourceMappingURL=client-error-rate-limiter.js.map