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
JavaScript
// 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
};