UNPKG

reqoal

Version:

A lightweight and efficient JavaScript library for request coalescing — merge concurrent identical async calls into a single request to reduce load and improve performance.

199 lines (194 loc) 7.36 kB
// src/instance/index.ts import stringify from "json-stable-stringify"; // src/instance/default.ts var DEFAULT_PRUNE_INTERVAL_MS = 6e4; var DEFAULT_TTL_MS = 1e3; var DEFAULT_CONCURRENCY_LIMIT = Infinity; // src/instance/hash.ts function hash(str) { return fnv1a(str); } function fnv1a(str) { if (!str) return "00000000"; let hash2 = 2166136261; for (let i = 0; i < str.length; i++) { hash2 ^= str.charCodeAt(i); hash2 += (hash2 << 1) + (hash2 << 4) + (hash2 << 7) + (hash2 << 8) + (hash2 << 24); } return (hash2 >>> 0).toString(16).padStart(8, "0"); } // src/instance/index.ts var ReqoalInstance = class { /** * Create a new ReqoalInstance. * @param intervalMs Interval (ms) for pruning expired cache entries. * @param ttlMs Time-to-live (ms) for cached results. * @param consoler Optional custom console for logging. * @param maxConcurrency Maximum number of concurrent in-flight requests. */ constructor(intervalMs = DEFAULT_PRUNE_INTERVAL_MS, ttlMs = DEFAULT_TTL_MS, consoler = console, maxConcurrency = DEFAULT_CONCURRENCY_LIMIT) { this._pruneIntervalMs = DEFAULT_PRUNE_INTERVAL_MS; this._ttlMs = DEFAULT_TTL_MS; this._maxConcurrency = DEFAULT_CONCURRENCY_LIMIT; this._console = console; this._interval = null; this._resultCache = /* @__PURE__ */ new Map(); this._inFlightRequests = /* @__PURE__ */ new Map(); this._currentConcurrency = 0; this._console = consoler; this._pruneIntervalMs = intervalMs; this._ttlMs = ttlMs; this._maxConcurrency = maxConcurrency; } /** * Coalesces concurrent or repeated calls for the same function and arguments. * If an in-flight promise exists, returns it. * If a cached result exists within TTL, returns it. * Otherwise, calls the function, caches the result, and returns it. * Supports both async and sync functions. * * @template T * @template Args * @param fn The function to call (can be sync or async). * @param args Arguments to pass to the function. * @returns The result of the function, possibly from cache or in-flight request. */ async coalesce(fn, ...args) { const functionName = fn.name || "anonymous"; const key = this._createKey(functionName, ...args); if (!this._interval) this._interval = setInterval(this.prune.bind(this), this._pruneIntervalMs); const now = Date.now(); if (this._inFlightRequests.has(key)) { this._console.trace(`[requestCoalescing] Reusing in-flight request for key: ${key}`); return this._inFlightRequests.get(key); } const cacheEntry = this._resultCache.get(key); if (cacheEntry && now - cacheEntry.timestamp <= this._ttlMs) { this._console.trace(`[requestCoalescing] Returning cached result for key within TTL: ${key}`); return Promise.resolve(cacheEntry.result); } if (this._currentConcurrency >= this._maxConcurrency) { this._console.warn( `[requestCoalescing] Max concurrency (${this._maxConcurrency}) reached. Rejecting request for key: ${key}` ); return Promise.reject(new Error("Max concurrency reached")); } this._currentConcurrency++; const promise = (async () => { try { const result = await Promise.resolve(fn(...args)); this._resultCache.set(key, { result, timestamp: Date.now() }); setTimeout(() => { const entry = this._resultCache.get(key); if (entry && Date.now() - entry.timestamp >= this._ttlMs) { this._resultCache.delete(key); this._console.trace(`[requestCoalescing] Cleared cached result for key: ${key}`); } }, this._ttlMs); return result; } catch (err) { this._console.error(`[requestCoalescing] Error processing request for key: ${key}`, err); throw err; } finally { this._inFlightRequests.delete(key); this._currentConcurrency--; this._console.trace(`[requestCoalescing] Cleared in-flight request for key: ${key}`); } })(); this._inFlightRequests.set(key, promise); this._console.trace(`[requestCoalescing] Created new in-flight request for key: ${key}`); return promise; } /** * Checks if a request for the given function and arguments is either in-flight or cached (within TTL). * * @template Args * @param fn The async function to check. * @param args Arguments to check. * @returns True if a request is in-flight or cached; otherwise, false. */ isCoalesced(fn, ...args) { const functionName = fn.name || "anonymous"; const key = this._createKey(functionName, ...args); const now = Date.now(); if (this._inFlightRequests.has(key)) { return true; } const entry = this._resultCache.get(key); return !!entry && now - entry.timestamp <= this._ttlMs; } /** * Set a custom key generator for this instance. * @param keyGen A function that generates a cache key from the function name and arguments. */ setKeyGenerator(keyGen) { this._customKeyGen = keyGen; } /** * Manually prune expired cache entries. Most users do not need to call this, as pruning is automatic. */ prune() { const now = Date.now(); for (const [key, { timestamp }] of this._resultCache.entries()) { if (now - timestamp > this._ttlMs) { this._resultCache.delete(key); } } this._console.trace("[requestCoalescing] Pruned caches"); } /** * Manually invalidate the cache for a specific function and arguments. * @param fn The async function whose cache should be invalidated. * @param args Arguments to the function. */ invalidate(fn, ...args) { const functionName = fn.name || "anonymous"; const key = this._createKey(functionName, ...args); this._resultCache.delete(key); this._inFlightRequests.delete(key); this._console.trace(`[requestCoalescing] Invalidated cache and in-flight for key: ${key}`); } /** * Clear all cached results and in-flight requests in this instance. */ clear() { this._resultCache.clear(); this._inFlightRequests.clear(); this._console.trace("[requestCoalescing] Cleared all cache and in-flight requests"); } /** * Generates a unique cache key based on function name and arguments. * If a custom key generator is set via setKeyGenerator, it will be used. * Otherwise, the default is `${functionName}|${stringify(args)}`. * * @param functionName The name of the function. * @param args Arguments to the function. * @returns A string key for caching and coalescing. * @private */ _createKey(functionName, ...args) { if (this._customKeyGen) { return this._customKeyGen(functionName, ...args); } return `${functionName}|${hash(stringify(args))}`; } }; // src/index.ts var globalCoalescer = new ReqoalInstance(); var coalesce = globalCoalescer.coalesce.bind(globalCoalescer); var isCoalesced = globalCoalescer.isCoalesced.bind(globalCoalescer); var invalidate = globalCoalescer.invalidate.bind(globalCoalescer); var clear = globalCoalescer.clear.bind(globalCoalescer); var prune = globalCoalescer.prune.bind(globalCoalescer); var setKeyGenerator = globalCoalescer.setKeyGenerator.bind(globalCoalescer); export { ReqoalInstance, clear, coalesce, invalidate, isCoalesced, prune, setKeyGenerator };