Edwin Veldhuizen 6cba59cc1c
[must-revalidate] properly revalidate based on eTag (#824)
* [must-revalidate] implement logic to properly revalidate requests with must-revalidate cache-control header

* [lint] fix sorting of imports

* [pull 824] apply feedback

---------

Co-authored-by: Edwin Veldhuizen <edwin@pxr.nl>
2024-04-23 13:06:04 -03:00

155 lines
4.9 KiB
TypeScript

import type { CacheRequestConfig } from '../cache/axios.js';
import { Header } from '../header/headers.js';
import type { MaybePromise } from '../util/types.js';
import type { AxiosStorage, CachedStorageValue, StaleStorageValue, StorageValue } from './types.js';
/** Returns true if the provided object was created from {@link buildStorage} function. */
export const isStorage = (obj: unknown): obj is AxiosStorage =>
!!obj && !!(obj as Record<string, boolean>)['is-storage'];
function hasUniqueIdentifierHeader(value: CachedStorageValue | StaleStorageValue): boolean {
const headers = value.data.headers;
return (
Header.ETag in headers ||
Header.LastModified in headers ||
Header.XAxiosCacheEtag in headers ||
Header.XAxiosCacheLastModified in headers
);
}
/** Returns true if value must be revalidated */
export function mustRevalidate(value: CachedStorageValue | StaleStorageValue): boolean {
// Must revalidate is a special case and should not serve stale values
// We could use cache-control's parse function, but this is way faster and simpler
return String(value.data.headers[Header.CacheControl]).includes('must-revalidate');
}
/** Returns true if this has sufficient properties to stale instead of expire. */
export function canStale(value: CachedStorageValue): boolean {
if (hasUniqueIdentifierHeader(value)) {
return true;
}
return (
value.state === 'cached' &&
value.staleTtl !== undefined &&
// Only allow stale values after the ttl is already in the past and the staleTtl is in the future.
// In cases that just createdAt + ttl > Date.now(), isn't enough because the staleTtl could be <= 0.
// This logic only returns true when Date.now() is between the (createdAt + ttl) and (createdAt + ttl + staleTtl).
// Following the example below:
// |--createdAt--:--ttl--:---staleTtl--->
// [ past ][now is in here]
Math.abs(Date.now() - (value.createdAt + value.ttl)) <= value.staleTtl
);
}
/**
* Checks if the provided cache is expired. You should also check if the cache
* {@link canStale} and {@link mayUseStale}
*/
export function isExpired(value: CachedStorageValue | StaleStorageValue): boolean {
return value.ttl !== undefined && value.createdAt + value.ttl <= Date.now();
}
export interface BuildStorage extends Omit<AxiosStorage, 'get'> {
/**
* Returns the value for the given key. This method does not have to make checks for
* cache invalidation or anything. It just returns what was previous saved, if present.
*
* @param key The key to look for
* @param currentRequest The current {@link CacheRequestConfig}, if any
* @see https://axios-cache-interceptor.js.org/guide/storages#buildstorage
*/
find: (
key: string,
currentRequest?: CacheRequestConfig
) => MaybePromise<StorageValue | undefined>;
}
/**
* All integrated storages are wrappers around the `buildStorage` function. External
* libraries use it and if you want to build your own, `buildStorage` is the way to go!
*
* The exported `buildStorage` function abstracts the storage interface and requires a
* super simple object to build the storage.
*
* **Note**: You can only create an custom storage with this function.
*
* @example
*
* ```js
* const myStorage = buildStorage({
* find: () => {...},
* set: () => {...},
* remove: () => {...}
* });
*
* const axios = setupCache(axios, { storage: myStorage });
* ```
*
* @see https://axios-cache-interceptor.js.org/guide/storages#buildstorage
*/
export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage {
return {
//@ts-expect-error - we don't want to expose this
'is-storage': 1,
set,
remove,
get: async (key, config) => {
let value = await find(key, config);
if (!value) {
return { state: 'empty' };
}
if (
value.state === 'empty' ||
value.state === 'loading' ||
value.state === 'must-revalidate'
) {
return value;
}
// Handle cached values
if (value.state === 'cached') {
if (!isExpired(value)) {
return value;
}
// Tries to stale expired value
if (!canStale(value)) {
await remove(key, config);
return { state: 'empty' };
}
value = {
state: 'stale',
createdAt: value.createdAt,
data: value.data,
ttl: value.staleTtl !== undefined ? value.staleTtl + value.ttl : undefined
};
await set(key, value, config);
// Must revalidate is a special case and should not serve stale values
if (mustRevalidate(value)) {
return { ...value, state: 'must-revalidate' };
}
}
// A second check in case the new stale value was created already expired.
if (!isExpired(value)) {
return value;
}
if (hasUniqueIdentifierHeader(value)) {
return value;
}
await remove(key, config);
return { state: 'empty' };
}
};
}