UNPKG

idmp

Version:

A lightweight TypeScript library for deduplicating and caching async function calls with automatic retries, designed for idempotent network requests in React and Node.js.

483 lines (482 loc) 15.8 kB
/*! idmp v4.1.0 | (c) github/haozi | MIT */ Object.defineProperties(exports, { __esModule: { value: true }, [Symbol.toStringTag]: { value: "Module" } }); //#region ../../dist/index.js /*! idmp v4.1.0 | (c) github/haozi | MIT */ /** * Internal status enum for promise states */ var Status = /* @__PURE__ */ function(Status) { /** Promise hasn't been initiated yet */ Status[Status["UNSENT"] = 0] = "UNSENT"; /** Promise is in progress */ Status[Status["OPENING"] = 1] = "OPENING"; /** Promise was aborted (reserved for future use) */ Status[Status["ABORTED"] = 2] = "ABORTED"; /** Promise was rejected */ Status[Status["REJECTED"] = 3] = "REJECTED"; /** Promise was resolved successfully */ Status[Status["RESOLVED"] = 4] = "RESOLVED"; return Status; }(Status || {}); /** * Internal keys enum for accessing cache store properties */ var K = /* @__PURE__ */ function(K) { K[K["retryCount"] = 0] = "retryCount"; K[K["status"] = 1] = "status"; K[K["pendingList"] = 2] = "pendingList"; K[K["resolvedData"] = 3] = "resolvedData"; K[K["rejectionError"] = 4] = "rejectionError"; K[K["cachedPromiseFunc"] = 5] = "cachedPromiseFunc"; K[K["timerId"] = 6] = "timerId"; K[K["_originalErrorStack"] = 7] = "_originalErrorStack"; return K; }(K || {}); var DEFAULT_MAX_AGE = 3e3; var _7days = 6048e5; var noop = () => {}; var UNDEFINED$1 = void 0; var $timeout = setTimeout; var $clearTimeout = clearTimeout; var getMax = (a, b) => a > b ? a : b; var getMin = (a, b) => a < b ? a : b; /** * Makes an object's properties read-only to prevent mutation * @param obj - Object to make read-only * @param key - Property key to make read-only * @param value - Value to assign to the property * @param visited - WeakSet to track visited objects and prevent circular references * @returns true if successful, false otherwise */ var defineReactive = (obj, key, value, visited) => { try { var _Object$getOwnPropert, _Object$getOwnPropert2; Object.prototype.toString.call(value); readonly(value, visited); Object.defineProperty(obj, key, { configurable: false, enumerable: (_Object$getOwnPropert = (_Object$getOwnPropert2 = Object.getOwnPropertyDescriptor(obj, key)) === null || _Object$getOwnPropert2 === void 0 ? void 0 : _Object$getOwnPropert2.enumerable) !== null && _Object$getOwnPropert !== void 0 ? _Object$getOwnPropert : true, get: () => value, set: (newValue) => { const msg = `[idmp error] The data is read-only, set ${key.toString()}=${JSON.stringify(newValue)} is not allow`; console.error(`%c ${msg}`, "font-weight: lighter; color: red", newValue); throw new Error(msg); } }); return true; } catch (_unused) { /* istanbul ignore next */ return false; } }; /** * Recursively makes an object and its properties read-only * @param obj - Object to make read-only * @param visited - WeakSet to track visited objects and prevent circular references * @returns The read-only object, or original object if readonly fails */ var readonly = (obj, visited) => { try { if (obj == null || typeof obj !== "object") return obj; const protoType = Object.prototype.toString.call(obj); if (!["[object Object]", "[object Array]"].includes(protoType)) return obj; const isImmerDraft = (obj) => !!obj[Symbol.for("immer-state")]; if (isImmerDraft(obj)) return obj; const proto = Object.getPrototypeOf(obj); if (proto !== Object.prototype && proto !== Array.prototype && proto !== null) /* istanbul ignore next */ return obj; if (!visited) visited = /* @__PURE__ */ new WeakSet(); if (visited.has(obj)) return obj; visited.add(obj); Object.keys(obj).forEach((key) => { try { const descriptor = Object.getOwnPropertyDescriptor(obj, key); if (!descriptor || descriptor.configurable === false) return; if (descriptor.get || descriptor.set) return; defineReactive(obj, key, obj[key], visited); } catch (_unused2) {} }); return obj; } catch (_unused3) { /* istanbul ignore next */ return obj; } }; /** * Ensures maxAge is within valid range (0 to 7 days) * @param maxAge - The requested max age in milliseconds * @returns A valid max age value */ var getRange = (maxAge) => { if (maxAge < 0) return 0; if (maxAge > _7days) return _7days; return maxAge; }; /** * Global store for caching promises and their results * Contains tracking information for each promise identified by globalKey */ var _globalStore = {}; /** * Normalizes options, applying defaults as needed * @param options - User-provided options * @returns Normalized options object */ var getOptions = (options) => { const { maxRetry = 30, maxAge: paramMaxAge = DEFAULT_MAX_AGE, minRetryDelay = 50, maxRetryDelay = 5e3, onBeforeRetry = noop, signal } = options || {}; return { maxRetry, maxAge: getRange(paramMaxAge), minRetryDelay, maxRetryDelay, onBeforeRetry, f: paramMaxAge === Infinity, signal }; }; /** * Clears the cached result for a specific key * @param globalKey - The key to clear from cache */ var flush = (globalKey) => { if (!globalKey) return; const cache = _globalStore[globalKey]; if (!cache) return; cache[K.timerId] && $clearTimeout(cache[K.timerId]); _globalStore[globalKey] = UNDEFINED$1; }; /** * Clears all cached results */ var flushAll = () => { for (let key of Object.keys(_globalStore)) flush(key); _globalStore = {}; }; /** * Main IDMP function - Intelligent Deduplication of Multiple Promises * * Ensures that multiple calls to the same asynchronous operation (identified by globalKey) * will reuse the same promise, avoiding duplicate network requests or operations. * Includes automatic retry logic, caching, and request deduplication. * * @param globalKey - Unique identifier for this promise operation * @param promiseFunc - Function that returns the promise to execute * @param options - Configuration options * @returns Promise resolving to the result of promiseFunc * * @example * ```typescript * // Basic usage * const data = await idmp('user-123', () => fetchUserData(123)); * * // With options * const data = await idmp('user-123', () => fetchUserData(123), { * maxRetry: 5, * maxAge: 60000, // 1 minute cache * onBeforeRetry: (err, {retryCount}) => console.log(`Retry #${retryCount}`) * }); * ``` */ var idmp = (globalKey, promiseFunc, options) => { if (process.env.NODE_ENV !== "production") options = readonly(options); if (!globalKey) return promiseFunc(); const { maxRetry, minRetryDelay, maxRetryDelay, maxAge, onBeforeRetry, f: isFiniteParamMaxAge, signal } = getOptions(options); _globalStore[globalKey] = _globalStore[globalKey] || [ 0, Status.UNSENT, [] ]; const cache = _globalStore[globalKey]; let callStackLocation = ""; const printLogs = (...msg) => { if (typeof window === "undefined") return; try { if (localStorage.idmp_debug === "false") return; } catch (_unused4) {} /* istanbul ignore else -- Fallback for envs without console.groupCollapsed */ if (console.groupCollapsed) { console.groupCollapsed(...msg); console.log("globalKey:", globalKey); console.log("callStackLocation:", callStackLocation); console.log("data:", cache[K.resolvedData]); console.groupEnd(); } else console.log(...msg); }; /** * Resets the promise state for retrying */ const reset = () => { cache[K.status] = Status.UNSENT; cache[K.resolvedData] = cache[K.rejectionError] = UNDEFINED$1; }; /** * Resolves all pending promises with the cached result */ const doResolves = () => { const len = cache[K.pendingList].length; for (let i = 0; i < len; ++i) { cache[K.pendingList][i][0](cache[K.resolvedData]); if (process.env.NODE_ENV !== "production") if (i === 0) printLogs(`%c[idmp debug] ${globalKey === null || globalKey === void 0 ? void 0 : globalKey.toString()} from origin`, "font-weight: lighter"); else printLogs(`%c[idmp debug] ${globalKey === null || globalKey === void 0 ? void 0 : globalKey.toString()} from cache`, "color: gray; font-weight: lighter"); } cache[K.pendingList] = []; if (!isFiniteParamMaxAge) cache[K.timerId] = $timeout(() => { flush(globalKey); }, maxAge); }; /** * Rejects all pending promises with the cached error */ const doRejects = () => { const len = cache[K.pendingList].length; let maxLen; maxLen = len - maxRetry; if (maxLen < 0 || !isFinite(len)) maxLen = getMax(1, cache[K.pendingList].length - 3); for (let i = 0; i < maxLen; ++i) cache[K.pendingList][i][1](cache[K.rejectionError]); flush(globalKey); }; /** * Creates and manages the actual promise execution */ const executePromise = () => new Promise((resolve, reject) => { !cache[K.cachedPromiseFunc] && (cache[K.cachedPromiseFunc] = promiseFunc); if (process.env.NODE_ENV !== "production") try { if (cache[K.retryCount] === 0) throw new Error(); } catch (err) { const getCodeLine = (stack, offset = 0) => { if (typeof globalKey === "symbol") return ""; try { let arr = stack.split("\n").filter((o) => o.includes(":")); let idx = Infinity; $0: for (let key of [ "idmp/src/index.ts", "idmp/", "idmp\\", "idmp" ]) { let _idx = arr.length - 1; $1: for (; _idx >= 0; --_idx) if (arr[_idx].indexOf(key) > -1) { idx = _idx; break $0; } } const line = arr[idx + offset + 1] || ""; if (line.includes("idmp")) return line; /* istanbul ignore next */ return ""; } catch (_unused5) { /* istanbul ignore next */ return ""; } }; callStackLocation = getCodeLine(err.stack, 1).split(" ").pop() || ""; !cache[K._originalErrorStack] && (cache[K._originalErrorStack] = err.stack); if (cache[K._originalErrorStack] !== err.stack) { const line1 = getCodeLine(cache[K._originalErrorStack]); const line2 = getCodeLine(err.stack); if (line1 && line2 && line1 !== line2) console.error(`[idmp warn] the same key \`${globalKey.toString()}\` may be used multiple times in different places\n(It may be a misjudgment and can be ignored):\nsee https://github.com/ha0z1/idmp?tab=readme-ov-file#implementation \n${[ `1.${line1} ${cache[K._originalErrorStack]}`, "------------", `2.${line2} ${err.stack}` ].join("\n")}`); } } if (cache[K.status] === Status.RESOLVED) { if (process.env.NODE_ENV !== "production") printLogs(`%c[idmp debug] \`${globalKey === null || globalKey === void 0 ? void 0 : globalKey.toString()}\` from cache`, "color: gray;font-weight: lighter"); resolve(cache[K.resolvedData]); return; } if (signal) { if (signal.aborted) return; signal.addEventListener("abort", () => { reset(); cache[K.rejectionError] = new DOMException(signal.reason, "AbortError"); doRejects(); }); } if (cache[K.status] === Status.UNSENT) { cache[K.status] = Status.OPENING; cache[K.pendingList].push([resolve, reject]); cache[K.cachedPromiseFunc]().then((data) => { if (process.env.NODE_ENV !== "production") cache[K.resolvedData] = readonly(data); else cache[K.resolvedData] = data; doResolves(); cache[K.status] = Status.RESOLVED; }).catch((err) => { cache[K.status] = Status.REJECTED; cache[K.rejectionError] = err; ++cache[K.retryCount]; if (cache[K.retryCount] > maxRetry) doRejects(); else { onBeforeRetry(err, { globalKey, retryCount: cache[K.retryCount] }); reset(); $timeout(executePromise, getMin(maxRetryDelay, minRetryDelay * Math.pow(2, cache[K.retryCount] - 1))); } }); } else if (cache[K.status] === Status.OPENING) cache[K.pendingList].push([resolve, reject]); }); return executePromise(); }; /** * Clear the cached result for a specific key */ idmp.flush = flush; /** * Clear all cached results */ idmp.flushAll = flushAll; idmp._s = process.env.NODE_ENV !== "production" ? _globalStore : UNDEFINED$1; //#endregion //#region \0@oxc-project+runtime@0.121.0/helpers/asyncToGenerator.js function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { e(n); return; } i.done ? t(u) : Promise.resolve(u).then(r, o); } function _asyncToGenerator(n) { return function() { var t = this, e = arguments; return new Promise(function(r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; } //#endregion //#region src/index.ts var UNDEFINED = void 0; var PREFIX = "@idmp/v4/"; var getCacheKey = (globalKey) => `${PREFIX}${globalKey}`; /** * Initialize a safe storage utility (localStorage or sessionStorage) with get/set/remove/clear methods * @param storageType - Either 'localStorage' or 'sessionStorage' */ var initStorage = (storageType) => { let storage; try { storage = window[storageType]; } catch (_unused) {} /** * Remove a cached item by key * @param key - Global cache key */ const remove = (key) => { if (!key) return; try { const cacheKey = getCacheKey(key); storage.removeItem(cacheKey); } catch (_unused2) {} }; /** * Retrieve cached data if available and not expired * @param key - Global cache key * @returns Cached data or undefined if not found or expired */ const get = (key) => { if (!key) return; const cacheKey = getCacheKey(key); let localData; try { localData = JSON.parse(storage.getItem(cacheKey) || ""); if (localData === UNDEFINED) return; const { t, a: maxAge, d: data } = localData; if (Date.now() - t > maxAge) { remove(cacheKey); return; } return data; } catch (_unused3) {} }; /** * Set data into storage with expiration * @param key - Global cache key * @param data - Data to cache * @param maxAge - Time in milliseconds before expiration */ const set = (key, data, maxAge) => { if (!key) return; const cacheKey = getCacheKey(key); try { storage.setItem(cacheKey, JSON.stringify({ t: Date.now(), a: maxAge, d: data })); } catch (_unused4) {} }; /** * Clear all cached entries in the current storage matching the specific prefix */ const clear = () => { try { for (let i = storage.length - 1; i >= 0; i--) { const key = storage.key(i); if (key && key.startsWith(PREFIX)) remove(key); } } catch (_unused5) {} }; return { get, set, remove, clear }; }; /** * Wrap an idmp instance with browser storage (localStorage or sessionStorage) for persistent caching * @param _idmp - Original idmp instance * @param storageType - Storage type to use, default is 'sessionStorage' * @returns Wrapped idmp instance with persistent caching */ var storageIdmpWrap = (_idmp, storageType = "sessionStorage") => { const storage = initStorage(storageType); const newIdmp = (globalKey, promiseFunc, options) => { const finalOptions = getOptions(options); return _idmp(globalKey, _asyncToGenerator(function* () { const localData = storage.get(globalKey); if (localData !== UNDEFINED) { if (process.env.NODE_ENV !== "production") console.log(`[idmp-plugin browser-storage debug] ${globalKey} from ${storageType}["${getCacheKey(globalKey)}"]`); return localData; } const memoryData = yield promiseFunc(); if (memoryData !== UNDEFINED) storage.set(globalKey, memoryData, finalOptions.maxAge); return memoryData; }), options); }; /** * Flush both idmp memory and browser storage cache for a specific key * @param globalKey - Global cache key */ newIdmp.flush = (globalKey) => { _idmp.flush(globalKey); storage.remove(globalKey); }; /** * Flush all idmp memory and browser storage cache */ newIdmp.flushAll = () => { _idmp.flushAll(); storage.clear(); }; return newIdmp; }; //#endregion exports.default = storageIdmpWrap; exports.getCacheKey = getCacheKey;