// smartFetch.js (vanilla JS) — timeout + retry + backoff + cache(TTL) + dedupe
export function createSmartFetch(opts = {}) {
const cfg = {
baseURL: "",
timeoutMs: 8000,
retries: 2,
backoffMs: 250,
cacheTtlMs: 30_000,
headers: {},
...opts,
};
const cache = new Map(); // key -> { exp, value }
const inflight = new Map(); // key -> Promise
const hooks = { onRequest: [], onResponse: [], onError: [] };
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
const now = () => Date.now();
const joinUrl = (base, path) => {
if (!base) return path;
if (!path) return base;
return base.replace(/\/+$/, "") + "/" + path.replace(/^\/+/, "");
};
function on(event, fn) {
if (!hooks[event]) throw new Error(`Unknown hook: ${event}`);
hooks[event].push(fn);
return () => hooks[event] = hooks[event].filter(x => x !== fn);
}
const emit = (event, payload) => hooks[event].forEach(fn => { try { fn(payload); } catch {} });
async function parseBody(res) {
const ct = res.headers.get("content-type") || "";
if (ct.includes("application/json")) return res.json();
const text = await res.text();
try { return JSON.parse(text); } catch { return text; }
}
async function request(method, url, { params, json, body, headers, cacheTtlMs, timeoutMs, retries } = {}) {
const fullUrl = (() => {
const u = new URL(joinUrl(cfg.baseURL, url), typeof window !== "undefined" ? window.location.href : "http://x/");
if (params) Object.entries(params).forEach(([k,v]) => u.searchParams.set(k, String(v)));
return u.toString().replace("http://x/", cfg.baseURL ? "" : "");
})();
const key = method + " " + fullUrl + " " + (json ? JSON.stringify(json) : body ? String(body) : "");
const ttl = cacheTtlMs ?? cfg.cacheTtlMs;
const tmo = timeoutMs ?? cfg.timeoutMs;
const rty = retries ?? cfg.retries;
// cache hit
const hit = cache.get(key);
if (hit && hit.exp > now()) return hit.value;
// dedupe inflight
if (inflight.has(key)) return inflight.get(key);
const p = (async () => {
let attempt = 0;
let lastErr;
while (attempt <= rty) {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(new Error("Timeout")), tmo);
try {
const init = {
method,
signal: ac.signal,
headers: { ...cfg.headers, ...headers },
body: undefined,
};
if (json !== undefined) {
init.headers["content-type"] = "application/json";
init.body = JSON.stringify(json);
} else if (body !== undefined) {
init.body = body;
}
emit("onRequest", { method, url: fullUrl, attempt });
const res = await fetch(fullUrl, init);
const data = await parseBody(res);
if (!res.ok) {
const err = new Error(`HTTP ${res.status}`);
err.status = res.status;
err.data = data;
throw err;
}
emit("onResponse", { method, url: fullUrl, attempt, status: res.status });
if (ttl > 0) cache.set(key, { exp: now() + ttl, value: data });
return data;
} catch (e) {
lastErr = e;
emit("onError", { method, url: fullUrl, attempt, error: e });
if (attempt >= rty) break;
// backoff + small jitter
const backoff = cfg.backoffMs * Math.pow(2, attempt) + Math.random() * 80;
await sleep(backoff);
} finally {
clearTimeout(timer);
attempt++;
}
}
throw lastErr;
})();
inflight.set(key, p);
try { return await p; }
finally { inflight.delete(key); }
}
return {
on,
request,
get: (url, options) => request("GET", url, options),
post: (url, json, options) => request("POST", url, { ...options, json }),
clearCache: () => cache.clear(),
};
}
/*
USAGE (Browser / Node 18+):
import { createSmartFetch } from "./smartFetch.js";
const api = createSmartFetch({ baseURL: "https://jsonplaceholder.typicode.com", cacheTtlMs: 10_000 });
api.on("onError", ({url, error}) => console.log("ERR", url, error.message));
const user = await api.get("/users/1"); // cached
const posts = await api.get("/posts", { params:{ userId: 1 }, retries: 3 });
console.log(user, posts);
*/