187 lines
4.6 KiB
JavaScript
187 lines
4.6 KiB
JavaScript
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);
|
|
}
|