SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
4.4 KB · 134 lines typescript
Raw Blame History
1import Redis from "ioredis";2import { config } from "./config";34/**5 * Redis client with an in-memory fallback for local development. Production requires6 * REDIS_URL — the fallback logs a warning and only works within a single process.7 */8export interface KV {9  incrWithTtl(key: string, ttlSec: number): Promise<number>;10  decr(key: string): Promise<number>;11  get(key: string): Promise<string | null>;12  set(key: string, value: string, ttlSec?: number): Promise<void>;13  del(key: string): Promise<void>;14  /** Sliding-window counter: returns count within window after adding one hit. */15  slidingWindow(key: string, windowMs: number, limit: number): Promise<{ count: number; allowed: boolean; retryAfterMs: number }>;16  ping(): Promise<boolean>;17  kind: "redis" | "memory";18}1920class MemoryKV implements KV {21  kind = "memory" as const;22  private store = new Map<string, { v: string; exp: number | null }>();23  private windows = new Map<string, number[]>();2425  private alive(k: string) {26    const e = this.store.get(k);27    if (!e) return null;28    if (e.exp !== null && e.exp < Date.now()) {29      this.store.delete(k);30      return null;31    }32    return e;33  }34  async incrWithTtl(key: string, ttlSec: number) {35    const e = this.alive(key);36    const n = (e ? Number(e.v) : 0) + 1;37    this.store.set(key, { v: String(n), exp: e?.exp ?? Date.now() + ttlSec * 1000 });38    return n;39  }40  async decr(key: string) {41    const e = this.alive(key);42    const n = Math.max(0, (e ? Number(e.v) : 0) - 1);43    if (e) this.store.set(key, { v: String(n), exp: e.exp });44    return n;45  }46  async get(key: string) {47    return this.alive(key)?.v ?? null;48  }49  async set(key: string, value: string, ttlSec?: number) {50    this.store.set(key, { v: value, exp: ttlSec ? Date.now() + ttlSec * 1000 : null });51  }52  async del(key: string) {53    this.store.delete(key);54  }55  async slidingWindow(key: string, windowMs: number, limit: number) {56    const now = Date.now();57    const arr = (this.windows.get(key) ?? []).filter((t) => t > now - windowMs);58    arr.push(now);59    this.windows.set(key, arr);60    const allowed = arr.length <= limit;61    return { count: arr.length, allowed, retryAfterMs: allowed ? 0 : Math.max(0, arr[0]! + windowMs - now) };62  }63  async ping() {64    return true;65  }66}6768class RedisKV implements KV {69  kind = "redis" as const;70  constructor(readonly client: Redis) {}71  async incrWithTtl(key: string, ttlSec: number) {72    const res = await this.client.multi().incr(key).expire(key, ttlSec, "NX").exec();73    return Number(res?.[0]?.[1] ?? 0);74  }75  async decr(key: string) {76    const n = await this.client.decr(key);77    if (n < 0) await this.client.set(key, "0");78    return Math.max(0, n);79  }80  async get(key: string) {81    return this.client.get(key);82  }83  async set(key: string, value: string, ttlSec?: number) {84    if (ttlSec) await this.client.set(key, value, "EX", ttlSec);85    else await this.client.set(key, value);86  }87  async del(key: string) {88    await this.client.del(key);89  }90  async slidingWindow(key: string, windowMs: number, limit: number) {91    const now = Date.now();92    const member = `${now}-${Math.random().toString(36).slice(2, 8)}`;93    const res = await this.client94      .multi()95      .zremrangebyscore(key, 0, now - windowMs)96      .zadd(key, now, member)97      .zcard(key)98      .zrange(key, 0, 0, "WITHSCORES")99      .pexpire(key, windowMs)100      .exec();101    const count = Number(res?.[2]?.[1] ?? 0);102    const oldest = Number((res?.[3]?.[1] as string[] | undefined)?.[1] ?? now);103    const allowed = count <= limit;104    return { count, allowed, retryAfterMs: allowed ? 0 : Math.max(0, oldest + windowMs - now) };105  }106  async ping() {107    try {108      return (await this.client.ping()) === "PONG";109    } catch {110      return false;111    }112  }113}114115let _kv: KV | null = null;116117export function getKV(): KV {118  if (_kv) return _kv;119  if (config.redisUrl) {120    const client = new Redis(config.redisUrl, { maxRetriesPerRequest: 2, enableOfflineQueue: true, lazyConnect: false });121    client.on("error", (e) => console.error("[redis]", e.message));122    _kv = new RedisKV(client);123  } else {124    if (config.env === "production") console.warn("[redis] REDIS_URL not set — falling back to in-memory limits (single process only)");125    _kv = new MemoryKV();126  }127  return _kv;128}129130export async function closeKV(): Promise<void> {131  if (_kv && _kv.kind === "redis") await (_kv as RedisKV).client.quit().catch(() => {});132  _kv = null;133}134