UNPKG

@gguf/claw

Version:

WhatsApp gateway CLI (Baileys web) with Pi RPC agent

304 lines (263 loc) 6.49 kB
/** * LRU-based seen event tracker with TTL support. * Prevents unbounded memory growth under high load or abuse. */ export interface SeenTrackerOptions { /** Maximum number of entries to track (default: 100,000) */ maxEntries?: number; /** TTL in milliseconds (default: 1 hour) */ ttlMs?: number; /** Prune interval in milliseconds (default: 10 minutes) */ pruneIntervalMs?: number; } export interface SeenTracker { /** Check if an ID has been seen (also marks it as seen if not) */ has: (id: string) => boolean; /** Mark an ID as seen */ add: (id: string) => void; /** Check if ID exists without marking */ peek: (id: string) => boolean; /** Delete an ID */ delete: (id: string) => void; /** Clear all entries */ clear: () => void; /** Get current size */ size: () => number; /** Stop the pruning timer */ stop: () => void; /** Pre-seed with IDs (useful for restart recovery) */ seed: (ids: string[]) => void; } interface Entry { seenAt: number; // For LRU: track order via doubly-linked list prev: string | null; next: string | null; } /** * Create a new seen tracker with LRU eviction and TTL expiration. */ export function createSeenTracker(options?: SeenTrackerOptions): SeenTracker { const maxEntries = options?.maxEntries ?? 100_000; const ttlMs = options?.ttlMs ?? 60 * 60 * 1000; // 1 hour const pruneIntervalMs = options?.pruneIntervalMs ?? 10 * 60 * 1000; // 10 minutes // Main storage const entries = new Map<string, Entry>(); // LRU tracking: head = most recent, tail = least recent let head: string | null = null; let tail: string | null = null; // Move an entry to the front (most recently used) function moveToFront(id: string): void { const entry = entries.get(id); if (!entry) { return; } // Already at front if (head === id) { return; } // Remove from current position if (entry.prev) { const prevEntry = entries.get(entry.prev); if (prevEntry) { prevEntry.next = entry.next; } } if (entry.next) { const nextEntry = entries.get(entry.next); if (nextEntry) { nextEntry.prev = entry.prev; } } // Update tail if this was the tail if (tail === id) { tail = entry.prev; } // Move to front entry.prev = null; entry.next = head; if (head) { const headEntry = entries.get(head); if (headEntry) { headEntry.prev = id; } } head = id; // If no tail, this is also the tail if (!tail) { tail = id; } } // Remove an entry from the linked list function removeFromList(id: string): void { const entry = entries.get(id); if (!entry) { return; } if (entry.prev) { const prevEntry = entries.get(entry.prev); if (prevEntry) { prevEntry.next = entry.next; } } else { head = entry.next; } if (entry.next) { const nextEntry = entries.get(entry.next); if (nextEntry) { nextEntry.prev = entry.prev; } } else { tail = entry.prev; } } // Evict the least recently used entry function evictLRU(): void { if (!tail) { return; } const idToEvict = tail; removeFromList(idToEvict); entries.delete(idToEvict); } // Prune expired entries function pruneExpired(): void { const now = Date.now(); const toDelete: string[] = []; for (const [id, entry] of entries) { if (now - entry.seenAt > ttlMs) { toDelete.push(id); } } for (const id of toDelete) { removeFromList(id); entries.delete(id); } } // Start pruning timer let pruneTimer: ReturnType<typeof setInterval> | undefined; if (pruneIntervalMs > 0) { pruneTimer = setInterval(pruneExpired, pruneIntervalMs); // Don't keep process alive just for pruning if (pruneTimer.unref) { pruneTimer.unref(); } } function add(id: string): void { const now = Date.now(); // If already exists, update and move to front const existing = entries.get(id); if (existing) { existing.seenAt = now; moveToFront(id); return; } // Evict if at capacity while (entries.size >= maxEntries) { evictLRU(); } // Add new entry at front const newEntry: Entry = { seenAt: now, prev: null, next: head, }; if (head) { const headEntry = entries.get(head); if (headEntry) { headEntry.prev = id; } } entries.set(id, newEntry); head = id; if (!tail) { tail = id; } } function has(id: string): boolean { const entry = entries.get(id); if (!entry) { add(id); return false; } // Check if expired if (Date.now() - entry.seenAt > ttlMs) { removeFromList(id); entries.delete(id); add(id); return false; } // Mark as recently used entry.seenAt = Date.now(); moveToFront(id); return true; } function peek(id: string): boolean { const entry = entries.get(id); if (!entry) { return false; } // Check if expired if (Date.now() - entry.seenAt > ttlMs) { removeFromList(id); entries.delete(id); return false; } return true; } function deleteEntry(id: string): void { if (entries.has(id)) { removeFromList(id); entries.delete(id); } } function clear(): void { entries.clear(); head = null; tail = null; } function size(): number { return entries.size; } function stop(): void { if (pruneTimer) { clearInterval(pruneTimer); pruneTimer = undefined; } } function seed(ids: string[]): void { const now = Date.now(); // Seed in reverse order so first IDs end up at front for (let i = ids.length - 1; i >= 0; i--) { const id = ids[i]; if (!entries.has(id) && entries.size < maxEntries) { const newEntry: Entry = { seenAt: now, prev: null, next: head, }; if (head) { const headEntry = entries.get(head); if (headEntry) { headEntry.prev = id; } } entries.set(id, newEntry); head = id; if (!tail) { tail = id; } } } } return { has, add, peek, delete: deleteEntry, clear, size, stop, seed, }; }