diff --git a/src/interceptors/build.ts b/src/interceptors/build.ts index 4d88efdc..14484de4 100644 --- a/src/interceptors/build.ts +++ b/src/interceptors/build.ts @@ -1,35 +1,10 @@ -import type { - AxiosCacheInstance, - CacheAxiosResponse, - CacheRequestConfig -} from '../cache/axios'; +import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios'; export interface AxiosInterceptor { onFulfilled?(value: T): T | Promise; onRejected?(error: any): any; - apply: (axios: AxiosCacheInstance) => void; + apply: () => void; } export type RequestInterceptor = AxiosInterceptor>; export type ResponseInterceptor = AxiosInterceptor>; - -export function buildInterceptor( - type: 'request', - interceptor: Omit -): RequestInterceptor; - -export function buildInterceptor( - type: 'response', - interceptor: Omit -): ResponseInterceptor; - -export function buildInterceptor( - type: 'request' | 'response', - { onFulfilled, onRejected }: Omit, 'apply'> -): AxiosInterceptor { - return { - onFulfilled, - onRejected, - apply: (axios) => axios.interceptors[type].use(onFulfilled, onRejected) - }; -} diff --git a/src/interceptors/request.ts b/src/interceptors/request.ts index 86187a08..b37924d4 100644 --- a/src/interceptors/request.ts +++ b/src/interceptors/request.ts @@ -1,11 +1,11 @@ import { deferred } from 'fast-defer'; -import { buildInterceptor } from '..'; import type { AxiosCacheInstance, CacheAxiosResponse } from '../cache/axios'; import type { CachedResponse, CachedStorageValue, LoadingStorageValue } from '../storage/types'; +import type { RequestInterceptor } from './build'; import { ConfigWithCache, createValidateStatus, @@ -14,99 +14,102 @@ import { } from './util'; export function defaultRequestInterceptor(axios: AxiosCacheInstance) { - return buildInterceptor('request', { - onFulfilled: async (config) => { - if (config.cache === false) { - return config; - } + const onFulfilled: RequestInterceptor['onFulfilled'] = async (config) => { + if (config.cache === false) { + return config; + } - // merge defaults with per request configuration - config.cache = { ...axios.defaults.cache, ...config.cache }; + // merge defaults with per request configuration + config.cache = { ...axios.defaults.cache, ...config.cache }; - if (!isMethodIn(config.method, config.cache.methods)) { - return config; + if (!isMethodIn(config.method, config.cache.methods)) { + return config; + } + + const key = axios.generateKey(config); + + // Assumes that the storage handled staled responses + let cache = await axios.storage.get(key); + + // Not cached, continue the request, and mark it as fetching + emptyOrStale: if (cache.state == 'empty' || cache.state === 'stale') { + /** + * 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[key]) { + cache = (await axios.storage.get(key)) as + | CachedStorageValue + | LoadingStorageValue; + break emptyOrStale; } - const key = axios.generateKey(config); - - // Assumes that the storage handled staled responses - let cache = await axios.storage.get(key); - - // Not cached, continue the request, and mark it as fetching - emptyOrStale: if (cache.state == 'empty' || cache.state === 'stale') { - /** - * 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[key]) { - cache = (await axios.storage.get(key)) as - | CachedStorageValue - | LoadingStorageValue; - break emptyOrStale; - } - - // Create a deferred to resolve other requests for the same key when it's completed - axios.waiting[key] = deferred(); - - /** - * Add a default reject handler to catch when the request is aborted without - * others waiting for it. - */ - axios.waiting[key]?.catch(() => undefined); - - await axios.storage.set(key, { - state: 'loading', - data: cache.data - }); - - if (cache.state === 'stale') { - setRevalidationHeaders(cache, config as ConfigWithCache); - } - - config.validateStatus = createValidateStatus(config.validateStatus); + // Create a deferred to resolve other requests for the same key when it's completed + axios.waiting[key] = deferred(); - return config; - } + /** + * Add a default reject handler to catch when the request is aborted without others + * waiting for it. + */ + axios.waiting[key]?.catch(() => undefined); + + await axios.storage.set(key, { + state: 'loading', + data: cache.data + }); - let cachedResponse: CachedResponse; - - if (cache.state === 'loading') { - const deferred = axios.waiting[key]; - - // Just in case, the deferred doesn't exists. - /* istanbul ignore if 'really hard to test' */ - if (!deferred) { - await axios.storage.remove(key); - return config; - } - - try { - cachedResponse = await deferred; - } catch { - // The deferred is rejected when the request that we are waiting rejected cache. - return config; - } - } else { - cachedResponse = cache.data; + if (cache.state === 'stale') { + setRevalidationHeaders(cache, config as ConfigWithCache); } - config.adapter = () => - /** - * Even though the response interceptor receives this one from here, it has been - * configured to ignore cached responses: true - */ - Promise.resolve>({ - config, - data: cachedResponse.data, - headers: cachedResponse.headers, - status: cachedResponse.status, - statusText: cachedResponse.statusText, - cached: true, - id: key - }); + config.validateStatus = createValidateStatus(config.validateStatus); return config; } - }); + + let cachedResponse: CachedResponse; + + if (cache.state === 'loading') { + const deferred = axios.waiting[key]; + + // Just in case, the deferred doesn't exists. + /* istanbul ignore if 'really hard to test' */ + if (!deferred) { + await axios.storage.remove(key); + return config; + } + + try { + cachedResponse = await deferred; + } catch { + // The deferred is rejected when the request that we are waiting rejected cache. + return config; + } + } else { + cachedResponse = cache.data; + } + + config.adapter = () => + /** + * Even though the response interceptor receives this one from here, it has been + * configured to ignore cached responses: true + */ + Promise.resolve>({ + config, + data: cachedResponse.data, + headers: cachedResponse.headers, + status: cachedResponse.status, + statusText: cachedResponse.statusText, + cached: true, + id: key + }); + + return config; + }; + + return { + onFulfilled, + apply: () => axios.interceptors.request.use(onFulfilled) + }; } diff --git a/src/interceptors/response.ts b/src/interceptors/response.ts index 89e3e76b..4d3aa6d0 100644 --- a/src/interceptors/response.ts +++ b/src/interceptors/response.ts @@ -1,111 +1,132 @@ -import { buildInterceptor } from '..'; import type { AxiosCacheInstance } from '../cache/axios'; import type { CacheProperties } from '../cache/cache'; import type { CachedStorageValue } from '../storage/types'; import { shouldCacheResponse } from '../util/cache-predicate'; import { Header } from '../util/headers'; import { updateCache } from '../util/update-cache'; -import { rejectResponse, setupCacheData } from './util'; - -export function defaultResponseInterceptor(axios: AxiosCacheInstance) { - return buildInterceptor('response', { - onFulfilled: async (response) => { - response.id ??= axios.generateKey(response.config); - response.cached ??= false; +import type { ResponseInterceptor } from './build'; +import { setupCacheData } from './util'; + +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 ( + { storage, waiting }: AxiosCacheInstance, + responseId: string + ) => { + // Update the cache to empty to prevent infinite loading state + await storage.remove(responseId); + // Reject the deferred if present + waiting[responseId]?.reject(null); + delete waiting[responseId]; + }; + + const onFulfilled: ResponseInterceptor['onFulfilled'] = async (response) => { + response.id ??= axios.generateKey(response.config); + response.cached ??= false; + + // Response is already cached + if (response.cached) { + return response; + } - // Response is already cached - if (response.cached) { - return response; - } + // Skip cache: either false or weird behavior + // config.cache should always exists, at least from global config merge. + if (!response.config.cache) { + return { ...response, cached: false }; + } - // Skip cache: either false or weird behavior - // config.cache should always exists, at least from global config merge. - if (!response.config.cache) { - return { ...response, cached: false }; - } + const cacheConfig = response.config.cache as CacheProperties; - const cacheConfig = response.config.cache as CacheProperties; + const cache = await axios.storage.get(response.id); - const cache = await axios.storage.get(response.id); + if ( + // If the request interceptor had a problem + cache.state === 'stale' || + cache.state === 'empty' || + // Should not hit here because of previous response.cached check + cache.state === 'cached' + ) { + return response; + } - if ( - // If the request interceptor had a problem - cache.state === 'stale' || - cache.state === 'empty' || - // Should not hit here because of previous response.cached check - cache.state === 'cached' - ) { - return response; - } + // Config told that this response should be cached. + if ( + // For 'loading' values (post stale), this check was already run in the past. + !cache.data && + !shouldCacheResponse(response, cacheConfig) + ) { + await rejectResponse(axios, response.id); + return response; + } - // Config told that this response should be cached. - if ( - // For 'loading' values (post stale), this check was already run in the past. - !cache.data && - !shouldCacheResponse(response, cacheConfig) - ) { - await rejectResponse(axios, response.id); - return response; - } + // avoid remnant headers from remote server to break implementation + delete response.headers[Header.XAxiosCacheEtag]; + delete response.headers[Header.XAxiosCacheLastModified]; - // avoid remnant headers from remote server to break implementation - delete response.headers[Header.XAxiosCacheEtag]; - delete response.headers[Header.XAxiosCacheLastModified]; + if (cacheConfig.etag && cacheConfig.etag !== true) { + response.headers[Header.XAxiosCacheEtag] = cacheConfig.etag; + } - 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(); + } - 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 ttl = cacheConfig.ttl || -1; // always set from global config + if (cacheConfig?.interpretHeader) { + const expirationTime = axios.headerInterpreter(response.headers); - if (cacheConfig?.interpretHeader) { - const expirationTime = axios.headerInterpreter(response.headers); + // Cache should not be used + if (expirationTime === 'dont cache') { + await rejectResponse(axios, response.id); + return response; + } - // Cache should not be used - if (expirationTime === 'dont cache') { - await rejectResponse(axios, response.id); - return response; - } + ttl = expirationTime === 'not enough headers' ? ttl : expirationTime; + } - ttl = expirationTime === 'not enough headers' ? ttl : expirationTime; - } + const data = setupCacheData(response, cache.data); - const data = setupCacheData(response, cache.data); + if (typeof ttl === 'function') { + ttl = await ttl(response); + } - if (typeof ttl === 'function') { - ttl = await ttl(response); - } + const newCache: CachedStorageValue = { + state: 'cached', + ttl, + createdAt: Date.now(), + data + }; - const newCache: CachedStorageValue = { - state: 'cached', - ttl, - createdAt: Date.now(), - data - }; + // Update other entries before updating himself + if (cacheConfig?.update) { + updateCache(axios.storage, response, cacheConfig.update); + } - // Update other entries before updating himself - if (cacheConfig?.update) { - updateCache(axios.storage, response, cacheConfig.update); - } + const deferred = axios.waiting[response.id]; - const deferred = axios.waiting[response.id]; + // Resolve all other requests waiting for this response + await deferred?.resolve(newCache.data); + delete axios.waiting[response.id]; - // Resolve all other requests waiting for this response - await deferred?.resolve(newCache.data); - delete axios.waiting[response.id]; + // Define this key as cache on the storage + await axios.storage.set(response.id, newCache); - // Define this key as cache on the storage - await axios.storage.set(response.id, newCache); + // Return the response with cached as false, because it was not cached at all + return response; + }; - // Return the response with cached as false, because it was not cached at all - return response; - } - }); + return { + onFulfilled, + apply: () => axios.interceptors.response.use(onFulfilled) + }; } diff --git a/src/interceptors/util.ts b/src/interceptors/util.ts index 5f488541..c42b26f8 100644 --- a/src/interceptors/util.ts +++ b/src/interceptors/util.ts @@ -1,9 +1,5 @@ import type { Method } from 'axios'; -import type { - AxiosCacheInstance, - CacheAxiosResponse, - CacheRequestConfig -} from '../cache/axios'; +import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios'; import type { CacheProperties } from '../cache/cache'; import type { CachedResponse, StaleStorageValue } from '../storage/types'; import { Header } from '../util/headers'; @@ -21,11 +17,10 @@ export function createValidateStatus( } /** Checks if the given method is in the methods array */ -export function isMethodIn(requestMethod?: Method, methodList?: Method[]): boolean { - if (!requestMethod || !methodList) { - return false; - } - +export function isMethodIn( + requestMethod: Method = 'get', + methodList: Method[] = [] +): boolean { requestMethod = requestMethod.toLowerCase() as Lowercase; for (const method of methodList) { @@ -99,19 +94,3 @@ export function setupCacheData( headers: response.headers }; } - -/** - * Rejects cache for an response response. - * - * Also update the waiting list for this key by rejecting it. - */ -export async function rejectResponse( - { storage, waiting }: AxiosCacheInstance, - responseId: string -) { - // Update the cache to empty to prevent infinite loading state - await storage.remove(responseId); - // Reject the deferred if present - waiting[responseId]?.reject(null); - delete waiting[responseId]; -}