interface Options> { requestAsync: (input: TInput) => Promise; getCacheKey?: (input: TInput) => string; cacheTtlMs?: number; fallbackToStaleOnError?: boolean; staleIfErrorTtlMs?: number; } type CacheEntry = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number }; const MAX_CACHE_SIZE = 1000; const DEFAULT_TTL_MS = 10_000; const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000; const evictExpired = (cache: Map>) => { const now = Date.now(); for (const [key, entry] of cache) { if (now >= entry.staleUntil) cache.delete(key); } }; export const createRequestHandler = >( options: Options, ) => { const cache = new Map>(); const inflight = new Map>>(); let expiryTimer: ReturnType | undefined; let expiryTimerAt: number | undefined; let generation = 0; const scheduleExpiry = (candidateExpiryAt?: number) => { if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return; if (expiryTimer) clearTimeout(expiryTimer); expiryTimer = undefined; expiryTimerAt = undefined; if (cache.size === 0) return; const nextExpiryAt = candidateExpiryAt ?? Math.min(...[...cache.values()].map(({ staleUntil }) => staleUntil)); expiryTimerAt = nextExpiryAt; expiryTimer = setTimeout( () => { expiryTimer = undefined; expiryTimerAt = undefined; evictExpired(cache); scheduleExpiry(); }, Math.max(0, nextExpiryAt - Date.now()), ); expiryTimer.unref?.(); }; return { invalidateCache: () => { generation += 1; cache.clear(); inflight.clear(); if (expiryTimer) clearTimeout(expiryTimer); expiryTimer = undefined; expiryTimerAt = undefined; }, handler: (input: TInput) => ({ async getDataAsync(): Promise<{ data: TData; timestamp: Date }> { const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS; const key = options.getCacheKey?.(input) ?? JSON.stringify(input); let cached = cache.get(key); const now = Date.now(); if (cached && now < cached.expiresAt) { return { data: cached.data, timestamp: cached.timestamp }; } if (cached && now >= cached.staleUntil) { cache.delete(key); scheduleExpiry(); cached = undefined; } const existing = inflight.get(key); if (existing) return existing; const requestGeneration = generation; let promise: Promise>; promise = options .requestAsync(input) .then((data) => { const expiresAt = Date.now() + ttl; const staleIfErrorTtlMs = options.fallbackToStaleOnError ? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS) : 0; const entry: CacheEntry = { data, timestamp: new Date(), expiresAt, staleUntil: expiresAt + staleIfErrorTtlMs, }; if (ttl > 0 && generation === requestGeneration) { evictExpired(cache); if (cache.size >= MAX_CACHE_SIZE) { const oldest = cache.keys().next().value; if (oldest !== undefined) cache.delete(oldest); } cache.set(key, entry); scheduleExpiry(entry.staleUntil); } return entry; }) .catch((err) => { if ( options.fallbackToStaleOnError && cached && Date.now() < cached.staleUntil && generation === requestGeneration ) { return cached; } throw err; }) .finally(() => { if (inflight.get(key) === promise) inflight.delete(key); }); inflight.set(key, promise); return promise; }, }), }; };