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
JavaScript
;
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