/** * In-memory, per-key request limiter used as a coarse anti-flood backstop for * the whole HTTP API. * * The semantics are a fixed window per key: the first request of a window * starts the clock, every later request in the same window increments the * counter, and an expired window is reset on the next request. This mirrors * the `login_attempts` window logic already used for login lockouts * (`server/app.ts`), but it never touches the database: a rate limit decision * must stay cheap enough to run on every request. * * Precise controls (per-IP login lockout, dangerous-operation re-auth) remain * in place on top of this limiter; it only stops a client from issuing an * abusive number of requests across all endpoints. */ export type RateLimiterOptions = { /** Maximum number of requests allowed per key inside one window. */ limit: number; /** Window length in milliseconds. */ windowMs: number; /** Injectable clock so tests can advance time without waiting. */ now?: () => number; }; export type RateLimitDecision = { allowed: boolean; /** Seconds the caller should wait before retrying; 0 when allowed. */ retryAfterSeconds: number; }; type Bucket = { count: number; windowStart: number; }; /** Run a full sweep every N checks instead of on every call. */ const SWEEP_INTERVAL_CHECKS = 1000; export function createRateLimiter(options: RateLimiterOptions) { const { limit, windowMs } = options; if (!Number.isInteger(limit) || limit < 1) throw new Error("rate limit 必须是大于等于 1 的整数"); if (!Number.isInteger(windowMs) || windowMs < 1) throw new Error("rate limit 窗口必须是大于等于 1 的整数毫秒数"); const now = options.now ?? Date.now; const buckets = new Map(); let checksSinceSweep = 0; return { check(key: string): RateLimitDecision { const current = now(); let bucket = buckets.get(key); // A key that is unknown or whose window has already elapsed starts a // fresh window. This also recycles the single key being hit, so an // idle client never leaves a stale counter behind. if (!bucket || current - bucket.windowStart >= windowMs) { bucket = { count: 0, windowStart: current }; buckets.set(key, bucket); } // Bounds long-running memory growth: keys that stopped sending traffic // are dropped by an amortized periodic sweep rather than on every call. if (++checksSinceSweep >= SWEEP_INTERVAL_CHECKS) { checksSinceSweep = 0; for (const [candidateKey, candidate] of buckets) { if (current - candidate.windowStart >= windowMs) buckets.delete(candidateKey); } } if (bucket.count >= limit) { return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil((bucket.windowStart + windowMs - current) / 1000)) }; } bucket.count += 1; return { allowed: true, retryAfterSeconds: 0 }; }, /** Number of tracked keys; used to observe lazy cleanup. */ size(): number { return buckets.size; }, }; } export type RateLimiter = ReturnType;