diff --git a/.eslintrc b/.eslintrc index 8ab2746..57d7be6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -11,7 +11,8 @@ "plugin:prettier/recommended" ], "rules": { - "@typescript-eslint/await-thenable": "off" + "@typescript-eslint/await-thenable": "off", + "@typescript-eslint/restrict-template-expressions": "off" }, "parserOptions": { "ecmaVersion": "latest", diff --git a/src/cache/axios.ts b/src/cache/axios.ts index 976a22a..3ad0803 100644 --- a/src/cache/axios.ts +++ b/src/cache/axios.ts @@ -77,8 +77,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 e11991f..367155f 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -3,7 +3,12 @@ import type { Deferred } from 'fast-defer'; import type { HeadersInterpreter } from '../header/types'; import type { AxiosInterceptor } from '../interceptors/build'; import type { AxiosStorage, CachedResponse } from '../storage/types'; -import type { CachePredicate, CacheUpdater, KeyGenerator } from '../util/types'; +import type { + CachePredicate, + CacheUpdater, + KeyGenerator, + StaleIfErrorPredicate +} from '../util/types'; import type { CacheAxiosResponse, CacheRequestConfig } from './axios'; /** @@ -76,6 +81,37 @@ export type CacheProperties = { * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since */ modifiedSince: Date | boolean; + + /** + * Enables cache to be returned if the response comes with an error, either by invalid + * status code, network errors and etc. You can filter the type of error that should be + * stale by using a predicate function. + * + * **Note**: If this value ends up `false`, either by default or by a predicate function + * and there was an error, the request cache will be purged. + * + * **Note**: If the response is treated as error because of invalid status code *(like + * from AxiosRequestConfig#invalidateStatus)*, and this ends up `true`, the cache will + * be preserved over the "invalid" request. So, if you want to preserve the response, + * you can use this predicate: + * + * ```js + * const customPredicate = (response, cache, error) => { + * // Return false if has a response + * return !response; + * }; + * ``` + * + * Possible types: + * + * - `number` -> the max time (in seconds) that the cache can be reused. + * - `boolean` -> `false` disables and `true` enables with infinite time. + * - `function` -> a predicate that can return `number` or `boolean` as described above. + * + * @default false + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error + */ + staleIfError: StaleIfErrorPredicate; }; export interface CacheInstance { @@ -107,8 +143,8 @@ export interface CacheInstance { headerInterpreter: HeadersInterpreter; /** The request interceptor that will be used to handle the cache. */ - requestInterceptor: AxiosInterceptor>; + requestInterceptor: AxiosInterceptor; /** The response interceptor that will be used to handle the cache. */ - responseInterceptor: AxiosInterceptor>; + responseInterceptor: AxiosInterceptor; } diff --git a/src/cache/create.ts b/src/cache/create.ts index 0cdead6..dd34db3 100644 --- a/src/cache/create.ts +++ b/src/cache/create.ts @@ -67,19 +67,17 @@ export function setupCache( options.responseInterceptor || defaultResponseInterceptor(axiosCache); // CacheRequestConfig values - axiosCache.defaults = { - ...axios.defaults, - cache: { - ttl: options.ttl ?? 1000 * 60 * 5, - interpretHeader: options.interpretHeader ?? false, - methods: options.methods || ['get'], - cachePredicate: options.cachePredicate || { - statusCheck: (status) => status >= 200 && status < 400 - }, - etag: options.etag ?? false, - modifiedSince: options.modifiedSince ?? false, - update: options.update || {} - } + axiosCache.defaults.cache = { + ttl: options.ttl ?? 1000 * 60 * 5, + interpretHeader: options.interpretHeader ?? false, + methods: options.methods || ['get'], + cachePredicate: options.cachePredicate || { + statusCheck: (status) => status >= 200 && status < 400 + }, + etag: options.etag ?? false, + modifiedSince: options.modifiedSince ?? false, + staleIfError: options.staleIfError ?? false, + update: options.update || {} }; // Apply interceptors diff --git a/src/interceptors/build.ts b/src/interceptors/build.ts index f40c1ba..3d6b4b3 100644 --- a/src/interceptors/build.ts +++ b/src/interceptors/build.ts @@ -3,9 +3,12 @@ import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios'; /** See {@link AxiosInterceptorManager} */ export interface AxiosInterceptor { onFulfilled?(value: T): T | Promise; - onRejected?(error: unknown): unknown; + + /** Returns a successful response or re-throws the error */ + onRejected?(error: Record): T | Promise; + apply: () => void; } -export type RequestInterceptor = AxiosInterceptor>; -export type ResponseInterceptor = AxiosInterceptor>; +export type RequestInterceptor = AxiosInterceptor; +export type ResponseInterceptor = AxiosInterceptor; diff --git a/src/interceptors/request.ts b/src/interceptors/request.ts index 84e5d20..f83f6ea 100644 --- a/src/interceptors/request.ts +++ b/src/interceptors/request.ts @@ -10,7 +10,7 @@ import { ConfigWithCache, createValidateStatus, isMethodIn, - setRevalidationHeaders + updateStaleRequest } from './util'; export function defaultRequestInterceptor(axios: AxiosCacheInstance) { @@ -56,11 +56,17 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) { await axios.storage.set(key, { state: 'loading', - data: cache.data + previous: cache.state, + + // Eslint complains a lot :) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + data: cache.data as any, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + createdAt: cache.createdAt as any }); if (cache.state === 'stale') { - setRevalidationHeaders(cache, config as ConfigWithCache); + updateStaleRequest(cache, config as ConfigWithCache); } config.validateStatus = createValidateStatus(config.validateStatus); @@ -92,7 +98,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) { // Even though the response interceptor receives this one from here, // it has been configured to ignore cached responses = true - config.adapter = (): Promise> => + config.adapter = (): Promise => Promise.resolve({ config, data: cachedResponse.data, diff --git a/src/interceptors/response.ts b/src/interceptors/response.ts index bc1e7b5..15171b7 100644 --- a/src/interceptors/response.ts +++ b/src/interceptors/response.ts @@ -1,5 +1,9 @@ -import type { AxiosCacheInstance } from '../cache/axios'; -import type { CacheProperties } from '../cache/cache'; +import type { CacheProperties } from '..'; +import type { + AxiosCacheInstance, + CacheAxiosResponse, + CacheRequestConfig +} from '../cache/axios'; import type { CachedStorageValue } from '../storage/types'; import { testCachePredicate } from '../util/cache-predicate'; import { Header } from '../util/headers'; @@ -15,15 +19,12 @@ export function defaultResponseInterceptor( * * Also update the waiting list for this key by rejecting it. */ - const rejectResponse = async ( - { storage, waiting }: AxiosCacheInstance, - responseId: string - ) => { + const rejectResponse = async (responseId: string) => { // Update the cache to empty to prevent infinite loading state - await storage.remove(responseId); + await axios.storage.remove(responseId); // Reject the deferred if present - waiting[responseId]?.reject(null); - delete waiting[responseId]; + axios.waiting[responseId]?.reject(null); + delete axios.waiting[responseId]; }; const onFulfilled: ResponseInterceptor['onFulfilled'] = async (response) => { @@ -41,6 +42,7 @@ export function defaultResponseInterceptor( return { ...response, cached: false }; } + // Request interceptor merges defaults with per request configuration const cacheConfig = response.config.cache as CacheProperties; const cache = await axios.storage.get(response.id); @@ -61,13 +63,18 @@ export function defaultResponseInterceptor( !cache.data && !(await testCachePredicate(response, cacheConfig.cachePredicate)) ) { - await rejectResponse(axios, response.id); + await rejectResponse(response.id); return response; } // avoid remnant headers from remote server to break implementation - delete response.headers[Header.XAxiosCacheEtag]; - delete response.headers[Header.XAxiosCacheLastModified]; + for (const header in Header) { + if (!header.startsWith('XAxiosCache')) { + continue; + } + + delete response.headers[header]; + } if (cacheConfig.etag && cacheConfig.etag !== true) { response.headers[Header.XAxiosCacheEtag] = cacheConfig.etag; @@ -87,7 +94,7 @@ export function defaultResponseInterceptor( // Cache should not be used if (expirationTime === 'dont cache') { - await rejectResponse(axios, response.id); + await rejectResponse(response.id); return response; } @@ -100,6 +107,15 @@ export function defaultResponseInterceptor( ttl = await ttl(response); } + if (cacheConfig.staleIfError) { + response.headers[Header.XAxiosCacheStaleIfError] = String(ttl); + } + + // Update other entries before updating himself + if (cacheConfig?.update) { + await updateCache(axios.storage, response, cacheConfig.update); + } + const newCache: CachedStorageValue = { state: 'cached', ttl, @@ -107,15 +123,8 @@ export function defaultResponseInterceptor( data }; - // Update other entries before updating himself - if (cacheConfig?.update) { - await updateCache(axios.storage, response, cacheConfig.update); - } - - const deferred = axios.waiting[response.id]; - // Resolve all other requests waiting for this response - deferred?.resolve(newCache.data); + axios.waiting[response.id]?.resolve(newCache.data); delete axios.waiting[response.id]; // Define this key as cache on the storage @@ -125,8 +134,74 @@ export function defaultResponseInterceptor( return response; }; + const onRejected: ResponseInterceptor['onRejected'] = async (error) => { + const config = error['config'] as CacheRequestConfig; + + if (!config || config.cache === false || !config.id) { + throw error; + } + + const cache = await axios.storage.get(config.id); + const cacheConfig = config.cache; + + if ( + // This will only not be loading if the interceptor broke + cache.state !== 'loading' || + cache.previous !== 'stale' + ) { + await rejectResponse(config.id); + throw error; + } + + if (cacheConfig?.staleIfError) { + const staleIfError = + typeof cacheConfig.staleIfError === 'function' + ? await cacheConfig.staleIfError( + error.response as CacheAxiosResponse, + cache, + error + ) + : cacheConfig.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()) + ) { + const newCache: CachedStorageValue = { + state: 'cached', + ttl: Number(cache.data.headers[Header.XAxiosCacheStaleIfError]), + createdAt: Date.now(), + data: cache.data + }; + + const response: CacheAxiosResponse = { + cached: true, + config, + id: config.id, + data: cache.data?.data, + headers: cache.data?.headers, + status: cache.data.status, + statusText: cache.data.statusText + }; + + // Resolve all other requests waiting for this response + axios.waiting[response.id]?.resolve(newCache.data); + delete axios.waiting[response.id]; + + // Valid response + return response; + } + } + + // Reject this response and rethrows the error + await rejectResponse(config.id); + throw error; + }; + return { onFulfilled, - apply: () => axios.interceptors.response.use(onFulfilled) + onRejected, + apply: () => axios.interceptors.response.use(onFulfilled, onRejected) }; } diff --git a/src/interceptors/util.ts b/src/interceptors/util.ts index 713f388..ea3c8d8 100644 --- a/src/interceptors/util.ts +++ b/src/interceptors/util.ts @@ -36,7 +36,11 @@ export type ConfigWithCache = CacheRequestConfig & { cache: Partial; }; -export function setRevalidationHeaders( +/** + * This function updates the cache when the request is stale. So, the next request to the + * server will be made with proper header / settings. + */ +export function updateStaleRequest( cache: StaleStorageValue, config: ConfigWithCache ): void { diff --git a/src/storage/build.ts b/src/storage/build.ts index 236fb43..e487cc0 100644 --- a/src/storage/build.ts +++ b/src/storage/build.ts @@ -56,9 +56,11 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage if ( value.data.headers && + // Any header below allows the response to stale (Header.ETag in value.data.headers || Header.LastModified in value.data.headers || Header.XAxiosCacheEtag in value.data.headers || + Header.XAxiosCacheStaleIfError in value.data.headers || Header.XAxiosCacheLastModified in value.data.headers) ) { const stale: StaleStorageValue = { diff --git a/src/storage/types.ts b/src/storage/types.ts index ac0e878..c117eea 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -16,6 +16,12 @@ export type StorageValue = export type NotEmptyStorageValue = Exclude; +export type StorageMetadata = { + /** If the request can be stale */ + shouldStale?: boolean; + [key: string]: unknown; +}; + export type StaleStorageValue = { data: CachedResponse; ttl?: undefined; @@ -31,18 +37,21 @@ export type CachedStorageValue = { state: 'cached'; }; -export type LoadingStorageValue = { - /** - * Only present if the previous state was `stale`. So, in case the new response comes - * without a value, this data is used - */ - data?: CachedResponse; - ttl?: number; - - /** Defined when the state is cached */ - createdAt?: undefined; - state: 'loading'; -}; +export type LoadingStorageValue = + | { + data?: undefined; + ttl?: undefined; + createdAt?: undefined; + state: 'loading'; + previous: 'empty'; + } + | { + state: 'loading'; + data: CachedResponse; + ttl?: undefined; + createdAt: number; + previous: 'stale'; + }; export type EmptyStorageValue = { data?: undefined; diff --git a/src/util/headers.ts b/src/util/headers.ts index ff2b724..4e8c825 100644 --- a/src/util/headers.ts +++ b/src/util/headers.ts @@ -28,7 +28,13 @@ export const Header = Object.freeze({ */ IfNoneMatch: 'if-none-match', - /** @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control */ + /** + * ```txt + * Cache-Control: max-age=604800 + * ``` + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control + */ CacheControl: 'cache-control', /** @@ -70,8 +76,8 @@ export const Header = Object.freeze({ ContentType: 'content-type', /** - * Used internally to mark the cache item as being revalidatable and enabling stale - * cache state Contains a string of ASCII characters that can be used as ETag for + * Used internally as metadata to mark the cache item as revalidatable and enabling + * stale cache state Contains a string of ASCII characters that can be used as ETag for * `If-Match` header Provided by user using `cache.etag` value. * * ```txt @@ -81,16 +87,27 @@ export const Header = Object.freeze({ XAxiosCacheEtag: 'x-axios-cache-etag', /** - * Used internally to mark the cache item as being revalidatable and enabling stale - * cache state may contain `'use-cache-timestamp'` if `cache.modifiedSince` is `true`, - * otherwise will contain a date from `cache.modifiedSince`. If a date is provided, it - * can be used for `If-Modified-Since` header, otherwise the cache timestamp can be used - * for `If-Modified-Since` header. + * Used internally as metadata to mark the cache item as revalidatable and enabling + * stale cache state may contain `'use-cache-timestamp'` if `cache.modifiedSince` is + * `true`, otherwise will contain a date from `cache.modifiedSince`. If a date is + * provided, it can be used for `If-Modified-Since` header, otherwise the cache + * timestamp can be used for `If-Modified-Since` header. * * ```txt * X-Axios-Cache-Last-Modified: , :: GMT * X-Axios-Cache-Last-Modified: use-cache-timestamp * ``` */ - XAxiosCacheLastModified: 'x-axios-cache-last-modified' + XAxiosCacheLastModified: 'x-axios-cache-last-modified', + + /** + * Used internally as metadata to mark the cache item able to be used if the server + * returns an error. The stale-if-error response directive indicates that the cache can + * reuse a stale response when any error occurs. + * + * ```txt + * XAxiosCacheStaleIfError: + * ``` + */ + XAxiosCacheStaleIfError: 'x-axios-cache-stale-if-error' }); diff --git a/src/util/types.ts b/src/util/types.ts index 36a5771..1e602b2 100644 --- a/src/util/types.ts +++ b/src/util/types.ts @@ -41,3 +41,20 @@ export type CacheUpdater = cached: Exclude, response: CacheAxiosResponse ) => MaybePromise); + +/** + * You can use a `number` to ensure an max time (in seconds) that the cache can be reused. + * + * You can use `true` to use the cache until a new response is received. + * + * You can use a `function` predicate to determine if the cache can be reused (`boolean`) + * or how much time the cache can be used (`number`) + */ +export type StaleIfErrorPredicate = + | number + | boolean + | (( + networkResponse: CacheAxiosResponse | undefined, + cache: LoadingStorageValue & { previous: 'stale' }, + error: Record + ) => MaybePromise); diff --git a/test/interceptors/stale-if-error.test.ts b/test/interceptors/stale-if-error.test.ts new file mode 100644 index 0000000..c4a063c --- /dev/null +++ b/test/interceptors/stale-if-error.test.ts @@ -0,0 +1,288 @@ +import Axios from 'axios'; +import { setupCache } from '../../src/cache/create'; +import { Header } from '../../src/util/headers'; +import { mockAxios } from '../mocks/axios'; + +describe('Last-Modified handling', () => { + it('expects that error is thrown', async () => { + const instance = Axios.create({}); + const axios = setupCache(instance, {}); + + try { + await axios.get('http://unknown.url.lan:1234'); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + axios.defaults.cache.staleIfError = 10e5; + + try { + await axios.get('http://unknown.url.lan:1234'); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + axios.defaults.cache.staleIfError = true; + + try { + await axios.get('http://unknown.url.lan:1234'); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + expect.assertions(3); + }); + + it('expects staleIfError does nothing without cache', async () => { + const axios = setupCache(Axios.create(), { + staleIfError: () => Promise.resolve(true) + }); + + try { + await axios.get('http://unknown.url.lan:1234'); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + expect.assertions(1); + }); + + it('expects that XAxiosCacheStaleIfError is defined', async () => { + const axios = mockAxios(); + + const { headers } = await axios.get('url', { + cache: { staleIfError: true } + }); + + expect(headers).toHaveProperty(Header.XAxiosCacheStaleIfError); + }); + + it('expects staleIfError is ignore if config.cache is false', async () => { + const axios = setupCache(Axios.create(), { + staleIfError: true + }); + + const cache = { + data: true, + headers: {}, + status: 200, + statusText: 'Ok' + }; + + // Fill the cache + const id = 'some-config-id'; + await axios.storage.set(id, { + state: 'stale', + createdAt: Date.now(), + data: cache + }); + + try { + await axios.get('http://unknown-url.lan:9090', { + id, + cache: false + }); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + expect.assertions(1); + }); + + it('tests staleIfError', async () => { + const axios = setupCache(Axios.create(), { + staleIfError: true + }); + + const cache = { + data: true, + headers: {}, + status: 200, + statusText: 'Ok' + }; + + // Fill the cache + const id = 'some-config-id'; + await axios.storage.set(id, { + state: 'stale', + createdAt: Date.now(), + data: cache + }); + + const response = await axios.get('http://unknown-url.lan:9090', { + id, + cache: { staleIfError: true } + }); + + expect(response).toBeDefined(); + expect(response.id).toBe(id); + expect(response.data).toBe(cache.data); + expect(response.status).toBe(cache.status); + expect(response.statusText).toBe(cache.statusText); + expect(response.headers).toBe(cache.headers); + expect(response.cached).toBe(true); + }); + + it('expects that staleIfError needs to be true', async () => { + const axios = setupCache(Axios.create(), { + staleIfError: true + }); + + const cache = { + data: true, + headers: {}, + status: 200, + statusText: 'Ok' + }; + + // Fill the cache + const id = 'some-config-id'; + await axios.storage.set(id, { + state: 'stale', + createdAt: Date.now(), + data: cache + }); + + try { + await axios.get('http://unknown-url.lan:9090', { + id, + cache: { staleIfError: false } + }); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + expect.assertions(1); + }); + + it('tests staleIfError returning false', async () => { + const axios = setupCache(Axios.create(), { + staleIfError: () => false + }); + + const id = 'some-config-id'; + const cache = { + data: true, + headers: {}, + status: 200, + statusText: 'Ok' + }; + + // Fill the cache + await axios.storage.set(id, { + state: 'stale', + createdAt: Date.now(), + data: cache + }); + + try { + await axios.get('http://unknown-url.lan:9090', { + id + }); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + expect.assertions(1); + }); + + it('tests staleIfError as function', async () => { + const axios = setupCache(Axios.create(), { + staleIfError: () => { + return Promise.resolve(false); + } + }); + + const id = 'some-config-id'; + + try { + await axios.get('http://unknown-url.lan:9090', { id }); + expect(true).toBe(false); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + try { + await axios.get('http://unknown-url.lan:9090', { + id, + cache: { + staleIfError: () => 1 // past + } + }); + expect(true).toBe(false); + } catch (error) { + expect(Axios.isAxiosError(error)).toBe(true); + } + + const cache = { + data: true, + headers: {}, + status: 200, + statusText: 'Ok' + }; + + // Fill the cache + await axios.storage.set(id, { + state: 'stale', + createdAt: Date.now(), + data: cache + }); + + const response = await axios.get('http://unknown-url.lan:9090', { + id, + cache: { + staleIfError: () => 10e5 // nearly infinity :) + } + }); + + expect(response).toBeDefined(); + expect(response.id).toBe(id); + expect(response.data).toBe(cache.data); + expect(response.status).toBe(cache.status); + expect(response.statusText).toBe(cache.statusText); + expect(response.headers).toBe(cache.headers); + expect(response.cached).toBe(true); + }); + + it('tests staleIfError with real 50X status code', async () => { + const axios = setupCache(Axios.create(), { staleIfError: true }); + + const id = 'some-config-id'; + + const cache = { + data: true, + headers: {}, + status: 200, + statusText: 'Ok' + }; + + // Fill the cache + await axios.storage.set(id, { + state: 'stale', + createdAt: Date.now(), + data: cache + }); + + const response = await axios.get('https://httpbin.org/status/503', { + id + }); + + expect(response).toBeDefined(); + expect(response.id).toBe(id); + expect(response.data).toBe(cache.data); + expect(response.status).toBe(cache.status); + expect(response.statusText).toBe(cache.statusText); + expect(response.headers).toBe(cache.headers); + expect(response.cached).toBe(true); + + const newResponse = await axios.get('https://httpbin.org/status/503', { + id, + validateStatus: () => true // prevents error + }); + + expect(newResponse).toBeDefined(); + expect(newResponse.id).toBe(id); + expect(newResponse.data).not.toBe(cache.data); + expect(newResponse.status).toBe(503); + }); +});