import type { AxiosResponseHeaders } from 'axios'; import { parse } from 'cache-parser'; import type { AxiosCacheInstance, CacheAxiosResponse, CacheRequestConfig } from '../cache/axios.js'; import type { CacheProperties } from '../cache/cache.js'; import { Header } from '../header/headers.js'; import type { CachedStorageValue } from '../storage/types.js'; import { testCachePredicate } from '../util/cache-predicate.js'; import { updateCache } from '../util/update-cache.js'; import type { ResponseInterceptor } from './build.js'; import { createCacheResponse, isMethodIn } from './util.js'; export function defaultResponseInterceptor(axios: AxiosCacheInstance): ResponseInterceptor { /** * Rejects cache for an response response. * * Also update the waiting list for this key by rejecting it. */ const rejectResponse = async ( responseId: string, config: CacheRequestConfig, clearCache: boolean ) => { // Updates the cache to empty to prevent infinite loading state if (clearCache) { await axios.storage.remove(responseId, config); } // Rejects the deferred, if present const deferred = axios.waiting.get(responseId); if (deferred) { deferred.reject(); axios.waiting.delete(responseId); } }; /** * Resolves the waiting deferred for a response, if present, and removes it from the waiting map. */ const resolveWaiting = (responseId: string) => { const waiting = axios.waiting.get(responseId); if (waiting) { waiting.resolve(); axios.waiting.delete(responseId); if (__ACI_DEV__) { axios.debug({ id: responseId, msg: 'Found waiting deferred(s) and resolved them' }); } } }; const onFulfilled: ResponseInterceptor['onFulfilled'] = async (response) => { // When response.config is not present, the response is indeed a error. if (!response?.config) { if (__ACI_DEV__) { axios.debug({ msg: 'Response interceptor received an unknown response.', data: response }); } // Re-throws the error throw response; } response.id = response.config.id!; response.cached ??= false; const config = response.config; // Request interceptor merges defaults with per request configuration const cacheConfig = config.cache as CacheProperties; // Response is already cached if (response.cached) { if (__ACI_DEV__) { axios.debug({ id: response.id, msg: 'Returned cached response' }); } return response; } // Skip cache: either false or weird behavior // config.cache should always exists, at least from global config merge. if (!cacheConfig) { if (__ACI_DEV__) { axios.debug({ id: response.id, msg: 'Response with config.cache falsy', data: response }); } response.cached = false; return response; } // Update other entries before updating himself if (cacheConfig.update) { await updateCache(axios.storage, response, cacheConfig.update); } if (!isMethodIn(config.method, cacheConfig.methods)) { if (__ACI_DEV__) { axios.debug({ id: response.id, msg: `Ignored because method (${config.method}) is not in cache.methods (${cacheConfig.methods})`, data: { config, cacheConfig } }); } return response; } const cache = await axios.storage.get(response.id, config); if ( // If the request interceptor had a problem or it wasn't cached cache.state !== 'loading' ) { if (__ACI_DEV__) { axios.debug({ id: response.id, msg: "Response not cached and storage isn't loading", data: { cache, response } }); } // Clean up the waiting map if the cache was removed (e.g., due to maxEntries eviction) resolveWaiting(response.id); return response; } // Config told that this response should be cached. if ( // For 'loading' values (previous: stale), this check already ran in the past. !cache.data && !(await testCachePredicate(response, cacheConfig.cachePredicate)) ) { await rejectResponse(response.id, config, true); if (__ACI_DEV__) { axios.debug({ id: response.id, msg: 'Cache predicate rejected this response' }); } return response; } // Avoid remnant headers from remote server to break implementation for (const header of Object.keys(response.headers)) { if (header.startsWith('x-axios-cache')) { delete response.headers[header]; } } if (cacheConfig.etag && cacheConfig.etag !== true) { response.headers[Header.XAxiosCacheEtag] = cacheConfig.etag; } if (cacheConfig.modifiedSince) { response.headers[Header.XAxiosCacheLastModified] = cacheConfig.modifiedSince === true ? 'use-cache-timestamp' : cacheConfig.modifiedSince.toUTCString(); } let ttl = cacheConfig.ttl || -1; // always set from global config let staleTtl: number | undefined; if (cacheConfig.interpretHeader) { const expirationTime = axios.headerInterpreter(response.headers, axios.location); // Cache should not be used if (expirationTime === 'dont cache') { await rejectResponse(response.id, config, true); if (__ACI_DEV__) { axios.debug({ id: response.id, msg: `Cache header interpreted as 'dont cache'`, data: { cache, response, expirationTime } }); } return response; } if (expirationTime !== 'not enough headers') { if (typeof expirationTime === 'number') { ttl = expirationTime; } else { ttl = expirationTime.cache; staleTtl = expirationTime.stale; } } } const data = createCacheResponse(response, cache.data); if (typeof ttl === 'function') { ttl = await ttl(response); } if (cacheConfig.staleIfError) { response.headers[Header.XAxiosCacheStaleIfError] = String(ttl); } if (__ACI_DEV__) { axios.debug({ id: response.id, msg: 'Useful response configuration found', data: { cacheConfig, cacheResponse: data } }); } const newCache: CachedStorageValue = { state: 'cached', ttl, staleTtl, createdAt: Date.now(), 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 resolveWaiting(response.id); if (__ACI_DEV__) { axios.debug({ id: response.id, msg: 'Response cached', data: { cache: newCache, response } }); } // Return the response with cached as false, because it was not cached at all return response; }; const onRejected: ResponseInterceptor['onRejected'] = async (error) => { // When response.config is not present, the response is indeed a error. if (!error.isAxiosError || !error.config) { if (__ACI_DEV__) { axios.debug({ msg: 'FATAL: Received an non axios error in the rejected response interceptor, ignoring.', data: error }); } // We should probably re-request the response to avoid an infinite loading state here // but, since this is an unknown error, we cannot figure out what request ID to use. // And the only solution is to let the storage actively reject the current loading state. throw error; } const config = error.config as CacheRequestConfig & { headers: AxiosResponseHeaders }; const id = config.id; const cacheConfig = config.cache as CacheProperties; const response = error.response as CacheAxiosResponse | undefined; // config.cache should always exist, at least from global config merge. if (!cacheConfig || !id) { if (__ACI_DEV__) { axios.debug({ msg: 'Web request returned an error but cache handling is not enabled', data: { error } }); } throw error; } if (!isMethodIn(config.method, cacheConfig.methods)) { if (__ACI_DEV__) { axios.debug({ id, msg: `Ignored because method (${config.method}) is not in cache.methods (${cacheConfig.methods})`, data: { config, cacheConfig } }); } // Rejects all other requests waiting for this response await rejectResponse(id, config, true); throw error; } const cache = await axios.storage.get(id, config); if ( // This will only not be loading if the interceptor broke cache.state !== 'loading' || cache.previous !== 'stale' ) { if (__ACI_DEV__) { axios.debug({ id, msg: 'Caught an error in the request interceptor', data: { cache, error, config } }); } // Rejects all other requests waiting for this response await rejectResponse( id, config, // Do not clear cache if this request is cached, but the request was cancelled before returning the cached response error.code !== 'ERR_CANCELED' || (error.code === 'ERR_CANCELED' && cache.state !== 'cached') ); throw error; } if (cacheConfig.staleIfError) { const cacheControl = String(response?.headers[Header.CacheControl]); const staleHeader = cacheControl && parse(cacheControl).staleIfError; const staleIfError = typeof cacheConfig.staleIfError === 'function' ? await cacheConfig.staleIfError(response, cache, error) : cacheConfig.staleIfError === true && staleHeader ? staleHeader * 1000 //staleIfError is in seconds : cacheConfig.staleIfError; if (__ACI_DEV__) { axios.debug({ id, msg: 'Found cache if stale config for rejected response', data: { error, config, staleIfError } }); } if ( staleIfError === true || // staleIfError is the number of seconds that stale is allowed to be used (typeof staleIfError === 'number' && cache.createdAt + staleIfError > Date.now()) ) { // re-mark the cache as stale await axios.storage.set( id, { state: 'stale', createdAt: Date.now(), data: cache.data }, config ); // Resolve all other requests waiting for this response resolveWaiting(id); if (__ACI_DEV__) { axios.debug({ id, msg: 'staleIfError resolved this response with cached data', data: { error, config, cache } }); } return { cached: true, stale: true, config, id, data: cache.data.data, headers: cache.data.headers, status: cache.data.status, statusText: cache.data.statusText }; } } if (__ACI_DEV__) { axios.debug({ id, msg: 'Received an unknown error that could not be handled', data: { error, config } }); } // Rejects all other requests waiting for this response await rejectResponse(id, config, true); throw error; }; return { onFulfilled, onRejected }; }