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.

242 lines (235 loc) 9.23 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { ReqoalInstance: () => ReqoalInstance, clear: () => clear, coalesce: () => coalesce, invalidate: () => invalidate, isCoalesced: () => isCoalesced, prune: () => prune, setKeyGenerator: () => setKeyGenerator }); module.exports = __toCommonJS(src_exports); // src/instance/index.ts var import_json_stable_stringify = __toESM(require("json-stable-stringify"), 1); // 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((0, import_json_stable_stringify.default)(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); // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { ReqoalInstance, clear, coalesce, invalidate, isCoalesced, prune, setKeyGenerator });