WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at main 132 lines 3.9 kB view raw
1import type { Logger } from "@atbb/logger"; 2 3/** 4 * Generic in-memory TTL store with periodic cleanup. 5 * 6 * Replaces the duplicated Map + set/get/delete + cleanup interval + destroy 7 * pattern that was previously implemented independently in OAuthStateStore, 8 * OAuthSessionStore, and CookieSessionStore. 9 */ 10 11/** 12 * Generic TTL (time-to-live) store backed by a Map. 13 * 14 * Entries are lazily evicted on `get()` if expired, and periodically 15 * swept by a background cleanup interval. 16 * 17 * @typeParam V - The value type stored in the map. 18 */ 19export class TTLStore<V> { 20 private entries = new Map<string, V>(); 21 private cleanupInterval: NodeJS.Timeout; 22 private destroyed = false; 23 24 /** 25 * @param isExpired - Predicate that returns true when an entry should be evicted. 26 * @param storeName - Human-readable name used in structured log messages. 27 * @param cleanupIntervalMs - How often the background sweep runs (default: 5 minutes). 28 * @param logger - Optional structured logger for cleanup messages. 29 */ 30 constructor( 31 private readonly isExpired: (value: V) => boolean, 32 private readonly storeName: string, 33 cleanupIntervalMs = 5 * 60 * 1000, 34 private readonly logger?: Logger, 35 ) { 36 this.cleanupInterval = setInterval( 37 () => this.cleanup(), 38 cleanupIntervalMs 39 ); 40 } 41 42 /** Store an entry. */ 43 set(key: string, value: V): void { 44 if (this.destroyed) { 45 throw new Error(`Cannot set on destroyed ${this.storeName}`); 46 } 47 this.entries.set(key, value); 48 } 49 50 /** 51 * Retrieve an entry, returning `undefined` if missing or expired. 52 * Expired entries are eagerly deleted on access. 53 */ 54 get(key: string): V | undefined { 55 if (this.destroyed) { 56 throw new Error(`Cannot get from destroyed ${this.storeName}`); 57 } 58 const entry = this.entries.get(key); 59 if (entry === undefined) return undefined; 60 if (this.isExpired(entry)) { 61 this.entries.delete(key); 62 return undefined; 63 } 64 return entry; 65 } 66 67 /** Delete an entry by key. */ 68 delete(key: string): void { 69 if (this.destroyed) { 70 throw new Error(`Cannot delete from destroyed ${this.storeName}`); 71 } 72 this.entries.delete(key); 73 } 74 75 /** 76 * UNSAFE: Retrieve entry without checking expiration. 77 * 78 * Only use when you have external expiration management (e.g., OAuth library 79 * that handles token refresh internally). Most callers should use get() instead. 80 * 81 * This bypasses the TTL contract and returns stale data if the entry is expired. 82 */ 83 getUnchecked(key: string): V | undefined { 84 if (this.destroyed) { 85 throw new Error(`Cannot getUnchecked from destroyed ${this.storeName}`); 86 } 87 return this.entries.get(key); 88 } 89 90 /** 91 * Stop the background cleanup timer (for graceful shutdown). 92 * Idempotent - safe to call multiple times. 93 */ 94 destroy(): void { 95 if (this.destroyed) return; 96 this.destroyed = true; 97 clearInterval(this.cleanupInterval); 98 } 99 100 /** 101 * Sweep all expired entries from the map. 102 * Runs on the background interval; errors are caught to avoid crashing the process. 103 */ 104 private cleanup(): void { 105 try { 106 const expired: string[] = []; 107 108 this.entries.forEach((value, key) => { 109 if (this.isExpired(value)) { 110 expired.push(key); 111 } 112 }); 113 114 for (const key of expired) { 115 this.entries.delete(key); 116 } 117 118 if (expired.length > 0) { 119 this.logger?.info(`${this.storeName} cleanup completed`, { 120 operation: `${this.storeName}.cleanup`, 121 cleanedCount: expired.length, 122 remainingCount: this.entries.size, 123 }); 124 } 125 } catch (error) { 126 this.logger?.error(`${this.storeName} cleanup failed`, { 127 operation: `${this.storeName}.cleanup`, 128 error: error instanceof Error ? error.message : String(error), 129 }); 130 } 131 } 132}