diff --git a/src/cache/axios.ts b/src/cache/axios.ts index d7953c1..f2a07e9 100644 --- a/src/cache/axios.ts +++ b/src/cache/axios.ts @@ -76,8 +76,8 @@ export interface AxiosCacheInstance extends CacheInstance, AxiosInstance { }; interceptors: { - request: AxiosInterceptorManager>; - response: AxiosInterceptorManager>; + request: AxiosInterceptorManager>; + response: AxiosInterceptorManager>; }; /** @template D The type that the request body use */ diff --git a/src/cache/cache.ts b/src/cache/cache.ts index 1b050f2..29f4069 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -1,7 +1,7 @@ import type { Method } from 'axios'; import type { Deferred } from 'fast-defer'; import type { HeadersInterpreter } from '../header/types'; -import type { AxiosInterceptor } from '../interceptors/types'; +import type { AxiosInterceptor } from '../interceptors/build'; import type { AxiosStorage, CachedResponse } from '../storage/types'; import type { CachePredicate, CacheUpdater, KeyGenerator } from '../util/types'; import type { CacheAxiosResponse, CacheRequestConfig } from './axios'; @@ -89,7 +89,7 @@ export interface CacheInstance { /** * The function used to create different keys for each request. Defaults to a function * that priorizes the id, and if not specified, a string is generated using the method, - * baseUrl, params, and url + * baseURL, params, and url */ generateKey: KeyGenerator; diff --git a/src/cache/create.ts b/src/cache/create.ts index c2fc432..2463817 100644 --- a/src/cache/create.ts +++ b/src/cache/create.ts @@ -1,7 +1,7 @@ import type { AxiosInstance } from 'axios'; import { defaultHeaderInterpreter } from '../header/interpreter'; -import { CacheRequestInterceptor } from '../interceptors/request'; -import { CacheResponseInterceptor } from '../interceptors/response'; +import { defaultRequestInterceptor } from '../interceptors/request'; +import { defaultResponseInterceptor } from '../interceptors/response'; import { isStorage } from '../storage/build'; import { buildMemoryStorage } from '../storage/memory'; import { defaultKeyGenerator } from '../util/key-generator'; @@ -65,16 +65,16 @@ export function setupCache( axiosCache.storage = storage || buildMemoryStorage(); if (!isStorage(axiosCache.storage)) { - throw new Error('Use buildStorage()'); + throw new Error('Use buildStorage() function'); } axiosCache.generateKey = generateKey || defaultKeyGenerator; axiosCache.waiting = waiting || {}; axiosCache.headerInterpreter = headerInterpreter || defaultHeaderInterpreter; axiosCache.requestInterceptor = - requestInterceptor || new CacheRequestInterceptor(axiosCache); + requestInterceptor || defaultRequestInterceptor(axiosCache); axiosCache.responseInterceptor = - responseInterceptor || new CacheResponseInterceptor(axiosCache); + responseInterceptor || defaultResponseInterceptor(axiosCache); // CacheRequestConfig values axiosCache.defaults = { @@ -92,8 +92,8 @@ export function setupCache( }; // Apply interceptors - axiosCache.requestInterceptor.use(); - axiosCache.responseInterceptor.use(); + axiosCache.requestInterceptor.apply(axiosCache); + axiosCache.responseInterceptor.apply(axiosCache); // @ts-expect-error - internal only axiosCache[symbolKey] = 1; diff --git a/src/header/types.ts b/src/header/types.ts index a8ced76..33564bf 100644 --- a/src/header/types.ts +++ b/src/header/types.ts @@ -1,11 +1,4 @@ -/** - * `false` if cache should not be used. - * - * `undefined` when provided headers was not enough to determine a valid value. - * - * `number` containing the number of **milliseconds** to cache the response. - */ -type MaybeTtl = 'dont cache' | 'not enough headers' | number; +export type InterpreterResult = 'dont cache' | 'not enough headers' | number; /** * Interpret all http headers to determina a time to live. @@ -15,7 +8,7 @@ type MaybeTtl = 'dont cache' | 'not enough headers' | number; * enough to determine a valid value. Or a `number` containing the number of * **milliseconds** to cache the response. */ -export type HeadersInterpreter = (headers?: Record) => MaybeTtl; +export type HeadersInterpreter = (headers?: Record) => InterpreterResult; /** * Interpret a single string header @@ -28,4 +21,4 @@ export type HeadersInterpreter = (headers?: Record) => MaybeTtl; export type HeaderInterpreter = ( header: string, headers: Record -) => MaybeTtl; +) => InterpreterResult; diff --git a/src/index.ts b/src/index.ts index d16b29e..b78f228 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,9 @@ export * from './cache/cache'; export * from './cache/create'; export * from './header/interpreter'; export * from './header/types'; +export * from './interceptors/build'; export * from './interceptors/request'; export * from './interceptors/response'; -export * from './interceptors/types'; export * as InterceptorUtil from './interceptors/util'; export * from './storage/build'; export * from './storage/memory'; diff --git a/src/interceptors/build.ts b/src/interceptors/build.ts new file mode 100644 index 0000000..4d88efd --- /dev/null +++ b/src/interceptors/build.ts @@ -0,0 +1,35 @@ +import type { + AxiosCacheInstance, + CacheAxiosResponse, + CacheRequestConfig +} from '../cache/axios'; + +export interface AxiosInterceptor { + onFulfilled?(value: T): T | Promise; + onRejected?(error: any): any; + apply: (axios: AxiosCacheInstance) => 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 0c3a132..86187a0 100644 --- a/src/interceptors/request.ts +++ b/src/interceptors/request.ts @@ -1,15 +1,11 @@ import { deferred } from 'fast-defer'; -import type { - AxiosCacheInstance, - CacheAxiosResponse, - CacheRequestConfig -} from '../cache/axios'; +import { buildInterceptor } from '..'; +import type { AxiosCacheInstance, CacheAxiosResponse } from '../cache/axios'; import type { CachedResponse, CachedStorageValue, LoadingStorageValue } from '../storage/types'; -import type { AxiosInterceptor } from './types'; import { ConfigWithCache, createValidateStatus, @@ -17,111 +13,100 @@ import { setRevalidationHeaders } from './util'; -export class CacheRequestInterceptor - implements AxiosInterceptor> -{ - constructor(readonly axios: AxiosCacheInstance) {} - - readonly use = (): void => { - this.axios.interceptors.request.use(this.onFulfilled); - }; - - readonly onFulfilled = async ( - config: CacheRequestConfig - ): Promise> => { - if (config.cache === false) { - return config; - } - - // merge defaults with per request configuration - config.cache = { ...this.axios.defaults.cache, ...config.cache }; - - if ( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - !isMethodIn(config.method!, config.cache.methods) - ) { - return config; - } - - const key = this.axios.generateKey(config); - - // Assumes that the storage handled staled responses - let cache = await this.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 (this.axios.waiting[key]) { - cache = (await this.axios.storage.get(key)) as - | CachedStorageValue - | LoadingStorageValue; - break emptyOrStale; - } - - // Create a deferred to resolve other requests for the same key when it's completed - this.axios.waiting[key] = deferred(); - - /** - * Add a default reject handler to catch when the request is aborted without others - * waiting for it. - */ - this.axios.waiting[key]?.catch(() => undefined); - - await this.axios.storage.set(key, { - state: 'loading', - data: cache.data - }); - - if (cache.state === 'stale') { - setRevalidationHeaders(cache, config as ConfigWithCache); - } - - config.validateStatus = createValidateStatus(config.validateStatus); - - return config; - } - - let cachedResponse: CachedResponse; - - if (cache.state === 'loading') { - const deferred = this.axios.waiting[key]; - - // Just in case, the deferred doesn't exists. - /* istanbul ignore if 'really hard to test' */ - if (!deferred) { - await this.axios.storage.remove(key); +export function defaultRequestInterceptor(axios: AxiosCacheInstance) { + return buildInterceptor('request', { + onFulfilled: async (config) => { + if (config.cache === false) { return config; } - try { - cachedResponse = await deferred; - } catch { - // The deferred is rejected when the request that we are waiting rejected cache. + // merge defaults with per request configuration + config.cache = { ...axios.defaults.cache, ...config.cache }; + + if (!isMethodIn(config.method, config.cache.methods)) { return config; } - } else { - cachedResponse = cache.data; + + 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); + + 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; } - - 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; - }; + }); } diff --git a/src/interceptors/response.ts b/src/interceptors/response.ts index 48bbd29..89e3e76 100644 --- a/src/interceptors/response.ts +++ b/src/interceptors/response.ts @@ -1,140 +1,111 @@ -import type { AxiosResponse } from 'axios'; -import type { AxiosCacheInstance, CacheAxiosResponse } from '../cache/axios'; +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 type { AxiosInterceptor } from './types'; -import { setupCacheData } from './util'; +import { rejectResponse, setupCacheData } from './util'; -export class CacheResponseInterceptor - implements AxiosInterceptor> -{ - constructor(readonly axios: AxiosCacheInstance) {} +export function defaultResponseInterceptor(axios: AxiosCacheInstance) { + return buildInterceptor('response', { + onFulfilled: async (response) => { + response.id ??= axios.generateKey(response.config); + response.cached ??= false; - readonly use = (): void => { - this.axios.interceptors.response.use(this.onFulfilled); - }; - - readonly onFulfilled = async ( - axiosResponse: AxiosResponse - ): Promise> => { - const response = this.cachedResponse(axiosResponse); - - // Response is already cached - if (response.cached) { - return response; - } - - // Skip cache - // either false or weird behavior, config.cache should always exists, from global config merge at least - if (!response.config.cache) { - return { ...response, cached: false }; - } - - const cacheConfig = response.config.cache as CacheProperties; - - const cache = await this.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; - } - - // 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 this.rejectResponse(response.id); - return response; - } - - // 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.modifiedSince) { - response.headers[Header.XAxiosCacheLastModified] = - cacheConfig.modifiedSince === true - ? 'use-cache-timestamp' - : cacheConfig.modifiedSince.toUTCString(); - } - - let ttl = cacheConfig.ttl || -1; // always set from global config - - if (cacheConfig?.interpretHeader) { - const expirationTime = this.axios.headerInterpreter(response.headers); - - // Cache should not be used - if (expirationTime === 'dont cache') { - await this.rejectResponse(response.id); + // Response is already cached + if (response.cached) { return response; } - ttl = expirationTime === 'not enough headers' ? ttl : expirationTime; + // 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 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; + } + + // 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]; + + 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 + + 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; + } + + ttl = expirationTime === 'not enough headers' ? ttl : expirationTime; + } + + const data = setupCacheData(response, cache.data); + + if (typeof ttl === 'function') { + ttl = await ttl(response); + } + + 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); + } + + const deferred = 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); + + // Return the response with cached as false, because it was not cached at all + return response; } - - const data = setupCacheData(response, cache.data); - - if (typeof ttl === 'function') { - ttl = await ttl(response); - } - - const newCache: CachedStorageValue = { - state: 'cached', - ttl, - createdAt: Date.now(), - data - }; - - // Update other entries before updating himself - if (cacheConfig?.update) { - updateCache(this.axios.storage, response, cacheConfig.update); - } - - const deferred = this.axios.waiting[response.id]; - - // Resolve all other requests waiting for this response - await deferred?.resolve(newCache.data); - delete this.axios.waiting[response.id]; - - // Define this key as cache on the storage - await this.axios.storage.set(response.id, newCache); - - // Return the response with cached as false, because it was not cached at all - return response; - }; - - /** Rejects cache for this response. Also update the waiting list for this key by rejecting it. */ - readonly rejectResponse = async (key: string) => { - // Update the cache to empty to prevent infinite loading state - await this.axios.storage.remove(key); - // Reject the deferred if present - this.axios.waiting[key]?.reject(null); - delete this.axios.waiting[key]; - }; - - readonly cachedResponse = ( - response: AxiosResponse - ): CacheAxiosResponse => { - return { - id: this.axios.generateKey(response.config), - // The request interceptor response.cache will return true or undefined. And true only when the response was cached. - - cached: (response as CacheAxiosResponse).cached || false, - ...response - }; - }; + }); } diff --git a/src/interceptors/types.ts b/src/interceptors/types.ts deleted file mode 100644 index 48507c1..0000000 --- a/src/interceptors/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -export interface AxiosInterceptor { - onFulfilled?(value: T): T | Promise; - onRejected?(error: any): any; - - /** - * Should apply this interceptor to an already provided axios instance. Does not call - * this method explicitly. - */ - use(): void; -} diff --git a/src/interceptors/util.ts b/src/interceptors/util.ts index 871502a..5f48854 100644 --- a/src/interceptors/util.ts +++ b/src/interceptors/util.ts @@ -1,5 +1,9 @@ import type { Method } from 'axios'; -import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios'; +import type { + AxiosCacheInstance, + CacheAxiosResponse, + CacheRequestConfig +} from '../cache/axios'; import type { CacheProperties } from '../cache/cache'; import type { CachedResponse, StaleStorageValue } from '../storage/types'; import { Header } from '../util/headers'; @@ -17,7 +21,11 @@ export function createValidateStatus( } /** Checks if the given method is in the methods array */ -export function isMethodIn(requestMethod: Method, methodList: Method[] = []): boolean { +export function isMethodIn(requestMethod?: Method, methodList?: Method[]): boolean { + if (!requestMethod || !methodList) { + return false; + } + requestMethod = requestMethod.toLowerCase() as Lowercase; for (const method of methodList) { @@ -91,3 +99,19 @@ 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]; +} diff --git a/src/util/cache-predicate.ts b/src/util/cache-predicate.ts index aaefbb6..3cf885f 100644 --- a/src/util/cache-predicate.ts +++ b/src/util/cache-predicate.ts @@ -36,8 +36,7 @@ export function isCachePredicateValid( if (containsHeaders) { for (const headerName in containsHeaders) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const value = containsHeaders[headerName]!; + const value = containsHeaders[headerName as keyof typeof containsHeaders]; const header = response.headers[headerName]; // At any case, if the header is not found, the predicate fails. diff --git a/test/interceptors/util.test.ts b/test/interceptors/util.test.ts index cedc1df..715afe2 100644 --- a/test/interceptors/util.test.ts +++ b/test/interceptors/util.test.ts @@ -25,7 +25,7 @@ describe('test util functions', () => { expect(isMethodIn('get', ['get', 'post', 'put'])).toBe(true); expect(isMethodIn('post', ['get', 'post', 'put'])).toBe(true); - expect(isMethodIn('get')).toBe(false); + expect(isMethodIn()).toBe(false); expect(isMethodIn('get', [])).toBe(false); expect(isMethodIn('post', ['get', 'put', 'delete'])).toBe(false); expect(isMethodIn('get', ['post', 'put', 'delete'])).toBe(false);