/** * Utility for deduplicating concurrent async calls and optionally caching results for a short TTL. * * - Deduplication: If multiple calls are made with the same arguments while one is in-flight, * they will all share the same Promise instead of creating multiple requests. * * - Caching: If a TTL (time-to-live) is specified, the last successful (or optionally failed) result * will be reused for repeated calls within the TTL window. * * Example usage: * * const apiCaller = new ThrottledApiCaller(fetchCart, { ttlMs: 5000 }); * const result = await apiCaller.call({ userId: "abc" }); */ export class ThrottledApiCaller { /** * Stores in-flight Promises keyed by args hash. * Ensures that simultaneous calls with the same args share the same Promise. */ private inflight = new Map>(); /** * Stores cached results (or errors) along with expiration timestamps. * Only used if ttlMs > 0. */ private cache = new Map< string, { value?: TResult; error?: unknown; expiresAt: number } >(); /** Function used to create a cache key from args. */ private readonly keyFn: (args: TArgs) => string; /** Time-to-live (ms) for caching results. If 0, no result caching is applied. */ private readonly ttlMs: number; /** Whether to cache failed results (errors). */ private readonly cacheErrors: boolean; /** * @param fn The async function you want to call in a deduped/cached manner. * @param opts Options: * - keyFn: custom function to turn args into a cache key (default: stable JSON stringify). * - ttlMs: cache result duration in ms (default: 0, disabled). * - cacheErrors: if true, errors are cached within TTL window (default: false). */ constructor( private readonly fn: (args: TArgs) => Promise, opts: { keyFn?: (args: TArgs) => string; ttlMs?: number; cacheErrors?: boolean; } = {} ) { this.keyFn = opts.keyFn ?? stableKey; this.ttlMs = Math.max(0, opts.ttlMs ?? 0); this.cacheErrors = !!opts.cacheErrors; } /** * Executes the wrapped function in a deduplicated and optionally cached manner. * - If a cached result is still fresh, return it immediately. * - If an in-flight request exists for the same args, return that Promise. * - Otherwise, run the function, store its Promise in inflight, and cache its result. */ async call(args: TArgs): Promise { const key = this.keyFn(args); const now = Date.now(); // 1. Serve from cache if still valid if (this.ttlMs > 0) { const hit = this.cache.get(key); if (hit && hit.expiresAt > now) { if (hit.error !== undefined) throw hit.error; return hit.value as TResult; } } // 2. Share ongoing in-flight call if one exists const existing = this.inflight.get(key); if (existing) return existing; // 3. Run new request const promise = (async () => { try { const result = await this.fn(args); // Cache the result if TTL is set if (this.ttlMs > 0) { this.cache.set(key, { value: result, expiresAt: now + this.ttlMs, }); } return result; } catch (err) { // Optionally cache the error too if (this.ttlMs > 0 && this.cacheErrors) { this.cache.set(key, { error: err, expiresAt: now + this.ttlMs, }); } throw err; } finally { // Always clear inflight entry when done this.inflight.delete(key); } })(); this.inflight.set(key, promise); return promise; } /** * Invalidate cache for a specific args set. * Next call with these args will trigger a fresh request. */ invalidate(args: TArgs): void { this.cache.delete(this.keyFn(args)); } /** * Clear all cache and in-flight requests. */ clear(): void { this.cache.clear(); this.inflight.clear(); } } /** * Default stable key generator for args. * Produces a deterministic string even if object keys are in different order. */ function stableKey(v: unknown): string { const seen = new WeakSet(); const encode = (x: any): any => { if (x && typeof x === "object") { if (seen.has(x)) return "__cycle__"; seen.add(x); if (Array.isArray(x)) return x.map(encode); const out: Record = {}; for (const k of Object.keys(x).sort()) out[k] = encode(x[k]); return out; } return x; }; return JSON.stringify(encode(v)); }