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 atb-52-css-token-extraction 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}