const CACHE_PREFIX = "fiddy:api-cache:v1:"; const CACHE_INDEX_KEY = "fiddy:api-cache-index:v1"; const MAX_CACHE_ENTRIES = 80; const TRANSIENT_STATUS_CODES = new Set([408, 429, 502, 503, 504]); const SENSITIVE_URL_PATTERN = /(receipt|download|update-image)/i; const BINARY_FIELD_PATTERN = /(image|receipt|bytes|buffer|blob|base64)/i; function getStorage() { if (typeof window === "undefined" || !window.localStorage) return null; return window.localStorage; } function getCurrentUserScope() { const storage = getStorage(); if (!storage) return null; try { return storage.getItem("userId") || null; } catch (_) { return null; } } function stableStringify(value) { if (value === null || typeof value !== "object") { return JSON.stringify(value); } if (Array.isArray(value)) { return `[${value.map(stableStringify).join(",")}]`; } return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) .join(",")}}`; } function hashString(value) { let hash = 0; for (let index = 0; index < value.length; index += 1) { hash = (hash << 5) - hash + value.charCodeAt(index); hash |= 0; } return Math.abs(hash).toString(36); } function requestFingerprint(config) { if (!config || String(config.method || "get").toLowerCase() !== "get") { return null; } if (config.responseType && config.responseType !== "json") { return null; } const url = config.url || ""; if (!url || SENSITIVE_URL_PATTERN.test(url)) { return null; } return stableStringify({ baseURL: config.baseURL || "", method: "get", params: config.params || null, url, }); } function cacheKeyForConfig(config) { const scope = getCurrentUserScope(); const fingerprint = requestFingerprint(config); if (!scope || !fingerprint) return null; return `${CACHE_PREFIX}${scope}:${hashString(fingerprint)}`; } function readIndex(storage) { try { const parsed = JSON.parse(storage.getItem(CACHE_INDEX_KEY) || "[]"); return Array.isArray(parsed) ? parsed : []; } catch (_) { return []; } } function writeIndex(storage, index) { try { storage.setItem(CACHE_INDEX_KEY, JSON.stringify(index.slice(-MAX_CACHE_ENTRIES))); } catch (_) { // Ignore cache index write failures; the live request already succeeded. } } function rememberCacheKey(storage, key, scope) { const now = Date.now(); const nextIndex = readIndex(storage) .filter((entry) => entry?.key !== key) .concat({ key, scope, touchedAt: now }); const trimmedIndex = nextIndex.slice(-MAX_CACHE_ENTRIES); for (const staleEntry of nextIndex.slice(0, -MAX_CACHE_ENTRIES)) { try { storage.removeItem(staleEntry.key); } catch (_) { // Best-effort cache pruning only. } } writeIndex(storage, trimmedIndex); } function sanitizeForCache(value) { if (Array.isArray(value)) { return value.map(sanitizeForCache); } if (!value || typeof value !== "object") { return value; } return Object.fromEntries( Object.entries(value).map(([key, entryValue]) => [ key, BINARY_FIELD_PATTERN.test(key) ? null : sanitizeForCache(entryValue), ]) ); } export function cacheApiResponse(config, data) { const storage = getStorage(); const key = cacheKeyForConfig(config); const scope = getCurrentUserScope(); if (!storage || !key || !scope) return; try { storage.setItem( key, JSON.stringify({ cachedAt: new Date().toISOString(), data: sanitizeForCache(data), }) ); rememberCacheKey(storage, key, scope); } catch (_) { // Cache is an opportunistic fallback. Quota/private-mode failures are non-fatal. } } export function getCachedApiResponse(config) { const storage = getStorage(); const key = cacheKeyForConfig(config); if (!storage || !key) return null; try { const cached = JSON.parse(storage.getItem(key) || "null"); if (!cached || !Object.prototype.hasOwnProperty.call(cached, "data")) { return null; } return cached; } catch (_) { return null; } } export function isTransientApiError(error) { if (!error?.response) return true; return TRANSIENT_STATUS_CODES.has(error.response.status); } export function clearApiCacheForCurrentUser() { const storage = getStorage(); const scope = getCurrentUserScope(); if (!storage || !scope) return; const nextIndex = readIndex(storage).filter((entry) => { if (entry?.scope !== scope) return true; try { storage.removeItem(entry.key); } catch (_) { // Best effort only. } return false; }); writeIndex(storage, nextIndex); }