feat: cache read-only API responses offline

This commit is contained in:
Nico 2026-05-26 00:40:35 -07:00
parent e793a59570
commit 921952eb2c
3 changed files with 217 additions and 6 deletions

View File

@ -1,5 +1,10 @@
import axios from "axios"; import axios from "axios";
import { API_BASE_URL } from "../config"; import { API_BASE_URL } from "../config";
import {
cacheApiResponse,
getCachedApiResponse,
isTransientApiError,
} from "./offlineCache";
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
@ -31,6 +36,7 @@ api.interceptors.response.use(
response.request_id = payload.request_id; response.request_id = payload.request_id;
response.data = payload.data; response.data = payload.data;
} }
cacheApiResponse(response.config, response.data);
return response; return response;
}, },
error => { error => {
@ -55,6 +61,23 @@ api.interceptors.response.use(
window.location.href = "/login"; window.location.href = "/login";
alert("Your session has expired. Please log in again."); alert("Your session has expired. Please log in again.");
} }
if (isTransientApiError(error)) {
const cached = getCachedApiResponse(error.config);
if (cached) {
return Promise.resolve({
data: cached.data,
status: 200,
statusText: "OK (stale cache)",
headers: {},
config: error.config,
request: error.request,
stale: true,
cachedAt: cached.cachedAt,
});
}
}
return Promise.reject(error); return Promise.reject(error);
} }
); );

View File

@ -0,0 +1,186 @@
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);
}

View File

@ -1,4 +1,5 @@
import { createContext, useState } from 'react'; import { createContext, useState } from 'react';
import { clearApiCacheForCurrentUser } from '../api/offlineCache';
export const AuthContext = createContext({ export const AuthContext = createContext({
token: null, token: null,
@ -16,6 +17,7 @@ export const AuthProvider = ({ children }) => {
const [username, setUsername] = useState(localStorage.getItem('username') || null); const [username, setUsername] = useState(localStorage.getItem('username') || null);
const clearAuthStorage = () => { const clearAuthStorage = () => {
clearApiCacheForCurrentUser();
localStorage.removeItem("token"); localStorage.removeItem("token");
localStorage.removeItem("userId"); localStorage.removeItem("userId");
localStorage.removeItem("role"); localStorage.removeItem("role");