import Redis from "ioredis"; import { config } from "./config"; /** * Redis client with an in-memory fallback for local development. Production requires * REDIS_URL — the fallback logs a warning and only works within a single process. */ export interface KV { incrWithTtl(key: string, ttlSec: number): Promise; decr(key: string): Promise; get(key: string): Promise; set(key: string, value: string, ttlSec?: number): Promise; del(key: string): Promise; /** Sliding-window counter: returns count within window after adding one hit. */ slidingWindow(key: string, windowMs: number, limit: number): Promise<{ count: number; allowed: boolean; retryAfterMs: number }>; ping(): Promise; kind: "redis" | "memory"; } class MemoryKV implements KV { kind = "memory" as const; private store = new Map(); private windows = new Map(); private alive(k: string) { const e = this.store.get(k); if (!e) return null; if (e.exp !== null && e.exp < Date.now()) { this.store.delete(k); return null; } return e; } async incrWithTtl(key: string, ttlSec: number) { const e = this.alive(key); const n = (e ? Number(e.v) : 0) + 1; this.store.set(key, { v: String(n), exp: e?.exp ?? Date.now() + ttlSec * 1000 }); return n; } async decr(key: string) { const e = this.alive(key); const n = Math.max(0, (e ? Number(e.v) : 0) - 1); if (e) this.store.set(key, { v: String(n), exp: e.exp }); return n; } async get(key: string) { return this.alive(key)?.v ?? null; } async set(key: string, value: string, ttlSec?: number) { this.store.set(key, { v: value, exp: ttlSec ? Date.now() + ttlSec * 1000 : null }); } async del(key: string) { this.store.delete(key); } async slidingWindow(key: string, windowMs: number, limit: number) { const now = Date.now(); const arr = (this.windows.get(key) ?? []).filter((t) => t > now - windowMs); arr.push(now); this.windows.set(key, arr); const allowed = arr.length <= limit; return { count: arr.length, allowed, retryAfterMs: allowed ? 0 : Math.max(0, arr[0]! + windowMs - now) }; } async ping() { return true; } } class RedisKV implements KV { kind = "redis" as const; constructor(readonly client: Redis) {} async incrWithTtl(key: string, ttlSec: number) { const res = await this.client.multi().incr(key).expire(key, ttlSec, "NX").exec(); return Number(res?.[0]?.[1] ?? 0); } async decr(key: string) { const n = await this.client.decr(key); if (n < 0) await this.client.set(key, "0"); return Math.max(0, n); } async get(key: string) { return this.client.get(key); } async set(key: string, value: string, ttlSec?: number) { if (ttlSec) await this.client.set(key, value, "EX", ttlSec); else await this.client.set(key, value); } async del(key: string) { await this.client.del(key); } async slidingWindow(key: string, windowMs: number, limit: number) { const now = Date.now(); const member = `${now}-${Math.random().toString(36).slice(2, 8)}`; const res = await this.client .multi() .zremrangebyscore(key, 0, now - windowMs) .zadd(key, now, member) .zcard(key) .zrange(key, 0, 0, "WITHSCORES") .pexpire(key, windowMs) .exec(); const count = Number(res?.[2]?.[1] ?? 0); const oldest = Number((res?.[3]?.[1] as string[] | undefined)?.[1] ?? now); const allowed = count <= limit; return { count, allowed, retryAfterMs: allowed ? 0 : Math.max(0, oldest + windowMs - now) }; } async ping() { try { return (await this.client.ping()) === "PONG"; } catch { return false; } } } let _kv: KV | null = null; export function getKV(): KV { if (_kv) return _kv; if (config.redisUrl) { const client = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, enableOfflineQueue: true, lazyConnect: false }); client.on("error", (e) => console.error("[redis]", e.message)); _kv = new RedisKV(client); } else { if (config.env === "production") console.warn("[redis] REDIS_URL not set — falling back to in-memory limits (single process only)"); _kv = new MemoryKV(); } return _kv; } export async function closeKV(): Promise { if (_kv && _kv.kind === "redis") await (_kv as RedisKV).client.quit().catch(() => {}); _kv = null; }