/** * Rate limiters (TASK-0091 R4/R6). * * - {@link TokenBucket} — per-socket SUSTAINED message rate (100 msg/s; * overflow → close 4008 + audit). Continuous refill: capacity tokens, * `rate` tokens/second. Deterministic via an injectable clock. * - {@link ConnectLimiter} — connect-time admission (30 conns/min/IP): * a sliding window of connect timestamps per key. * * Both are pure (no timers) so the heartbeat/limit integration tests can * drive them with a fake clock. */ export interface TokenBucketOpts { /** Bucket capacity (= burst allowance). */ readonly capacity: number; /** Sustained tokens per second. */ readonly rate: number; /** Injectable clock (ms) — defaults to Date.now. */ readonly now?: () => number; } export class TokenBucket { private readonly capacity: number; private readonly rate: number; private readonly now: () => number; private readonly buckets = new Map(); constructor(opts: TokenBucketOpts) { this.capacity = opts.capacity; this.rate = opts.rate; this.now = opts.now ?? Date.now; } private refill(key: string, t: number): { tokens: number; last: number } { const b = this.buckets.get(key); if (b === undefined) { const bucket = { tokens: this.capacity, last: t }; this.buckets.set(key, bucket); return bucket; } const elapsed = Math.max(0, t - b.last); b.tokens = Math.min(this.capacity, b.tokens + (elapsed / 1000) * this.rate); b.last = t; return b; } /** Consume one token. Returns `{ok}` or `{ok:false, retryAfterMs}`. */ consume(key: string): { ok: true } | { ok: false; retryAfterMs: number } { const b = this.refill(key, this.now()); if (b.tokens >= 1) { b.tokens -= 1; return { ok: true }; } const deficit = 1 - b.tokens; const retryAfterMs = Math.ceil((deficit / this.rate) * 1000); return { ok: false, retryAfterMs }; } /** Peek without consuming. */ available(key: string): number { return this.refill(key, this.now()).tokens; } /** Drop a bucket (socket close / prune). */ drop(key: string): void { this.buckets.delete(key); } /** Bucket count (metrics). */ size(): number { return this.buckets.size; } /** Drop buckets idle longer than `staleMs` (housekeeping). */ sweep(staleMs: number): number { const t = this.now(); let dropped = 0; for (const [key, b] of this.buckets) { if (t - b.last > staleMs) { this.buckets.delete(key); dropped += 1; } } return dropped; } } /** * Connect-time admission over a sliding window (30 conns/min/IP): counts * distinct connect events per key inside the window. */ export class ConnectLimiter { private readonly windowMs: number; private readonly max: number; private readonly now: () => number; private readonly hits = new Map(); constructor(opts: { windowMs: number; max: number; now?: () => number }) { this.windowMs = opts.windowMs; this.max = opts.max; this.now = opts.now ?? Date.now; } /** Record a connect attempt; `ok:false` when the window is already full. */ tryAdmit(key: string): { ok: true } | { ok: false } { const t = this.now(); const cutoff = t - this.windowMs; const prev = (this.hits.get(key) ?? []).filter((x) => x > cutoff); if (prev.length >= this.max) { this.hits.set(key, prev); return { ok: false }; } prev.push(t); this.hits.set(key, prev); return { ok: true }; } /** Current in-window count for a key (tests/metrics). */ count(key: string): number { const t = this.now(); const cutoff = t - this.windowMs; return (this.hits.get(key) ?? []).filter((x) => x > cutoff).length; } drop(key: string): void { this.hits.delete(key); } size(): number { return this.hits.size; } /** Drop keys idle longer than `staleMs` (housekeeping). */ sweep(staleMs: number): number { const t = this.now(); let dropped = 0; for (const [key, arr] of this.hits) { if (arr.length === 0 || t - arr[arr.length - 1]! > staleMs) { this.hits.delete(key); dropped += 1; } } return dropped; } }