UNPKG

use-fetch-smart

Version:

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

726 lines (709 loc) 22.8 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { FetchSmartDevtools: () => FetchSmartDevtools, FetchSmartProvider: () => FetchSmartProvider, createSmartAxios: () => createSmartAxios, setGlobalToken: () => setGlobalToken, useDeleteSmart: () => useDeleteSmart, useFetchSmartContext: () => useFetchSmartContext, useGetSmart: () => useGetSmart, usePostSmart: () => usePostSmart, usePutSmart: () => usePutSmart }); module.exports = __toCommonJS(index_exports); // src/useGetSmart.ts var import_react2 = require("react"); // src/FetchSmartProvider.tsx var import_react = require("react"); // src/smartAxios.ts var import_axios = __toESM(require("axios"), 1); var refreshPromise = null; var latestToken = null; var setGlobalToken = (token) => { latestToken = token; }; var createSmartAxios = (baseURL, refreshTokenFn, retryLimit = 3) => { const api = import_axios.default.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 var import_jsx_runtime = require("react/jsx-runtime"); var FetchSmartContext = (0, import_react.createContext)(null); var FetchSmartProvider = ({ config, children }) => { if (config.token) { setGlobalToken(config.token); } const axiosRef = (0, import_react.useRef)( createSmartAxios( config.baseURL || "", config.refreshToken, // refresh function config.retryLimit // retry limit ) ); return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FetchSmartContext.Provider, { value: { axiosInstance: axiosRef.current }, children }); }; var useFetchSmartContext = () => { const ctx = (0, import_react.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 var import_idb_keyval = require("idb-keyval"); var isDev = (() => { const env = globalThis?.process?.env?.NODE_ENV; return env !== "production"; })(); var indexedDBCache = { async get(key) { try { const item = await (0, import_idb_keyval.get)(key); if (!item) return null; if (item.expiry && Date.now() > item.expiry) { await (0, import_idb_keyval.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 (0, import_idb_keyval.set)(key, { data, expiry }); } catch (err) { if (isDev) console.error("IndexedDB set error", err); } }, async delete(key) { try { await (0, import_idb_keyval.del)(key); } catch (err) { if (isDev) console.error("IndexedDB delete error", err); } }, async keys() { try { return await (0, import_idb_keyval.keys)(); } catch (err) { if (isDev) console.error("IndexedDB keys error", err); return []; } }, async dump() { try { const allKeys = await (0, import_idb_keyval.keys)(); const output = []; for (const k of allKeys) { const item = await (0, import_idb_keyval.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 (0, import_idb_keyval.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] = (0, import_react2.useState)(null); const [loading, setLoading] = (0, import_react2.useState)(!data); const [error, setError] = (0, import_react2.useState)(null); const swr = opts?.swr ?? false; const didRun = (0, import_react2.useRef)(false); const abortRef = (0, import_react2.useRef)(null); const mountedRef = (0, import_react2.useRef)(true); (0, import_react2.useEffect)(() => { return () => { mountedRef.current = false; abortRef.current?.abort(); }; }, []); (0, import_react2.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 var import_react3 = require("react"); function usePostSmart(url, opts) { const { axiosInstance: api } = useFetchSmartContext(); const [data, setData] = (0, import_react3.useState)(null); const [loading, setLoading] = (0, import_react3.useState)(false); const [error, setError] = (0, import_react3.useState)(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 var import_react4 = require("react"); function usePutSmart(url, opts) { const { axiosInstance: api } = useFetchSmartContext(); const [data, setData] = (0, import_react4.useState)(null); const [loading, setLoading] = (0, import_react4.useState)(false); const [error, setError] = (0, import_react4.useState)(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 var import_react5 = require("react"); function useDeleteSmart(url, opts) { const { axiosInstance: api } = useFetchSmartContext(); const [data, setData] = (0, import_react5.useState)(null); const [loading, setLoading] = (0, import_react5.useState)(false); const [error, setError] = (0, import_react5.useState)(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 var import_react6 = require("react"); var import_jsx_runtime2 = require("react/jsx-runtime"); var FetchSmartDevtools = () => { const _proc = globalThis.process; if (_proc?.env?.NODE_ENV === "production") return null; const [open, setOpen] = (0, import_react6.useState)(false); const [state, setState] = (0, import_react6.useState)({ memDump: [], idbDump: [], combinedDump: [], memKeys: [], idbKeys: [], allKeys: [], inFlightGetKeys: [], inFlightMutationKeys: [] }); const refresh = (0, import_react6.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()) }); }, []); (0, import_react6.useEffect)(() => { 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__ */ (0, import_jsx_runtime2.jsx)( "button", { onClick: () => setOpen(true), style: FAB_STYLE, children: "FS Devtools" } ); return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("div", { style: PANEL_STYLE, children: [ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { onClick: () => setOpen(false), style: BTN_STYLE, children: "Close" }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "\u{1F535} In-Flight GET Requests", children: state.inFlightGetKeys }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "\u{1F534} In-Flight Mutations", children: state.inFlightMutationKeys }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "Memory Keys", children: state.memKeys }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "IndexedDB Keys", children: state.idbKeys }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "All Cache Keys", children: state.allKeys }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "Memory Cache", children: state.memDump }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "IndexedDB Cache", children: state.idbDump }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(Section, { title: "Combined Cache View", children: state.combinedDump }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: BTN_STYLE, onClick: refresh, children: "Refresh" }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: BTN_DANGER, onClick: clearMemory, children: "Clear Memory Cache" }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: BTN_WARN, onClick: clearIndexedDB, children: "Clear IndexedDB" }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("button", { style: BTN_INFO, onClick: clearInFlight, children: "Clear In-Flight Requests" }) ] }); }; var Section = ({ title, children }) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("h3", { children: title }), /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("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" }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { FetchSmartDevtools, FetchSmartProvider, createSmartAxios, setGlobalToken, useDeleteSmart, useFetchSmartContext, useGetSmart, usePostSmart, usePutSmart }); //# sourceMappingURL=index.cjs.map