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
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}