grocery-app/frontend/src/api/offlineCache.js

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);
}