axios-revalidation-cache 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,362 @@
1
+ # axios-revalidation-cache
2
+
3
+ Small axios response cache for frontend applications with explicit TTL-based revalidation.
4
+
5
+ This package is designed for browser apps where you want a simple cache layer in front of `axios.get(...)` without pulling in a larger data-fetching framework.
6
+
7
+ ## What it does
8
+
9
+ - Returns `network` on the first request.
10
+ - Returns `cache` while the entry is still fresh.
11
+ - Returns `revalidated` after the TTL expires and a new API call refreshes the cached value.
12
+ - Supports manual refresh with `revalidate(...)` when the user clicks refresh or after a mutation.
13
+ - Dedupes concurrent requests for the same key and reports them as `in-flight`.
14
+
15
+ ## Best fit
16
+
17
+ Use this library when:
18
+
19
+ - You already use axios in the frontend.
20
+ - You want a lightweight cache for GET requests.
21
+ - You want predictable TTL-based refresh behavior.
22
+ - You prefer keeping cache control in your own service layer.
23
+
24
+ If you need pagination orchestration, mutations, retries, background sync, devtools, or stale-while-revalidate UX out of the box, a larger library like TanStack Query may still be a better fit.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ npm install axios-revalidation-cache axios
30
+ ```
31
+
32
+ ## Core idea
33
+
34
+ ```js
35
+ const axios = require("axios");
36
+ const { createAxiosCache } = require("axios-revalidation-cache");
37
+
38
+ const apiCache = createAxiosCache({
39
+ client: axios.create({
40
+ baseURL: "https://jsonplaceholder.typicode.com",
41
+ }),
42
+ ttl: 5_000,
43
+ maxSize: 100,
44
+ });
45
+
46
+ async function run() {
47
+ const first = await apiCache.get("/todos/1");
48
+ console.log(first.meta.source); // network
49
+
50
+ const second = await apiCache.get("/todos/1");
51
+ console.log(second.meta.source); // cache
52
+
53
+ await new Promise((resolve) => setTimeout(resolve, 5_100));
54
+
55
+ const third = await apiCache.get("/todos/1");
56
+ console.log(third.meta.source); // revalidated
57
+
58
+ const forced = await apiCache.revalidate("/todos/1");
59
+ console.log(forced.meta.source); // revalidated
60
+ }
61
+ ```
62
+
63
+ ## Frontend setup
64
+
65
+ Create the axios client and cache once, outside your components.
66
+
67
+ ```js
68
+ // api/client.js
69
+ import axios from "axios";
70
+ import { createAxiosCache } from "axios-revalidation-cache";
71
+
72
+ export const apiClient = axios.create({
73
+ baseURL: "https://jsonplaceholder.typicode.com",
74
+ timeout: 8000,
75
+ });
76
+
77
+ export const apiCache = createAxiosCache({
78
+ client: apiClient,
79
+ ttl: 30_000,
80
+ maxSize: 200,
81
+ });
82
+ ```
83
+
84
+ Important:
85
+
86
+ - Do not create the cache inside a React component or hook body.
87
+ - Treat the cache as a singleton service in the browser app.
88
+ - Use `revalidate(...)` after actions that make existing cached data stale.
89
+
90
+ ## React example
91
+
92
+ ### Simple service layer
93
+
94
+ ```js
95
+ // api/todos.js
96
+ import { apiCache } from "./client";
97
+
98
+ export async function getTodo(todoId) {
99
+ const result = await apiCache.get(`/todos/${todoId}`);
100
+ return result;
101
+ }
102
+
103
+ export async function refreshTodo(todoId) {
104
+ const result = await apiCache.revalidate(`/todos/${todoId}`);
105
+ return result;
106
+ }
107
+ ```
108
+
109
+ ### React component example
110
+
111
+ ```jsx
112
+ // TodoCard.jsx
113
+ import { useEffect, useState } from "react";
114
+ import { getTodo, refreshTodo } from "./api/todos";
115
+
116
+ export function TodoCard({ todoId }) {
117
+ const [todo, setTodo] = useState(null);
118
+ const [source, setSource] = useState("idle");
119
+ const [loading, setLoading] = useState(true);
120
+ const [error, setError] = useState(null);
121
+
122
+ useEffect(() => {
123
+ let cancelled = false;
124
+
125
+ async function load() {
126
+ setLoading(true);
127
+ setError(null);
128
+
129
+ try {
130
+ const result = await getTodo(todoId);
131
+
132
+ if (!cancelled) {
133
+ setTodo(result.data);
134
+ setSource(result.meta.source);
135
+ }
136
+ } catch (requestError) {
137
+ if (!cancelled) {
138
+ setError(requestError);
139
+ }
140
+ } finally {
141
+ if (!cancelled) {
142
+ setLoading(false);
143
+ }
144
+ }
145
+ }
146
+
147
+ load();
148
+
149
+ return () => {
150
+ cancelled = true;
151
+ };
152
+ }, [todoId]);
153
+
154
+ async function handleRefresh() {
155
+ setLoading(true);
156
+ setError(null);
157
+
158
+ try {
159
+ const result = await refreshTodo(todoId);
160
+ setTodo(result.data);
161
+ setSource(result.meta.source);
162
+ } catch (requestError) {
163
+ setError(requestError);
164
+ } finally {
165
+ setLoading(false);
166
+ }
167
+ }
168
+
169
+ if (loading) {
170
+ return <p>Loading...</p>;
171
+ }
172
+
173
+ if (error) {
174
+ return <p>Failed to load todo.</p>;
175
+ }
176
+
177
+ return (
178
+ <section>
179
+ <h2>{todo.title}</h2>
180
+ <p>Status: {todo.completed ? "Done" : "Pending"}</p>
181
+ <p>Source: {source}</p>
182
+ <button onClick={handleRefresh}>Refresh</button>
183
+ </section>
184
+ );
185
+ }
186
+ ```
187
+
188
+ ### React custom hook example
189
+
190
+ ```js
191
+ // hooks/useCachedTodo.js
192
+ import { useEffect, useState } from "react";
193
+ import { apiCache } from "../api/client";
194
+
195
+ export function useCachedTodo(todoId) {
196
+ const [data, setData] = useState(null);
197
+ const [meta, setMeta] = useState(null);
198
+ const [loading, setLoading] = useState(true);
199
+ const [error, setError] = useState(null);
200
+
201
+ useEffect(() => {
202
+ let cancelled = false;
203
+
204
+ async function load() {
205
+ setLoading(true);
206
+ setError(null);
207
+
208
+ try {
209
+ const result = await apiCache.get(`/todos/${todoId}`);
210
+
211
+ if (!cancelled) {
212
+ setData(result.data);
213
+ setMeta(result.meta);
214
+ }
215
+ } catch (requestError) {
216
+ if (!cancelled) {
217
+ setError(requestError);
218
+ }
219
+ } finally {
220
+ if (!cancelled) {
221
+ setLoading(false);
222
+ }
223
+ }
224
+ }
225
+
226
+ load();
227
+
228
+ return () => {
229
+ cancelled = true;
230
+ };
231
+ }, [todoId]);
232
+
233
+ async function revalidate() {
234
+ setLoading(true);
235
+ setError(null);
236
+
237
+ try {
238
+ const result = await apiCache.revalidate(`/todos/${todoId}`);
239
+ setData(result.data);
240
+ setMeta(result.meta);
241
+ return result;
242
+ } catch (requestError) {
243
+ setError(requestError);
244
+ throw requestError;
245
+ } finally {
246
+ setLoading(false);
247
+ }
248
+ }
249
+
250
+ return {
251
+ data,
252
+ meta,
253
+ loading,
254
+ error,
255
+ revalidate,
256
+ };
257
+ }
258
+ ```
259
+
260
+ ## Other frontend libraries
261
+
262
+ The same pattern works in Vue, Svelte, Solid, or plain JavaScript: keep the cache in a shared module and call it from your store, composable, or service.
263
+
264
+ ```js
265
+ // services/posts.js
266
+ import { apiCache } from "./client";
267
+
268
+ export function getPost(postId) {
269
+ return apiCache.get(`/posts/${postId}`);
270
+ }
271
+
272
+ export function refreshPost(postId) {
273
+ return apiCache.revalidate(`/posts/${postId}`);
274
+ }
275
+ ```
276
+
277
+ Examples by framework:
278
+
279
+ - Vue: call the service from a composable like `usePost()`.
280
+ - Svelte: call the service from a store or `load` helper.
281
+ - Solid: call the service inside `createResource`.
282
+ - Plain JS: call the service from event handlers and update the DOM manually.
283
+
284
+ ## When to revalidate
285
+
286
+ You should call `revalidate(...)` when:
287
+
288
+ - The TTL has not expired yet, but you know the server data changed.
289
+ - A user clicks a refresh button.
290
+ - A POST, PUT, PATCH, or DELETE likely invalidated the cached GET response.
291
+ - You return to a screen and want guaranteed fresh data instead of a cached value.
292
+
293
+ Example after a mutation:
294
+
295
+ ```js
296
+ await apiClient.patch(`/todos/${todoId}`, { completed: true });
297
+ await apiCache.revalidate(`/todos/${todoId}`);
298
+ ```
299
+
300
+ ## API
301
+
302
+ ### `createAxiosCache(options)`
303
+
304
+ Creates a cache manager for an axios-compatible client.
305
+
306
+ Options:
307
+
308
+ - `client`: Required. Any object with a `get(path, config)` function.
309
+ - `ttl`: Optional. Freshness window in milliseconds. Default `60000`.
310
+ - `maxSize`: Optional. Maximum number of cache entries. Default `100`.
311
+ - `getKey`: Optional. Custom cache key function with signature `(path, config) => string`.
312
+
313
+ ### `cache.get(path, config?, requestOptions?)`
314
+
315
+ Returns:
316
+
317
+ ```js
318
+ {
319
+ data,
320
+ meta: {
321
+ cacheKey,
322
+ source, // network | cache | revalidated | in-flight
323
+ fromCache,
324
+ deduped,
325
+ revalidated,
326
+ cachedAt,
327
+ },
328
+ }
329
+ ```
330
+
331
+ Notes:
332
+
333
+ - `config` is passed directly to `axios.get(path, config)`.
334
+ - If the cached entry is still valid, the cached value is returned.
335
+ - If the TTL has expired, `get(...)` makes a fresh API call and updates the cached value.
336
+ - `requestOptions.revalidate = true` forces a fresh request even if the entry is still valid.
337
+
338
+ ### `cache.revalidate(path, config?)`
339
+
340
+ Bypasses cache, fetches fresh data, and replaces the stored entry.
341
+
342
+ ### `cache.invalidate(path, config?)`
343
+
344
+ Deletes a single cached entry.
345
+
346
+ ### `cache.clear()`
347
+
348
+ Deletes all cached entries.
349
+
350
+ ### `cache.size()`
351
+
352
+ Returns the current number of cache entries.
353
+
354
+ ## Publish checklist
355
+
356
+ Before publishing, verify:
357
+
358
+ 1. The package name in [package.json](package.json) is available on npm.
359
+ 2. The `version` in [package.json](package.json) is correct.
360
+ 3. `npm test` passes.
361
+ 4. The README reflects your final public API.
362
+ 5. `npm publish --access public` is run from the package root.
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ const { createAxiosCache } = require("./src/createAxiosCache");
2
+
3
+ module.exports = {
4
+ createAxiosCache,
5
+ };
package/index.mjs ADDED
@@ -0,0 +1,4 @@
1
+ import cachePackage from "./index.js";
2
+
3
+ export const { createAxiosCache } = cachePackage;
4
+ export default cachePackage;
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "axios-revalidation-cache",
3
+ "version": "1.0.0",
4
+ "description": "A small axios response cache with TTL-based revalidation and manual refresh support.",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": {
10
+ "require": "./index.js",
11
+ "import": "./index.mjs"
12
+ }
13
+ },
14
+ "files": [
15
+ "index.js",
16
+ "index.mjs",
17
+ "src"
18
+ ],
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "axios",
24
+ "frontend",
25
+ "browser",
26
+ "cache",
27
+ "revalidate",
28
+ "react",
29
+ "ttl",
30
+ "http"
31
+ ],
32
+ "scripts": {
33
+ "demo": "node cache.js",
34
+ "test": "node --test"
35
+ },
36
+ "dependencies": {
37
+ "axios": "^1.13.6"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
@@ -0,0 +1,177 @@
1
+ function createAxiosCache(options = {}) {
2
+ const {
3
+ client,
4
+ ttl = 60_000,
5
+ maxSize = 100,
6
+ getKey = defaultGetKey,
7
+ } = options;
8
+
9
+ if (!client || typeof client.get !== "function") {
10
+ throw new TypeError(
11
+ "createAxiosCache requires an axios-compatible client with a get method.",
12
+ );
13
+ }
14
+
15
+ if (!Number.isInteger(maxSize) || maxSize < 1) {
16
+ throw new TypeError("maxSize must be an integer greater than 0.");
17
+ }
18
+
19
+ if (typeof ttl !== "number" || Number.isNaN(ttl) || ttl < 0) {
20
+ throw new TypeError("ttl must be a non-negative number.");
21
+ }
22
+
23
+ const cache = new Map();
24
+
25
+ function get(path, config = {}, requestOptions = {}) {
26
+ const cacheKey = getKey(path, config);
27
+ const existingEntry = cache.get(cacheKey);
28
+ const forceRefresh = Boolean(requestOptions.revalidate);
29
+
30
+ if (!forceRefresh && existingEntry) {
31
+ if (existingEntry.status === "pending") {
32
+ return existingEntry.promise.then((data) =>
33
+ buildResult({
34
+ cacheKey,
35
+ data,
36
+ source: "in-flight",
37
+ cachedAt: existingEntry.cachedAt,
38
+ }),
39
+ );
40
+ }
41
+
42
+ if (!isExpired(existingEntry.cachedAt, ttl)) {
43
+ touchEntry(existingEntry);
44
+ return Promise.resolve(
45
+ buildResult({
46
+ cacheKey,
47
+ data: existingEntry.data,
48
+ source: "cache",
49
+ cachedAt: existingEntry.cachedAt,
50
+ }),
51
+ );
52
+ }
53
+ }
54
+
55
+ const source = existingEntry ? "revalidated" : "network";
56
+ return requestAndCache({ cacheKey, path, config, source });
57
+ }
58
+
59
+ function revalidate(path, config = {}) {
60
+ return get(path, config, { revalidate: true });
61
+ }
62
+
63
+ function invalidate(path, config = {}) {
64
+ return cache.delete(getKey(path, config));
65
+ }
66
+
67
+ function clear() {
68
+ cache.clear();
69
+ }
70
+
71
+ function size() {
72
+ return cache.size;
73
+ }
74
+
75
+ async function requestAndCache({ cacheKey, path, config, source }) {
76
+ const pendingEntry = {
77
+ status: "pending",
78
+ cachedAt: Date.now(),
79
+ lastAccessedAt: Date.now(),
80
+ promise: null,
81
+ data: undefined,
82
+ };
83
+
84
+ cache.delete(cacheKey);
85
+ cache.set(cacheKey, pendingEntry);
86
+ trimCache(cache, maxSize);
87
+
88
+ const requestPromise = client
89
+ .get(path, config)
90
+ .then((response) => response.data);
91
+
92
+ pendingEntry.promise = requestPromise;
93
+
94
+ try {
95
+ const data = await requestPromise;
96
+ pendingEntry.status = "resolved";
97
+ pendingEntry.data = data;
98
+ pendingEntry.cachedAt = Date.now();
99
+ pendingEntry.promise = Promise.resolve(data);
100
+ touchEntry(pendingEntry);
101
+
102
+ return buildResult({
103
+ cacheKey,
104
+ data,
105
+ source,
106
+ cachedAt: pendingEntry.cachedAt,
107
+ });
108
+ } catch (error) {
109
+ cache.delete(cacheKey);
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ return {
115
+ get,
116
+ revalidate,
117
+ invalidate,
118
+ clear,
119
+ size,
120
+ };
121
+ }
122
+
123
+ function buildResult({ cacheKey, data, source, cachedAt }) {
124
+ return {
125
+ data,
126
+ meta: {
127
+ cacheKey,
128
+ source,
129
+ fromCache: source === "cache",
130
+ deduped: source === "in-flight",
131
+ revalidated: source === "revalidated",
132
+ cachedAt,
133
+ },
134
+ };
135
+ }
136
+
137
+ function isExpired(cachedAt, ttl) {
138
+ if (ttl === 0) {
139
+ return true;
140
+ }
141
+
142
+ return Date.now() - cachedAt > ttl;
143
+ }
144
+
145
+ function touchEntry(entry) {
146
+ entry.lastAccessedAt = Date.now();
147
+ }
148
+
149
+ function trimCache(cache, maxSize) {
150
+ while (cache.size > maxSize) {
151
+ const oldestKey = cache.keys().next().value;
152
+ cache.delete(oldestKey);
153
+ }
154
+ }
155
+
156
+ function defaultGetKey(path, config = {}) {
157
+ return `${path}::${stableStringify(config)}`;
158
+ }
159
+
160
+ function stableStringify(value) {
161
+ if (Array.isArray(value)) {
162
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
163
+ }
164
+
165
+ if (value && typeof value === "object") {
166
+ const keys = Object.keys(value).sort();
167
+ return `{${keys
168
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
169
+ .join(",")}}`;
170
+ }
171
+
172
+ return JSON.stringify(value);
173
+ }
174
+
175
+ module.exports = {
176
+ createAxiosCache,
177
+ };