Skip to content

Commit

Permalink
code
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurfiorette committed Oct 18, 2024
1 parent 3c06514 commit 2c9129a
Show file tree
Hide file tree
Showing 10 changed files with 110 additions and 62 deletions.
16 changes: 10 additions & 6 deletions docs/src/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,14 @@ In any persistent cache scenario where hitting over 77K unique keys is a possibi

<Badge text="optional" type="warning"/>

- Type: `Record<string, Deferred<CachedResponse>>`
- Default: `{}`
- Type: `Map<string, Deferred<void>>`
- Default: `new Map`

A simple object that will hold a promise for each pending request. Used to handle
concurrent requests.

You'd normally not need to change this, but it is exposed in case you need to use it as
some sort of listener of know when a request is waiting for other to finish.
You shouldn't change this property, but it is exposed in case you need to use it as some
sort of listener or know when a request is waiting for others to finish.

## headerInterpreter

Expand Down Expand Up @@ -102,7 +102,10 @@ The possible returns are:
::: details Example of a custom headerInterpreter

```ts
import { setupCache, type HeaderInterpreter } from 'axios-cache-interceptor';
import {
setupCache,
type HeaderInterpreter
} from 'axios-cache-interceptor';

const myHeaderInterpreter: HeaderInterpreter = (headers) => {
if (headers['x-my-custom-header']) {
Expand Down Expand Up @@ -186,7 +189,8 @@ setupCache(axiosInstance, { debug: console.log });

// Own logging platform.
setupCache(axiosInstance, {
debug: ({ id, msg, data }) => myLoggerExample.emit({ id, msg, data })
debug: ({ id, msg, data }) =>
myLoggerExample.emit({ id, msg, data })
});

// Disables debug. (default)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/config/request-specifics.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ and in this [StackOverflow](https://stackoverflow.com/a/62781874/14681561) answe
<Badge text="optional" type="warning"/>

- Type: `Method[]`
- Default: `["get"]`
- Default: `["get", "head"]`

Specifies which methods we should handle and cache. This is where you can enable caching
to `POST`, `PUT`, `DELETE` and other methods, as the default is only `GET`.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "axios-cache-interceptor",
"version": "1.6.0",
"version": "1.6.1",
"description": "Cache interceptor for axios",
"keywords": ["axios", "cache", "interceptor", "adapter", "http", "plugin", "wrapper"],
"homepage": "https://axios-cache-interceptor.js.org",
Expand Down
7 changes: 3 additions & 4 deletions src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import type { HeaderInterpreter } from '../header/types.js';
import type { AxiosInterceptor } from '../interceptors/build.js';
import type {
AxiosStorage,
CachedResponse,
CachedStorageValue,
LoadingStorageValue,
StaleStorageValue
Expand Down Expand Up @@ -86,7 +85,7 @@ export interface CacheProperties<R = unknown, D = unknown> {
* We use `methods` in a per-request configuration setup because sometimes you have
* exceptions to the method rule.
*
* @default ['get']
* @default ['get', 'head']
* @see https://axios-cache-interceptor.js.org/config/request-specifics#cache-methods
*/
methods: Lowercase<Method>[];
Expand Down Expand Up @@ -261,10 +260,10 @@ export interface CacheInstance {
* You'd normally not need to change this, but it is exposed in case you need to use it
* as some sort of listener of know when a request is waiting for other to finish.
*
* @default { }
* @default new Map()
* @see https://axios-cache-interceptor.js.org/config#waiting
*/
waiting: Record<string, Deferred<CachedResponse>>;
waiting: Map<string, Deferred<void>>;

/**
* The function used to interpret all headers from a request and determine a time to
Expand Down
2 changes: 1 addition & 1 deletion src/cache/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export function setupCache(axios: AxiosInstance, options: CacheOptions = {}): Ax
throw new Error('Use buildStorage() function');
}

axiosCache.waiting = options.waiting || {};
axiosCache.waiting = options.waiting || new Map();

axiosCache.generateKey = options.generateKey || defaultKeyGenerator;

Expand Down
35 changes: 29 additions & 6 deletions src/interceptors/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
// This checks for simultaneous access to a new key. The js event loop jumps on the
// first await statement, so the second (asynchronous call) request may have already
// started executing.
if (axios.waiting[config.id] && !overrideCache) {
if (axios.waiting.has(config.id) && !overrideCache) {
cache = (await axios.storage.get(config.id, config)) as
| CachedStorageValue
| LoadingStorageValue;
Expand All @@ -116,11 +116,12 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
}

// Create a deferred to resolve other requests for the same key when it's completed
axios.waiting[config.id] = deferred();
const def = deferred<void>();
axios.waiting.set(config.id, def);

// Adds a default reject handler to catch when the request gets aborted without
// others waiting for it.
axios.waiting[config.id]!.catch(() => undefined);
def.catch(() => undefined);

await axios.storage.set(
config.id,
Expand Down Expand Up @@ -178,7 +179,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
let cachedResponse: CachedResponse;

if (cache.state === 'loading') {
const deferred = axios.waiting[config.id];
const deferred = axios.waiting.get(config.id);

// The deferred may not exists when the process is using a persistent
// storage and cancelled in the middle of a request, this would result in
Expand All @@ -200,7 +201,28 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
}

try {
cachedResponse = await deferred;
// Deferred can't reuse the value because the user's storage might clone
// or mutate the value, so we need to ask it again.
// For example with memoryStorage + cloneData
await deferred;
const state = await axios.storage.get(config.id, config);

// This is a cache mismatch and should never happen, but in case it does,
// we need to redo the request all over again.
/* c8 ignore start */
if (!state.data) {
if (__ACI_DEV__) {
axios.debug({
id: config.id,
msg: 'Deferred resolved, but no data was found, requesting again'
});
}

return onFulfilled(config);
}
/* c8 ignore end */

cachedResponse = state.data;
} catch (err) {
if (__ACI_DEV__) {
axios.debug({
Expand All @@ -211,10 +233,11 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
}

// Hydrates any UI temporarily, if cache is available
/* c8 ignore next 3 */
/* c8 ignore start */
if (cache.data) {
await config.cache.hydrate?.(cache);
}
/* c8 ignore end */

// The deferred is rejected when the request that we are waiting rejects its cache.
// In this case, we need to redo the request all over again.
Expand Down
37 changes: 25 additions & 12 deletions src/interceptors/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
await axios.storage.remove(responseId, config);

// Rejects the deferred, if present
axios.waiting[responseId]?.reject();
const deferred = axios.waiting.get(responseId);

delete axios.waiting[responseId];
if (deferred) {
deferred.reject();
axios.waiting.delete(responseId);
}
};

const onFulfilled: ResponseInterceptor['onFulfilled'] = async (response) => {
Expand Down Expand Up @@ -200,12 +203,15 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
data
};

// Define this key as cache on the storage
await axios.storage.set(response.id, newCache, config);

// Resolve all other requests waiting for this response
const waiting = axios.waiting[response.id];
const waiting = axios.waiting.get(response.id);

if (waiting) {
waiting.resolve(newCache.data);
delete axios.waiting[response.id];
waiting.resolve();
axios.waiting.delete(response.id);

if (__ACI_DEV__) {
axios.debug({
Expand All @@ -215,9 +221,6 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
}
}

// Define this key as cache on the storage
await axios.storage.set(response.id, newCache, config);

if (__ACI_DEV__) {
axios.debug({
id: response.id,
Expand Down Expand Up @@ -323,10 +326,6 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
// staleIfError is the number of seconds that stale is allowed to be used
(typeof staleIfError === 'number' && cache.createdAt + staleIfError > Date.now())
) {
// Resolve all other requests waiting for this response
axios.waiting[id]?.resolve(cache.data);
delete axios.waiting[id];

// re-mark the cache as stale
await axios.storage.set(
id,
Expand All @@ -337,6 +336,20 @@ export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseI
},
config
);
// Resolve all other requests waiting for this response
const waiting = axios.waiting.get(id);

if (waiting) {
waiting.resolve();
axios.waiting.delete(id);

if (__ACI_DEV__) {
axios.debug({
id,
msg: 'Found waiting deferred(s) and resolved them'
});
}
}

if (__ACI_DEV__) {
axios.debug({
Expand Down
40 changes: 14 additions & 26 deletions src/storage/memory.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import { buildStorage, canStale, isExpired } from './build.js';
import type { AxiosStorage, NotEmptyStorageValue, StorageValue } from './types.js';
import type { AxiosStorage, StorageValue } from './types.js';

/* c8 ignore start */
/**
* Modern function to natively deep clone.
*
* @link https://caniuse.com/mdn-api_structuredclone (07/03/2022 -> 59.4%)
* Clones an object using the structured clone algorithm if available, otherwise
* it uses JSON.parse(JSON.stringify(value)).
*/
declare const structuredClone: (<T>(value: T) => T) | undefined;
const clone: <T>(value: T) => T =
// https://caniuse.com/mdn-api_structuredclone (10/18/2023 92.51%)
typeof structuredClone === 'function'
? structuredClone
: (value) => JSON.parse(JSON.stringify(value));
/* c8 ignore stop */

/**
* Creates a simple in-memory storage. This means that if you need to persist data between
Expand Down Expand Up @@ -69,15 +74,9 @@ export function buildMemoryStorage(
}
}

storage.data[key] =
// Clone the value before storing to prevent future mutations
// from affecting cached data.
cloneData === 'double'
? /* c8 ignore next 3 */
typeof structuredClone === 'function'
? structuredClone(value)
: (JSON.parse(JSON.stringify(value)) as NotEmptyStorageValue)
: value;
// Clone the value before storing to prevent future mutations
// from affecting cached data.
storage.data[key] = cloneData === 'double' ? clone(value) : value;
},

remove: (key) => {
Expand All @@ -87,16 +86,7 @@ export function buildMemoryStorage(
find: (key) => {
const value = storage.data[key];

/* c8 ignore next 7 */
if (cloneData && value !== undefined) {
if (typeof structuredClone === 'function') {
return structuredClone(value);
}

return JSON.parse(JSON.stringify(value)) as StorageValue;
}

return value;
return cloneData && value !== undefined ? clone(value) : value;
},

clear: () => {
Expand All @@ -123,8 +113,6 @@ export function buildMemoryStorage(
value = storage.data[key]!;

if (value.state === 'empty') {
// this storage returns void.

storage.remove(key);
continue;
}
Expand Down
24 changes: 22 additions & 2 deletions test/interceptors/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { setTimeout } from 'node:timers/promises';
import type { AxiosAdapter, AxiosResponse } from 'axios';
import type { CacheRequestConfig, InternalCacheRequestConfig } from '../../src/cache/axios.js';
import { Header } from '../../src/header/headers.js';
import { buildMemoryStorage } from '../../src/index.js';
import type { LoadingStorageValue } from '../../src/storage/types.js';
import { mockAxios } from '../mocks/axios.js';
import { mockDateNow } from '../utils.js';
Expand Down Expand Up @@ -227,15 +228,15 @@ describe('Request Interceptor', () => {
// it still has a waiting entry.
const { state } = await axios.storage.get(ID);
assert.equal(state, 'empty');
assert.ok(axios.waiting[ID]);
assert.ok(axios.waiting.get(ID));

// This line should throw an error if this bug isn't fixed.
await axios.get('url', { id: ID });

const { state: newState } = await axios.storage.get(ID);

assert.notEqual(newState, 'empty');
assert.equal(axios.waiting[ID], undefined);
assert.equal(axios.waiting.get(ID), undefined);
});

it('`cache.override = true` with previous cache', async () => {
Expand Down Expand Up @@ -451,4 +452,23 @@ describe('Request Interceptor', () => {
assert.equal(req5.cached, false);
assert.equal(req5.stale, undefined);
});

it('clone works with concurrent requests', async () => {
const axios = mockAxios(
{
storage: buildMemoryStorage('double')
},
undefined,
undefined,
() => ({ a: 1 })
);

await Promise.all(
Array.from({ length: 10 }, async () => {
const result = await axios.get<{ a: 1 }>('/url');
result.data.a++;
assert.equal(result.data.a, 2);
})
);
});
});
7 changes: 4 additions & 3 deletions test/mocks/axios.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export const XMockRandom = 'x-mock-random';
export function mockAxios(
options: CacheOptions = {},
responseHeaders: Record<string, string> = {},
instance = Axios.create()
instance = Axios.create(),
data: () => any = () => true
): AxiosCacheInstance {
const axios = setupCache(instance, options);

Expand All @@ -30,7 +31,7 @@ export function mockAxios(
config,
{ config },
{
data: true,
data: data(),
status,
statusText,
headers: {
Expand All @@ -45,7 +46,7 @@ export function mockAxios(
}

return {
data: true,
data: data(),
status,
statusText,
headers: {
Expand Down

0 comments on commit 2c9129a

Please sign in to comment.