UNPKG

use-fetch-smart

Version:

A smart React data-fetching hook with caching, retries, TTL, schema validation, and automatic token refresh.

681 lines (666 loc) 19.8 kB
// src/useGetSmart.ts import { useEffect, useRef as useRef2, useState } from "react"; // src/FetchSmartProvider.tsx import { createContext, useContext, useRef } from "react"; // src/smartAxios.ts import axios from "axios"; var refreshPromise = null; var latestToken = null; var setGlobalToken = (token) => { latestToken = token; }; var createSmartAxios = (baseURL, refreshTokenFn, retryLimit = 3) => { const api = axios.create({ baseURL }); api.interceptors.request.use((config) => { if (latestToken) { config.headers = config.headers || {}; if (typeof config.headers.set === "function") { config.headers.set("Authorization", `Bearer ${latestToken}`); } else { config.headers["Authorization"] = `Bearer ${latestToken}`; } } return config; }); api.interceptors.response.use( (res) => res, async (error) => { const config = error.config; if (!config) return Promise.reject(error); if (error.response?.status === 401 && latestToken && refreshTokenFn) { if (!refreshPromise) { refreshPromise = refreshTokenFn(); } const newToken = await refreshPromise; refreshPromise = null; if (newToken) { setGlobalToken(newToken); config.headers.Authorization = `Bearer ${newToken}`; return api(config); } } config.__retry = config.__retry || 0; const shouldRetry = !error.response || error.response.status >= 500 && error.response.status < 600; if (shouldRetry && config.__retry < retryLimit) { config.__retry++; const delay = 500 * Math.pow(2, config.__retry - 1); await new Promise((resolve) => setTimeout(resolve, delay)); return api(config); } return Promise.reject(error); } ); return api; }; // src/FetchSmartProvider.tsx import { jsx } from "react/jsx-runtime"; var FetchSmartContext = createContext(null); var FetchSmartProvider = ({ config, children }) => { if (config.token) { setGlobalToken(config.token); } const axiosRef = useRef( createSmartAxios( config.baseURL || "", config.refreshToken, // refresh function config.retryLimit // retry limit ) ); return /* @__PURE__ */ jsx(FetchSmartContext.Provider, { value: { axiosInstance: axiosRef.current }, children }); }; var useFetchSmartContext = () => { const ctx = useContext(FetchSmartContext); if (!ctx) { throw new Error( "useGetSmart / usePostSmart must be used inside <FetchSmartProvider>" ); } return ctx; }; // src/cache/memoryCache.ts var memoryStore = /* @__PURE__ */ new Map(); var memoryCache = { get(key) { const item = memoryStore.get(key); if (!item) return null; if (item.expiry && Date.now() > item.expiry) { memoryStore.delete(key); return null; } return item.data; }, set(key, data, ttlMs) { const expiry = ttlMs ? Date.now() + ttlMs : null; memoryStore.set(key, { data, expiry }); }, delete(key) { memoryStore.delete(key); }, clear() { memoryStore.clear(); }, keys() { return Array.from(memoryStore.keys()); }, dump() { return Array.from(memoryStore.entries()).map(([key, item]) => ({ key, data: item.data, expiry: item.expiry })); } }; // src/cache/indexedDBCache.ts import { get, set, del, keys, clear } from "idb-keyval"; var isDev = (() => { const env = globalThis?.process?.env?.NODE_ENV; return env !== "production"; })(); var indexedDBCache = { async get(key) { try { const item = await get(key); if (!item) return null; if (item.expiry && Date.now() > item.expiry) { await del(key); return null; } return item.data; } catch (err) { if (isDev) console.error("IndexedDB get error", err); return null; } }, async set(key, data, ttlMs) { const expiry = ttlMs ? Date.now() + ttlMs : null; try { await set(key, { data, expiry }); } catch (err) { if (isDev) console.error("IndexedDB set error", err); } }, async delete(key) { try { await del(key); } catch (err) { if (isDev) console.error("IndexedDB delete error", err); } }, async keys() { try { return await keys(); } catch (err) { if (isDev) console.error("IndexedDB keys error", err); return []; } }, async dump() { try { const allKeys = await keys(); const output = []; for (const k of allKeys) { const item = await get(k); if (!item) continue; output.push({ key: k, data: item.data, expiry: item.expiry }); } return output; } catch (err) { if (isDev) console.error("IndexedDB dump error", err); return []; } }, clear: async () => { try { await clear(); } catch (err) { if (isDev) console.error("IndexedDB clear error", err); } } }; // src/cache/cacheDriver.ts var cacheDriver = { async get(key, persist) { if (persist) { const data = await indexedDBCache.get(key); if (data !== null) return data; } return memoryCache.get(key); }, async set(key, data, opts) { const ttlMs = opts?.ttlMs; memoryCache.set(key, data, ttlMs); if (opts?.persist) { await indexedDBCache.set(key, data, ttlMs); } }, async delete(key) { memoryCache.delete(key); await indexedDBCache.delete(key); }, async clear() { memoryCache.clear(); } }; // src/utils/formatSchemaError.ts function formatSchemaError(err, endpoint) { let msg = `Schema validation failed for response at "${endpoint}": `; const issues = err?.issues || err?.errors || err?.inner; if (Array.isArray(issues)) { issues.forEach((issue) => { const pathArray = issue.path ?? []; const path = pathArray.length > 0 ? "[" + pathArray.join("].") + "]" : "value"; msg += `- ${path}: ${issue.message} `; }); } else { msg += `- ${err?.message || "Unknown schema validation error"}`; } return msg.trim(); } // src/utils/validateWithSchema.ts function validateWithSchema(data, schema, mode = "error", endpoint = "") { if (!schema) return data; try { return schema.parse(data); } catch (err) { const formatted = formatSchemaError(err, endpoint); if (mode === "warn") { console.warn("[use-fetch-smart warn]\n" + formatted); return data; } err.message = formatted; throw err; } } // src/prefetchSmart.ts var inFlightPrefetch = /* @__PURE__ */ new Map(); var prefetchQueue = []; var MAX_PREFETCH_CONCURRENCY = 3; var activePrefetches = 0; var lastPrefetchTs = 0; var PREFETCH_THROTTLE_MS = 200; var isSlowNetwork = () => { const conn = navigator?.connection?.effectiveType; return conn === "2g" || conn === "slow-2g"; }; function schedulePrefetch(run) { prefetchQueue.push(run); const tryRun = () => { if (activePrefetches >= MAX_PREFETCH_CONCURRENCY) return; if (prefetchQueue.length === 0) return; const task = prefetchQueue.shift(); if (!task) return; activePrefetches++; task().finally(() => { activePrefetches--; tryRun(); }); }; tryRun(); } async function prefetchSmart(url, api, opts) { const cacheKey = url; if (typeof navigator !== "undefined" && !navigator.onLine) return; if (isSlowNetwork()) return; if (Date.now() - lastPrefetchTs < PREFETCH_THROTTLE_MS) return; lastPrefetchTs = Date.now(); const existing = await cacheDriver.get(cacheKey, opts?.persist); if (existing) return; if (inFlightPrefetch.has(cacheKey)) return; const controller = new AbortController(); const signal = controller.signal; const runPrefetch = async () => { try { const promise = api.get(url, { signal }); inFlightPrefetch.set(cacheKey, { controller, promise }); const res = await promise; const validated = validateWithSchema( res.data, opts?.schema, opts?.schemaMode ?? "error", url ); await cacheDriver.set(cacheKey, validated, { ttlMs: opts?.ttlMs, persist: opts?.persist }); } catch (err) { } finally { inFlightPrefetch.delete(cacheKey); } }; schedulePrefetch(runPrefetch); } // src/utils/smartDedupe.ts var inFlightRequests = /* @__PURE__ */ new Map(); var inFlightMutations = /* @__PURE__ */ new Map(); function mutationKey(method, url, body) { const bodyKey = body ? JSON.stringify(body) : ""; return `${method}:${url}:${bodyKey}`; } // src/useGetSmart.ts function useGetSmart(url, opts) { const { axiosInstance: api } = useFetchSmartContext(); const cacheKey = url; const ttlMs = opts?.cacheTimeMs ?? 0; const [data, setData] = useState(null); const [loading, setLoading] = useState(!data); const [error, setError] = useState(null); const swr = opts?.swr ?? false; const didRun = useRef2(false); const abortRef = useRef2(null); const mountedRef = useRef2(true); useEffect(() => { return () => { mountedRef.current = false; abortRef.current?.abort(); }; }, []); useEffect(() => { fetchData(); return () => abortRef.current?.abort(); }, [url]); const fetchData = async () => { try { setLoading(true); setError(null); abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; let requestPromise; if (inFlightRequests.has(url)) { requestPromise = inFlightRequests.get(url); } else { requestPromise = api.get(url, { signal: controller.signal }); inFlightRequests.set(url, requestPromise); } const res = await requestPromise; if (inFlightRequests.get(url) === requestPromise) { inFlightRequests.delete(url); } const validated = validateWithSchema(res.data, opts?.schema, "error", url); setData(validated); await cacheDriver.set(cacheKey, validated); } catch (err) { inFlightRequests.delete(url); setError(err); } finally { setLoading(false); } }; const revalidate = async () => { try { setLoading(true); abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller; const signal = controller.signal; let requestPromise; if (inFlightRequests.has(url)) { requestPromise = inFlightRequests.get(url); } else { requestPromise = api.get(url, { signal }); inFlightRequests.set(url, requestPromise); } const res = await requestPromise; if (inFlightRequests.get(url) === requestPromise) { inFlightRequests.delete(url); } if (signal.aborted) return; const validated = validateWithSchema( res.data, opts?.schema, opts?.schemaMode ?? "error", url ); setData(validated); await cacheDriver.set(cacheKey, validated, { ttlMs, persist: opts?.persist }); if (opts?.prefetchNext) { const predictions = opts.prefetchNext(validated, { url }); predictions?.forEach((p) => { prefetchSmart(p.url, api, { ttlMs: p.ttlMs ?? ttlMs, persist: p.persist ?? opts?.persist, schema: p.schema ?? opts?.schema, schemaMode: p.schemaMode ?? opts?.schemaMode }); }); } } catch (err) { inFlightRequests.delete(url); setError(err); } finally { setLoading(false); } }; return { data, loading, error, refetch: revalidate }; } // src/usePostSmart.ts import { useState as useState2 } from "react"; function usePostSmart(url, opts) { const { axiosInstance: api } = useFetchSmartContext(); const [data, setData] = useState2(null); const [loading, setLoading] = useState2(false); const [error, setError] = useState2(null); const mutate = async (body) => { setLoading(true); setError(null); const key = mutationKey("POST", url, body); if (inFlightMutations.has(key)) { return await inFlightMutations.get(key); } const promise = api.post(url, body); inFlightMutations.set(key, promise); try { const res = await promise; if (inFlightMutations.get(key) === promise) { inFlightMutations.delete(key); } const validated = validateWithSchema(res.data, opts?.schema, "error", url); setData(validated); return validated; } catch (err) { inFlightMutations.delete(key); setError(err); return null; } finally { setLoading(false); } }; return { mutate, data, loading, error }; } // src/usePutSmart.ts import { useState as useState3 } from "react"; function usePutSmart(url, opts) { const { axiosInstance: api } = useFetchSmartContext(); const [data, setData] = useState3(null); const [loading, setLoading] = useState3(false); const [error, setError] = useState3(null); const mutate = async (body) => { setLoading(true); setError(null); const key = mutationKey("PUT", url, body); if (inFlightMutations.has(key)) { return await inFlightMutations.get(key); } const promise = api.put(url, body); inFlightMutations.set(key, promise); try { const res = await promise; if (inFlightMutations.get(key) === promise) { inFlightMutations.delete(key); } const validated = validateWithSchema(res.data, opts?.schema, "error", url); setData(validated); return validated; } catch (err) { inFlightMutations.delete(key); setError(err); return null; } finally { setLoading(false); } }; return { mutate, data, loading, error }; } // src/useDeleteSmart.ts import { useState as useState4 } from "react"; function useDeleteSmart(url, opts) { const { axiosInstance: api } = useFetchSmartContext(); const [data, setData] = useState4(null); const [loading, setLoading] = useState4(false); const [error, setError] = useState4(null); const mutate = async () => { setLoading(true); setError(null); const key = mutationKey("DELETE", url); if (inFlightMutations.has(key)) { return await inFlightMutations.get(key); } const promise = api.delete(url); inFlightMutations.set(key, promise); try { const res = await promise; if (inFlightMutations.get(key) === promise) { inFlightMutations.delete(key); } const validated = validateWithSchema(res.data, opts?.schema, "error", url); setData(validated); return validated; } catch (err) { inFlightMutations.delete(key); setError(err); return null; } finally { setLoading(false); } }; return { mutate, data, loading, error }; } // src/FetchSmartDevtools.tsx import { useEffect as useEffect2, useState as useState5, useCallback } from "react"; import { Fragment, jsx as jsx2, jsxs } from "react/jsx-runtime"; var FetchSmartDevtools = () => { const _proc = globalThis.process; if (_proc?.env?.NODE_ENV === "production") return null; const [open, setOpen] = useState5(false); const [state, setState] = useState5({ memDump: [], idbDump: [], combinedDump: [], memKeys: [], idbKeys: [], allKeys: [], inFlightGetKeys: [], inFlightMutationKeys: [] }); const refresh = useCallback(async () => { const mem = memoryCache.dump?.() ?? []; const memKeys = mem.map((x) => x.key); let idbKeys = []; let idbDump = []; if (indexedDBCache.keys) { idbKeys = await indexedDBCache.keys(); const reads = await Promise.all(idbKeys.map((k) => indexedDBCache.get(k))); idbDump = idbKeys.map((k, i) => ({ key: k, data: reads[i] })).filter((x) => x.data !== null); } const allKeys = Array.from(/* @__PURE__ */ new Set([...memKeys, ...idbKeys])); const memMap = new Map(mem.map((x) => [x.key, x])); const idbMap = new Map(idbDump.map((x) => [x.key, x])); const combinedDump = allKeys.map((key) => { const m = memMap.get(key); const d = idbMap.get(key); return { key, source: m && d ? "memory + indexeddb" : m ? "memory" : "indexeddb", memoryData: m?.data ?? null, idbData: d?.data ?? null, memoryExpiry: m?.expiry ?? null, idbExpiry: d?.expiry ?? null }; }); setState({ memDump: mem, idbDump, combinedDump, memKeys, idbKeys, allKeys, inFlightGetKeys: Array.from(inFlightRequests.keys()), inFlightMutationKeys: Array.from(inFlightMutations.keys()) }); }, []); useEffect2(() => { if (open) refresh(); }, [open, refresh]); const clearMemory = () => { memoryCache.clear?.(); refresh(); }; const clearIndexedDB = async () => { await indexedDBCache.clear?.(); refresh(); }; const clearInFlight = () => { inFlightRequests.clear(); inFlightMutations.clear(); refresh(); }; if (!open) return /* @__PURE__ */ jsx2( "button", { onClick: () => setOpen(true), style: FAB_STYLE, children: "FS Devtools" } ); return /* @__PURE__ */ jsxs("div", { style: PANEL_STYLE, children: [ /* @__PURE__ */ jsx2("button", { onClick: () => setOpen(false), style: BTN_STYLE, children: "Close" }), /* @__PURE__ */ jsx2(Section, { title: "\u{1F535} In-Flight GET Requests", children: state.inFlightGetKeys }), /* @__PURE__ */ jsx2(Section, { title: "\u{1F534} In-Flight Mutations", children: state.inFlightMutationKeys }), /* @__PURE__ */ jsx2(Section, { title: "Memory Keys", children: state.memKeys }), /* @__PURE__ */ jsx2(Section, { title: "IndexedDB Keys", children: state.idbKeys }), /* @__PURE__ */ jsx2(Section, { title: "All Cache Keys", children: state.allKeys }), /* @__PURE__ */ jsx2(Section, { title: "Memory Cache", children: state.memDump }), /* @__PURE__ */ jsx2(Section, { title: "IndexedDB Cache", children: state.idbDump }), /* @__PURE__ */ jsx2(Section, { title: "Combined Cache View", children: state.combinedDump }), /* @__PURE__ */ jsx2("button", { style: BTN_STYLE, onClick: refresh, children: "Refresh" }), /* @__PURE__ */ jsx2("button", { style: BTN_DANGER, onClick: clearMemory, children: "Clear Memory Cache" }), /* @__PURE__ */ jsx2("button", { style: BTN_WARN, onClick: clearIndexedDB, children: "Clear IndexedDB" }), /* @__PURE__ */ jsx2("button", { style: BTN_INFO, onClick: clearInFlight, children: "Clear In-Flight Requests" }) ] }); }; var Section = ({ title, children }) => /* @__PURE__ */ jsxs(Fragment, { children: [ /* @__PURE__ */ jsx2("h3", { children: title }), /* @__PURE__ */ jsx2("pre", { children: JSON.stringify(children, null, 2) }) ] }); var FAB_STYLE = { position: "fixed", bottom: 20, right: 20, padding: "6px 10px", background: "#333", color: "white", borderRadius: "6px", fontFamily: "monospace", zIndex: 99999 }; var PANEL_STYLE = { position: "fixed", bottom: 20, right: 20, width: 480, maxHeight: "80vh", overflowY: "auto", background: "#111", padding: 16, borderRadius: 10, color: "white", fontFamily: "monospace", zIndex: 99999 }; var BTN_STYLE = { background: "#444", padding: "6px 8px", borderRadius: 4, marginBottom: 10, width: "100%" }; var BTN_DANGER = { ...BTN_STYLE, background: "#550000" }; var BTN_WARN = { ...BTN_STYLE, background: "#A36C00" }; var BTN_INFO = { ...BTN_STYLE, background: "#003355" }; export { FetchSmartDevtools, FetchSmartProvider, createSmartAxios, setGlobalToken, useDeleteSmart, useFetchSmartContext, useGetSmart, usePostSmart, usePutSmart }; //# sourceMappingURL=index.js.map