mirror of
https://github.com/arthurfiorette/axios-cache-interceptor.git
synced 2025-12-08 17:36:16 +00:00
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import { deferred } from 'fast-defer';
|
|
import type { AxiosCacheInstance, CacheAxiosResponse } from '../cache/axios.js';
|
|
import { Header } from '../header/headers.js';
|
|
import type { CachedResponse, CachedStorageValue, LoadingStorageValue } from '../storage/types.js';
|
|
import { regexOrStringMatch } from '../util/cache-predicate.js';
|
|
import type { RequestInterceptor } from './build.js';
|
|
import {
|
|
type ConfigWithCache,
|
|
createValidateStatus,
|
|
isMethodIn,
|
|
updateStaleRequest
|
|
} from './util.js';
|
|
|
|
export function defaultRequestInterceptor(axios: AxiosCacheInstance): RequestInterceptor {
|
|
const onFulfilled: RequestInterceptor['onFulfilled'] = async (config) => {
|
|
config.id = axios.generateKey(config);
|
|
|
|
if (config.cache === false) {
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: 'Ignoring cache because config.cache === false',
|
|
data: config
|
|
});
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
// merge defaults with per request configuration
|
|
config.cache = { ...axios.defaults.cache, ...config.cache };
|
|
|
|
// ignoreUrls (blacklist)
|
|
if (
|
|
typeof config.cache.cachePredicate === 'object' &&
|
|
config.cache.cachePredicate.ignoreUrls &&
|
|
config.url
|
|
) {
|
|
for (const url of config.cache.cachePredicate.ignoreUrls) {
|
|
if (regexOrStringMatch(url, config.url)) {
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: `Ignored because url (${config.url}) matches ignoreUrls (${config.cache.cachePredicate.ignoreUrls})`,
|
|
data: {
|
|
url: config.url,
|
|
cachePredicate: config.cache.cachePredicate
|
|
}
|
|
});
|
|
}
|
|
|
|
return config;
|
|
}
|
|
}
|
|
}
|
|
|
|
// allowUrls
|
|
if (
|
|
typeof config.cache.cachePredicate === 'object' &&
|
|
config.cache.cachePredicate.allowUrls &&
|
|
config.url
|
|
) {
|
|
let matched = false;
|
|
|
|
for (const url of config.cache.cachePredicate.allowUrls) {
|
|
if (regexOrStringMatch(url, config.url)) {
|
|
matched = true;
|
|
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: `Cached because url (${config.url}) matches allowUrls (${config.cache.cachePredicate.allowUrls})`,
|
|
data: {
|
|
url: config.url,
|
|
cachePredicate: config.cache.cachePredicate
|
|
}
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!matched) {
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: `Ignored because url (${config.url}) does not match any allowUrls (${config.cache.cachePredicate.allowUrls})`,
|
|
data: {
|
|
url: config.url,
|
|
cachePredicate: config.cache.cachePredicate
|
|
}
|
|
});
|
|
}
|
|
return config;
|
|
}
|
|
}
|
|
|
|
// Applies sufficient headers to prevent other cache systems to work along with this one
|
|
//
|
|
// Its currently used before isMethodIn because if the isMethodIn returns false, the request
|
|
// shouldn't be cached an therefore neither in the browser.
|
|
// https://stackoverflow.com/a/2068407
|
|
if (config.cache.cacheTakeover) {
|
|
config.headers[Header.CacheControl] ??= 'no-cache, no-store, must-revalidate';
|
|
config.headers[Header.Pragma] ??= 'no-cache';
|
|
config.headers[Header.Expires] ??= '0';
|
|
}
|
|
|
|
if (!isMethodIn(config.method, config.cache.methods)) {
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: `Ignored because method (${config.method}) is not in cache.methods (${config.cache.methods})`
|
|
});
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
// Assumes that the storage handled staled responses
|
|
let cache = await axios.storage.get(config.id, config);
|
|
const overrideCache = config.cache.override;
|
|
|
|
// Not cached, continue the request, and mark it as fetching
|
|
// biome-ignore lint/suspicious/noConfusingLabels: required to break condition in simultaneous accesses
|
|
ignoreAndRequest: if (
|
|
cache.state === 'empty' ||
|
|
cache.state === 'stale' ||
|
|
cache.state === 'must-revalidate' ||
|
|
overrideCache
|
|
) {
|
|
// 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.has(config.id) && !overrideCache) {
|
|
cache = (await axios.storage.get(config.id, config)) as
|
|
| CachedStorageValue
|
|
| LoadingStorageValue;
|
|
|
|
// @ts-expect-error This check is required when a request has it own cache deleted manually, lets
|
|
// say by a `axios.storage.delete(key)` and has a concurrent loading request.
|
|
// Because in this case, the cache will be empty and may still has a pending key
|
|
// on waiting map.
|
|
if (cache.state !== 'empty' && cache.state !== 'must-revalidate') {
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: 'Waiting list had an deferred for this key, waiting for it to finish'
|
|
});
|
|
}
|
|
|
|
break ignoreAndRequest;
|
|
}
|
|
}
|
|
|
|
// Create a deferred to resolve other requests for the same key when it's completed
|
|
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.
|
|
def.catch(() => undefined);
|
|
|
|
await axios.storage.set(
|
|
config.id,
|
|
{
|
|
state: 'loading',
|
|
previous: overrideCache
|
|
? // Simply determine if the request is stale or not
|
|
// based if it had previous data or not
|
|
cache.data
|
|
? 'stale'
|
|
: 'empty'
|
|
: // Typescript doesn't know that cache.state here can only be 'empty' or 'stale'
|
|
(cache.state as 'stale' | 'must-revalidate'),
|
|
|
|
data: cache.data as any,
|
|
|
|
// If the cache is empty and asked to override it, use the current timestamp
|
|
createdAt: overrideCache && !cache.createdAt ? Date.now() : (cache.createdAt as any)
|
|
},
|
|
config
|
|
);
|
|
|
|
if ((cache.state === 'stale' || cache.state === 'must-revalidate') && !overrideCache) {
|
|
updateStaleRequest(cache, config as ConfigWithCache<unknown>);
|
|
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: 'Updated stale request'
|
|
});
|
|
}
|
|
}
|
|
|
|
config.validateStatus = createValidateStatus(config.validateStatus);
|
|
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: 'Sending request, waiting for response',
|
|
data: {
|
|
overrideCache,
|
|
state: cache.state
|
|
}
|
|
});
|
|
}
|
|
|
|
// Hydrates any UI temporarily, if cache is available
|
|
if (cache.state === 'stale' || (cache.data && cache.state !== 'must-revalidate')) {
|
|
await config.cache.hydrate?.(cache);
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
let cachedResponse: CachedResponse;
|
|
|
|
if (cache.state === 'loading') {
|
|
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
|
|
// a pending loading state in the storage but no current promises to resolve
|
|
if (!deferred) {
|
|
// Hydrates any UI temporarily, if cache is available
|
|
if (cache.data) {
|
|
await config.cache.hydrate?.(cache);
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: 'Detected concurrent request, waiting for it to finish'
|
|
});
|
|
}
|
|
|
|
try {
|
|
// 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({
|
|
id: config.id,
|
|
msg: 'Deferred rejected, requesting again',
|
|
data: err
|
|
});
|
|
}
|
|
|
|
// Hydrates any UI temporarily, if cache is available
|
|
/* 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.
|
|
return onFulfilled!(config);
|
|
}
|
|
} else {
|
|
cachedResponse = cache.data;
|
|
}
|
|
|
|
// The cached data is already transformed after receiving the response from the server.
|
|
// Reapplying the transformation on the transformed data will have an unintended effect.
|
|
// Since the cached data is already in the desired format, there is no need to apply the transformation function again.
|
|
config.transformResponse = undefined;
|
|
|
|
// Even though the response interceptor receives this one from here,
|
|
// it has been configured to ignore cached responses = true
|
|
config.adapter = function cachedAdapter(): Promise<CacheAxiosResponse> {
|
|
return Promise.resolve({
|
|
config,
|
|
data: cachedResponse.data,
|
|
headers: cachedResponse.headers,
|
|
status: cachedResponse.status,
|
|
statusText: cachedResponse.statusText,
|
|
cached: true,
|
|
stale: (cache as LoadingStorageValue).previous === 'stale',
|
|
id: config.id!
|
|
});
|
|
};
|
|
|
|
if (__ACI_DEV__) {
|
|
axios.debug({
|
|
id: config.id,
|
|
msg: 'Returning cached response'
|
|
});
|
|
}
|
|
|
|
return config;
|
|
};
|
|
|
|
return {
|
|
onFulfilled
|
|
};
|
|
}
|