mirror of
https://github.com/arthurfiorette/axios-cache-interceptor.git
synced 2025-12-08 17:36:16 +00:00
feat: etag and if-modified-since support (#53)
* feat(WIP): etag and if-modified-since support * test: fixed test name * fix: merge response headers * test: add etag / last-modified tests * test: add must-revalidate tests * fix: handle expirationTime 0 as true. * tests: refactored some tests * test: added keepIfStale tests * fix: remove axios error for 304 requests * fix: possible infinite loop on validateStatus function * tests: ignore code that is hard test from coverage * fix: use Last-Modified header for modifiedSince If-Modified-Since is never sent by a server, it's a client only header. However Last-Modified is sent by the server to indicate the last time the returned version was modified (it might not be the last version depending on cache configuration on intermediate servers) * test: use validateStatus in mock This more closely match default axios adapter. * fix: validateStatus handling all cases * refactor: use cache.createdAt if the last-modified header isn't present * test: etag with predefined value * test: added more test cases * fix: fixed logic in some tests * docs: initial documentation * fix: manual values work out of the box This removes header requirement from server. * docs: add details to etag and modifiedSince features * fix: delete custom headers from response * feat: accept all configurations from global axios Merging global into local configuration enable user to use global configuration for all options and remove the need to check local and global values every time. * fix: preserve original types from AxiosInstance The only value axios needs is a URL, and in the second definition of the method, there is already a URL parameter, so it can be undefined. * Fix: defaults modifiedSince back to false. Avoids breaking changes. * docs: fix etag examples * docs: document internal headers * refactor: ternary operator :) * style: prettified code * test: remove modifiedSince: false in tests since this is the default * docs: fix headers examples * docs: fixed example formatting * tests: split tests into multiple files to test them faster * docs: correct jsdoc empty object Co-authored-by: Charly Koza <cka@f4-group.com>
This commit is contained in:
parent
b35ae3e574
commit
48e33c5d5a
16
README.md
16
README.md
@ -106,6 +106,8 @@ const resp2 = await api.get('https://api.example.com/');
|
||||
- [request.cache.methods](#requestcachemethods)
|
||||
- [request.cache.cachePredicate](#requestcachecachepredicate)
|
||||
- [request.cache.update](#requestcacheupdate)
|
||||
- [request.cache.etag](#requestcacheetag)
|
||||
- [request.cache.modifiedSince](#requestcachemodifiedsince)
|
||||
- [License](#license)
|
||||
- [Contact](#contact)
|
||||
|
||||
@ -384,6 +386,20 @@ axios.get('url', {
|
||||
});
|
||||
```
|
||||
|
||||
### request.cache.etag
|
||||
|
||||
If the request should handle `ETag` and `If-None-Match support`. Use a string to force a
|
||||
custom static value or true to use the previous response ETag. To use `true` (automatic
|
||||
etag handling), `interpretHeader` option must be set to `true`. Default: `false`
|
||||
|
||||
### request.cache.modifiedSince
|
||||
|
||||
Use `If-Modified-Since` header in this request. Use a date to force a custom static value
|
||||
or true to use the last cached timestamp. If never cached before, the header is not set.
|
||||
If `interpretHeader` is set and a `Last-Modified` header is sent then value from that
|
||||
header is used, otherwise cache creation timestamp will be sent in `If-Modified-Since`.
|
||||
Default: `true`
|
||||
|
||||
<br />
|
||||
|
||||
## License
|
||||
|
||||
2
src/cache/axios.ts
vendored
2
src/cache/axios.ts
vendored
@ -63,7 +63,7 @@ export interface AxiosCacheInstance extends CacheInstance, AxiosInstance {
|
||||
* @template D The type that the request body use
|
||||
*/
|
||||
<T = any, D = any, R = CacheAxiosResponse<T, D>>(
|
||||
config?: CacheRequestConfig<D>
|
||||
config: CacheRequestConfig<D>
|
||||
): Promise<R>;
|
||||
/**
|
||||
* @template T The type returned by this response
|
||||
|
||||
21
src/cache/cache.ts
vendored
21
src/cache/cache.ts
vendored
@ -55,9 +55,28 @@ export type CacheProperties = {
|
||||
* The id used is the same as the id on `CacheRequestConfig['id']`,
|
||||
* auto-generated or not.
|
||||
*
|
||||
* @default {}
|
||||
* @default {{}}
|
||||
*/
|
||||
update: Record<string, CacheUpdater>;
|
||||
|
||||
/**
|
||||
* If the request should handle ETag and If-None-Match support. Use
|
||||
* a string to force a custom value or true to use the response ETag
|
||||
*
|
||||
* @default false
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
|
||||
*/
|
||||
etag: string | boolean;
|
||||
|
||||
/**
|
||||
* Use If-Modified-Since header in this request. Use a date to force
|
||||
* a custom value or true to use the last cached timestamp. If never
|
||||
* cached before, the header is not set.
|
||||
*
|
||||
* @default false
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
|
||||
*/
|
||||
modifiedSince: Date | boolean;
|
||||
};
|
||||
|
||||
export interface CacheInstance {
|
||||
|
||||
6
src/cache/create.ts
vendored
6
src/cache/create.ts
vendored
@ -44,9 +44,9 @@ export function useCache(
|
||||
ttl: 1000 * 60 * 5,
|
||||
interpretHeader: false,
|
||||
methods: ['get'],
|
||||
cachePredicate: {
|
||||
statusCheck: [200, 399]
|
||||
},
|
||||
cachePredicate: { statusCheck: [200, 399] },
|
||||
etag: false,
|
||||
modifiedSince: false,
|
||||
update: {},
|
||||
...cacheOptions
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import type { AxiosRequestConfig, Method } from 'axios';
|
||||
import { deferred } from 'typed-core/dist/promises/deferred';
|
||||
import type { CacheProperties } from '..';
|
||||
import type {
|
||||
AxiosCacheInstance,
|
||||
CacheAxiosResponse,
|
||||
@ -7,8 +9,10 @@ import type {
|
||||
import type {
|
||||
CachedResponse,
|
||||
CachedStorageValue,
|
||||
LoadingStorageValue
|
||||
LoadingStorageValue,
|
||||
StaleStorageValue
|
||||
} from '../storage/types';
|
||||
import { Header } from '../util/headers';
|
||||
import type { AxiosInterceptor } from './types';
|
||||
|
||||
export class CacheRequestInterceptor<D>
|
||||
@ -16,21 +20,23 @@ export class CacheRequestInterceptor<D>
|
||||
{
|
||||
constructor(readonly axios: AxiosCacheInstance) {}
|
||||
|
||||
use = (): void => {
|
||||
public use = (): void => {
|
||||
this.axios.interceptors.request.use(this.onFulfilled);
|
||||
};
|
||||
|
||||
onFulfilled = async (config: CacheRequestConfig<D>): Promise<CacheRequestConfig<D>> => {
|
||||
// Skip cache
|
||||
public onFulfilled = async (
|
||||
config: CacheRequestConfig<D>
|
||||
): Promise<CacheRequestConfig<D>> => {
|
||||
if (config.cache === false) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// Only cache specified methods
|
||||
const allowedMethods = config.cache?.methods || this.axios.defaults.cache.methods;
|
||||
// merge defaults with per request configuration
|
||||
config.cache = { ...this.axios.defaults.cache, ...config.cache };
|
||||
|
||||
if (
|
||||
!allowedMethods.some((method) => (config.method || 'get').toLowerCase() == method)
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
!this.isMethodAllowed(config.method!, config.cache)
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
@ -41,7 +47,7 @@ export class CacheRequestInterceptor<D>
|
||||
let cache = await this.axios.storage.get(key);
|
||||
|
||||
// Not cached, continue the request, and mark it as fetching
|
||||
emptyState: if (cache.state == 'empty') {
|
||||
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
|
||||
@ -51,7 +57,7 @@ export class CacheRequestInterceptor<D>
|
||||
cache = (await this.axios.storage.get(key)) as
|
||||
| CachedStorageValue
|
||||
| LoadingStorageValue;
|
||||
break emptyState;
|
||||
break emptyOrStale;
|
||||
}
|
||||
|
||||
// Create a deferred to resolve other requests for the same key when it's completed
|
||||
@ -65,9 +71,18 @@ export class CacheRequestInterceptor<D>
|
||||
|
||||
await this.axios.storage.set(key, {
|
||||
state: 'loading',
|
||||
ttl: config.cache?.ttl
|
||||
data: cache.data
|
||||
});
|
||||
|
||||
if (cache.state === 'stale') {
|
||||
//@ts-expect-error type infer couldn't resolve this
|
||||
this.setRevalidationHeaders(cache, config);
|
||||
}
|
||||
|
||||
config.validateStatus = CacheRequestInterceptor.createValidateStatus(
|
||||
config.validateStatus
|
||||
);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
@ -77,6 +92,7 @@ export class CacheRequestInterceptor<D>
|
||||
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);
|
||||
return config;
|
||||
@ -109,4 +125,56 @@ export class CacheRequestInterceptor<D>
|
||||
|
||||
return config;
|
||||
};
|
||||
|
||||
private isMethodAllowed = (
|
||||
method: Method,
|
||||
properties: Partial<CacheProperties>
|
||||
): boolean => {
|
||||
const requestMethod = method.toLowerCase();
|
||||
|
||||
for (const method of properties.methods || []) {
|
||||
if (method.toLowerCase() === requestMethod) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
private setRevalidationHeaders = (
|
||||
cache: StaleStorageValue,
|
||||
config: CacheRequestConfig<D> & { cache: Partial<CacheProperties> }
|
||||
): void => {
|
||||
config.headers ||= {};
|
||||
|
||||
const { etag, modifiedSince } = config.cache;
|
||||
|
||||
if (etag) {
|
||||
const etagValue = etag === true ? cache.data?.headers[Header.ETag] : etag;
|
||||
if (etagValue) {
|
||||
config.headers[Header.IfNoneMatch] = etagValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (modifiedSince) {
|
||||
config.headers[Header.IfModifiedSince] =
|
||||
modifiedSince === true
|
||||
? // If last-modified is not present, use the createdAt timestamp
|
||||
cache.data.headers[Header.LastModified] ||
|
||||
new Date(cache.createdAt).toUTCString()
|
||||
: modifiedSince.toUTCString();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new validateStatus function that will use the one
|
||||
* already used and also accept status code 304.
|
||||
*/
|
||||
static createValidateStatus = (oldValidate?: AxiosRequestConfig['validateStatus']) => {
|
||||
return (status: number): boolean => {
|
||||
return oldValidate
|
||||
? oldValidate(status) || status === 304
|
||||
: (status >= 200 && status < 300) || status === 304;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import type { AxiosCacheInstance, CacheAxiosResponse } from '../cache/axios';
|
||||
import type { CacheProperties } from '../cache/cache';
|
||||
import type { CachedStorageValue } from '../storage/types';
|
||||
import { checkPredicateObject } from '../util/cache-predicate';
|
||||
import { Header } from '../util/headers';
|
||||
import { updateCache } from '../util/update-cache';
|
||||
import type { AxiosInterceptor } from './types';
|
||||
|
||||
@ -12,16 +13,128 @@ export class CacheResponseInterceptor<R, D>
|
||||
{
|
||||
constructor(readonly axios: AxiosCacheInstance) {}
|
||||
|
||||
use = (): void => {
|
||||
public use = (): void => {
|
||||
this.axios.interceptors.response.use(this.onFulfilled);
|
||||
};
|
||||
|
||||
public onFulfilled = async (
|
||||
axiosResponse: AxiosResponse<R, D>
|
||||
): Promise<CacheAxiosResponse<R, D>> => {
|
||||
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 later 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 &&
|
||||
!this.testCachePredicate(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 === false) {
|
||||
await this.rejectResponse(response.id);
|
||||
return response;
|
||||
}
|
||||
|
||||
ttl = expirationTime || expirationTime === 0 ? expirationTime : ttl;
|
||||
}
|
||||
|
||||
const data =
|
||||
response.status == 304 && cache.data
|
||||
? (() => {
|
||||
// Rust syntax <3
|
||||
response.cached = true;
|
||||
response.data = cache.data.data;
|
||||
response.status = cache.data.status;
|
||||
response.statusText = cache.data.statusText;
|
||||
|
||||
// We may have new headers.
|
||||
response.headers = {
|
||||
...cache.data.headers,
|
||||
...response.headers
|
||||
};
|
||||
|
||||
return cache.data;
|
||||
})()
|
||||
: extract(response, ['data', 'headers', 'status', 'statusText']);
|
||||
|
||||
const newCache: CachedStorageValue = {
|
||||
state: 'cached',
|
||||
ttl,
|
||||
createdAt: Date.now(),
|
||||
data
|
||||
};
|
||||
|
||||
// Update other entries before updating himself
|
||||
if (cacheConfig?.update) {
|
||||
updateCache(this.axios.storage, response.data, 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;
|
||||
};
|
||||
|
||||
private testCachePredicate = <R>(
|
||||
response: AxiosResponse<R>,
|
||||
cache?: Partial<CacheProperties>
|
||||
cache: CacheProperties
|
||||
): boolean => {
|
||||
const cachePredicate =
|
||||
cache?.cachePredicate || this.axios.defaults.cache.cachePredicate;
|
||||
const cachePredicate = cache.cachePredicate;
|
||||
|
||||
return (
|
||||
(typeof cachePredicate === 'function' && cachePredicate(response)) ||
|
||||
@ -42,88 +155,15 @@ export class CacheResponseInterceptor<R, D>
|
||||
delete this.axios.waiting[key];
|
||||
};
|
||||
|
||||
onFulfilled = async (
|
||||
axiosResponse: AxiosResponse<R, D>
|
||||
): Promise<CacheAxiosResponse<R, D>> => {
|
||||
const key = this.axios.generateKey(axiosResponse.config);
|
||||
|
||||
const response: CacheAxiosResponse<R, D> = {
|
||||
id: key,
|
||||
|
||||
private cachedResponse = (response: AxiosResponse<R, D>): CacheAxiosResponse<R, D> => {
|
||||
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: (axiosResponse as CacheAxiosResponse<R, D>).cached || false,
|
||||
...axiosResponse
|
||||
cached: (response as CacheAxiosResponse<R, D>).cached || false,
|
||||
...response
|
||||
};
|
||||
|
||||
// Skip cache
|
||||
if (response.config.cache === false) {
|
||||
return { ...response, cached: false };
|
||||
}
|
||||
|
||||
// Response is already cached
|
||||
if (response.cached) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const cache = await this.axios.storage.get(key);
|
||||
|
||||
/**
|
||||
* From now on, the cache and response represents the state of the
|
||||
* first response to a request, which has not yet been cached or
|
||||
* processed before.
|
||||
*/
|
||||
if (cache.state !== 'loading') {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Config told that this response should be cached.
|
||||
if (!this.testCachePredicate(response, response.config.cache)) {
|
||||
await this.rejectResponse(key);
|
||||
return response;
|
||||
}
|
||||
|
||||
let ttl = response.config.cache?.ttl || this.axios.defaults.cache.ttl;
|
||||
|
||||
if (
|
||||
response.config.cache?.interpretHeader ||
|
||||
this.axios.defaults.cache.interpretHeader
|
||||
) {
|
||||
const expirationTime = this.axios.headerInterpreter(response.headers);
|
||||
|
||||
// Cache should not be used
|
||||
if (expirationTime === false) {
|
||||
await this.rejectResponse(key);
|
||||
return response;
|
||||
}
|
||||
|
||||
ttl = expirationTime ? expirationTime : ttl;
|
||||
}
|
||||
|
||||
const newCache: CachedStorageValue = {
|
||||
state: 'cached',
|
||||
ttl: ttl,
|
||||
createdAt: Date.now(),
|
||||
data: extract(response, ['data', 'headers', 'status', 'statusText'])
|
||||
};
|
||||
|
||||
// Update other entries before updating himself
|
||||
if (response.config.cache?.update) {
|
||||
updateCache(this.axios.storage, response.data, response.config.cache.update);
|
||||
}
|
||||
|
||||
const deferred = this.axios.waiting[key];
|
||||
|
||||
// Resolve all other requests waiting for this response
|
||||
await deferred?.resolve(newCache.data);
|
||||
delete this.axios.waiting[key];
|
||||
|
||||
// Define this key as cache on the storage
|
||||
await this.axios.storage.set(key, newCache);
|
||||
|
||||
// Return the response with cached as false, because it was not cached at all
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import type { NotEmptyStorageValue } from '..';
|
||||
import { AxiosStorage } from './storage';
|
||||
import type { EmptyStorageValue, StorageValue } from './types';
|
||||
import type { StorageValue } from './types';
|
||||
|
||||
export class BrowserAxiosStorage extends AxiosStorage {
|
||||
public static DEFAULT_KEY_PREFIX = 'a-c-i';
|
||||
|
||||
/**
|
||||
* @param storage any browser storage, like sessionStorage or localStorage
|
||||
* @param prefix the key prefix to use on all keys.
|
||||
* @param storage Any browser storage, like sessionStorage or localStorage
|
||||
* @param prefix The key prefix to use on all keys.
|
||||
*/
|
||||
constructor(
|
||||
readonly storage: Storage,
|
||||
@ -15,30 +16,16 @@ export class BrowserAxiosStorage extends AxiosStorage {
|
||||
super();
|
||||
}
|
||||
|
||||
public get = (key: string): StorageValue => {
|
||||
const prefixedKey = `${this.prefix}:${key}`;
|
||||
|
||||
const json = this.storage.getItem(prefixedKey);
|
||||
|
||||
if (!json) {
|
||||
return { state: 'empty' };
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(json);
|
||||
|
||||
if (!AxiosStorage.isValid(parsed)) {
|
||||
this.storage.removeItem(prefixedKey);
|
||||
return { state: 'empty' };
|
||||
}
|
||||
|
||||
return parsed;
|
||||
public find = async (key: string): Promise<StorageValue> => {
|
||||
const json = this.storage.getItem(`${this.prefix}:${key}`);
|
||||
return json ? JSON.parse(json) : { state: 'empty' };
|
||||
};
|
||||
|
||||
public set = (key: string, value: Exclude<StorageValue, EmptyStorageValue>): void => {
|
||||
public set = async (key: string, value: NotEmptyStorageValue): Promise<void> => {
|
||||
return this.storage.setItem(`${this.prefix}:${key}`, JSON.stringify(value));
|
||||
};
|
||||
|
||||
public remove = (key: string): void | Promise<void> => {
|
||||
public remove = async (key: string): Promise<void> => {
|
||||
return this.storage.removeItem(`${this.prefix}:${key}`);
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,31 +1,20 @@
|
||||
import { AxiosStorage } from './storage';
|
||||
import type { CachedStorageValue, LoadingStorageValue, StorageValue } from './types';
|
||||
import type { NotEmptyStorageValue, StorageValue } from './types';
|
||||
|
||||
export class MemoryAxiosStorage extends AxiosStorage {
|
||||
constructor(readonly storage: Record<string, StorageValue> = {}) {
|
||||
super();
|
||||
}
|
||||
|
||||
public get = (key: string): StorageValue => {
|
||||
const value = this.storage[key];
|
||||
|
||||
if (!value) {
|
||||
return { state: 'empty' };
|
||||
}
|
||||
|
||||
if (!AxiosStorage.isValid(value)) {
|
||||
this.remove(key);
|
||||
return { state: 'empty' };
|
||||
}
|
||||
|
||||
return value;
|
||||
public find = async (key: string): Promise<StorageValue> => {
|
||||
return this.storage[key] || { state: 'empty' };
|
||||
};
|
||||
|
||||
public set = (key: string, value: CachedStorageValue | LoadingStorageValue): void => {
|
||||
public set = async (key: string, value: NotEmptyStorageValue): Promise<void> => {
|
||||
this.storage[key] = value;
|
||||
};
|
||||
|
||||
public remove = (key: string): void => {
|
||||
public remove = async (key: string): Promise<void> => {
|
||||
delete this.storage[key];
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,38 +1,65 @@
|
||||
import type { EmptyStorageValue, StorageValue } from './types';
|
||||
import type { CachedStorageValue, NotEmptyStorageValue } from '..';
|
||||
import { Header } from '../util/headers';
|
||||
import type { StaleStorageValue, StorageValue } from './types';
|
||||
|
||||
export abstract class AxiosStorage {
|
||||
/**
|
||||
* Returns the cached value for the given key. Must handle cache
|
||||
* miss and staling by returning a new `StorageValue` with `empty` state.
|
||||
*
|
||||
* @see {AxiosStorage#isValid}
|
||||
* Returns the cached value for the given key. The get method is
|
||||
* what takes care to invalidate the values.
|
||||
*/
|
||||
public abstract get: (key: string) => Promise<StorageValue> | StorageValue;
|
||||
protected abstract find: (key: string) => Promise<StorageValue>;
|
||||
|
||||
/**
|
||||
* Sets a new value for the given key
|
||||
*
|
||||
* Use CacheStorage.remove(key) to define a key to 'empty' state.
|
||||
*/
|
||||
public abstract set: (
|
||||
key: string,
|
||||
value: Exclude<StorageValue, EmptyStorageValue>
|
||||
) => Promise<void> | void;
|
||||
public abstract set: (key: string, value: NotEmptyStorageValue) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Removes the value for the given key
|
||||
*/
|
||||
public abstract remove: (key: string) => Promise<void> | void;
|
||||
public abstract remove: (key: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Returns true if a storage value can still be used by checking his
|
||||
* createdAt and ttl values.
|
||||
*/
|
||||
static isValid = (value?: StorageValue): boolean | 'unknown' => {
|
||||
if (value?.state === 'cached') {
|
||||
return value.createdAt + value.ttl > Date.now();
|
||||
public get = async (key: string): Promise<StorageValue> => {
|
||||
const value = await this.find(key);
|
||||
|
||||
if (
|
||||
value.state !== 'cached' ||
|
||||
// Cached and fresh value
|
||||
value.createdAt + value.ttl > Date.now()
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return true;
|
||||
// Check if his can stale value.
|
||||
if (AxiosStorage.keepIfStale(value)) {
|
||||
const stale: StaleStorageValue = {
|
||||
data: value.data,
|
||||
state: 'stale',
|
||||
createdAt: value.createdAt
|
||||
};
|
||||
await this.set(key, stale);
|
||||
return stale;
|
||||
}
|
||||
|
||||
await this.remove(key);
|
||||
return { state: 'empty' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true if a invalid cache should still be kept
|
||||
*/
|
||||
static keepIfStale = ({ data }: CachedStorageValue): boolean => {
|
||||
if (data?.headers) {
|
||||
return (
|
||||
Header.ETag in data.headers ||
|
||||
Header.LastModified in data.headers ||
|
||||
Header.XAxiosCacheEtag in data.headers ||
|
||||
Header.XAxiosCacheLastModified in data.headers
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,7 +8,20 @@ export type CachedResponse = {
|
||||
/**
|
||||
* The value returned for a given key.
|
||||
*/
|
||||
export type StorageValue = CachedStorageValue | LoadingStorageValue | EmptyStorageValue;
|
||||
export type StorageValue =
|
||||
| StaleStorageValue
|
||||
| CachedStorageValue
|
||||
| LoadingStorageValue
|
||||
| EmptyStorageValue;
|
||||
|
||||
export type NotEmptyStorageValue = Exclude<StorageValue, EmptyStorageValue>;
|
||||
|
||||
export type StaleStorageValue = {
|
||||
data: CachedResponse;
|
||||
ttl?: undefined;
|
||||
createdAt: number;
|
||||
state: 'stale';
|
||||
};
|
||||
|
||||
export type CachedStorageValue = {
|
||||
data: CachedResponse;
|
||||
@ -22,7 +35,11 @@ export type CachedStorageValue = {
|
||||
};
|
||||
|
||||
export type LoadingStorageValue = {
|
||||
data?: undefined;
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
|
||||
@ -8,6 +8,15 @@ export enum Header {
|
||||
*/
|
||||
IfModifiedSince = 'if-modified-since',
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* Last-Modified: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
|
||||
*/
|
||||
LastModified = 'last-modified',
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* If-None-Match: "<etag_value>"
|
||||
@ -26,8 +35,8 @@ export enum Header {
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* ETag: W / '<etag_value>';
|
||||
* ETag: '<etag_value>';
|
||||
* ETag: W/"<etag_value>"
|
||||
* ETag: "<etag_value>"
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
|
||||
@ -60,5 +69,32 @@ export enum Header {
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
|
||||
*/
|
||||
ContentType = 'content-type'
|
||||
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 `If-Match` header Provided by user
|
||||
* using `cache.etag` value.
|
||||
*
|
||||
* ```txt
|
||||
* X-Axios-Cache-Etag: "<etag_value>"
|
||||
* ```
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* ```txt
|
||||
* X-Axios-Cache-Last-Modified: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
|
||||
* X-Axios-Cache-Last-Modified: use-cache-timestamp
|
||||
* ```
|
||||
*/
|
||||
XAxiosCacheLastModified = 'x-axios-cache-last-modified'
|
||||
}
|
||||
|
||||
@ -1,13 +1,21 @@
|
||||
import type { AxiosStorage } from '../storage/storage';
|
||||
import type { CachedStorageValue, EmptyStorageValue } from '../storage/types';
|
||||
import type {
|
||||
CachedStorageValue,
|
||||
LoadingStorageValue,
|
||||
StorageValue
|
||||
} from '../storage/types';
|
||||
|
||||
export type CacheUpdater =
|
||||
| 'delete'
|
||||
| ((
|
||||
cached: EmptyStorageValue | CachedStorageValue,
|
||||
cached: Exclude<StorageValue, LoadingStorageValue>,
|
||||
newData: any
|
||||
) => CachedStorageValue | void);
|
||||
|
||||
/**
|
||||
* Function to update all caches, from CacheProperties.update, with
|
||||
* the new data.
|
||||
*/
|
||||
export async function updateCache<T = any>(
|
||||
storage: AxiosStorage,
|
||||
data: T,
|
||||
@ -17,7 +25,7 @@ export async function updateCache<T = any>(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const value = entries[cacheKey]!;
|
||||
|
||||
if (value == 'delete') {
|
||||
if (value === 'delete') {
|
||||
await storage.remove(cacheKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ describe('tests header interpreter', () => {
|
||||
expect(result).toBe(10 * 1000);
|
||||
});
|
||||
|
||||
it('tests with maxAge=10 and age=7 headers', () => {
|
||||
it('tests with maxAge=10 and age=3 headers', () => {
|
||||
const result = defaultHeaderInterpreter({
|
||||
[Header.CacheControl]: 'max-age=10',
|
||||
[Header.Age]: '3'
|
||||
|
||||
81
test/interceptors/etag.test.ts
Normal file
81
test/interceptors/etag.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import { Header } from '../../src/util/headers';
|
||||
import { mockAxios } from '../mocks/axios';
|
||||
import { sleep } from '../utils';
|
||||
|
||||
describe('ETag handling', () => {
|
||||
it('tests etag header handling', async () => {
|
||||
const axios = mockAxios({}, { etag: 'fakeEtag', 'cache-control': 'max-age=1' });
|
||||
const config = { cache: { interpretHeader: true, etag: true } };
|
||||
|
||||
// initial request
|
||||
await axios.get('', config);
|
||||
|
||||
const response = await axios.get('', config);
|
||||
expect(response.cached).toBe(true);
|
||||
expect(response.data).toBe(true);
|
||||
|
||||
// Sleep entire max age time.
|
||||
await sleep(1000);
|
||||
|
||||
const response2 = await axios.get('', config);
|
||||
// from revalidation
|
||||
expect(response2.cached).toBe(true);
|
||||
// ensure value from stale cache is kept
|
||||
expect(response2.data).toBe(true);
|
||||
});
|
||||
|
||||
it('tests etag header handling in global config', async () => {
|
||||
const axios = mockAxios(
|
||||
{ interpretHeader: true, etag: true },
|
||||
{ etag: 'fakeEtag', 'cache-control': 'max-age=1' }
|
||||
);
|
||||
|
||||
// initial request
|
||||
await axios.get('');
|
||||
|
||||
const response = await axios.get('');
|
||||
expect(response.cached).toBe(true);
|
||||
expect(response.data).toBe(true);
|
||||
|
||||
// Sleep entire max age time.
|
||||
await sleep(1000);
|
||||
|
||||
const response2 = await axios.get('');
|
||||
// from revalidation
|
||||
expect(response2.cached).toBe(true);
|
||||
// ensure value from stale cache is kept
|
||||
expect(response2.data).toBe(true);
|
||||
});
|
||||
|
||||
it('tests "must revalidate" handling with etag', async () => {
|
||||
const axios = mockAxios({}, { etag: 'fakeEtag', 'cache-control': 'must-revalidate' });
|
||||
const config = { cache: { interpretHeader: true, etag: true } };
|
||||
|
||||
await axios.get('', config);
|
||||
|
||||
// 0ms cache
|
||||
await sleep(1);
|
||||
|
||||
const response = await axios.get('', config);
|
||||
// from etag revalidation
|
||||
expect(response.cached).toBe(true);
|
||||
expect(response.data).toBe(true);
|
||||
});
|
||||
|
||||
it('tests custom e-tag', async () => {
|
||||
const axios = mockAxios({ ttl: 0 }, { etag: 'fake-etag-2' });
|
||||
const config = { cache: { interpretHeader: true, etag: 'fake-etag' } };
|
||||
|
||||
const response = await axios.get('', config);
|
||||
expect(response.cached).toBe(false);
|
||||
expect(response.data).toBe(true);
|
||||
expect(response.config.headers?.[Header.IfModifiedSince]).toBeUndefined();
|
||||
expect(response.headers?.[Header.LastModified]).toBeUndefined();
|
||||
|
||||
const response2 = await axios.get('', config);
|
||||
expect(response2.cached).toBe(true);
|
||||
expect(response2.data).toBe(true);
|
||||
expect(response2.config.headers?.[Header.IfNoneMatch]).toBe('fake-etag');
|
||||
expect(response2.headers?.[Header.ETag]).toBe('fake-etag-2');
|
||||
});
|
||||
});
|
||||
101
test/interceptors/last-modified.test.ts
Normal file
101
test/interceptors/last-modified.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { Header } from '../../src/util/headers';
|
||||
import { mockAxios } from '../mocks/axios';
|
||||
import { sleep } from '../utils';
|
||||
|
||||
describe('Last-Modified handling', () => {
|
||||
it('tests last modified header handling', async () => {
|
||||
const axios = mockAxios(
|
||||
{},
|
||||
{
|
||||
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
|
||||
'cache-control': 'max-age=1'
|
||||
}
|
||||
);
|
||||
|
||||
const config = { cache: { interpretHeader: true, modifiedSince: true } };
|
||||
|
||||
await axios.get('', config);
|
||||
|
||||
const response = await axios.get('', config);
|
||||
expect(response.cached).toBe(true);
|
||||
expect(response.data).toBe(true);
|
||||
|
||||
// Sleep entire max age time.
|
||||
await sleep(1000);
|
||||
|
||||
const response2 = await axios.get('', config);
|
||||
// from revalidation
|
||||
expect(response2.cached).toBe(true);
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it('tests last modified header handling in global config', async () => {
|
||||
const axios = mockAxios(
|
||||
{ interpretHeader: true, modifiedSince: true },
|
||||
{
|
||||
'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT',
|
||||
'cache-control': 'max-age=1'
|
||||
}
|
||||
);
|
||||
|
||||
await axios.get('');
|
||||
|
||||
const response = await axios.get('');
|
||||
expect(response.cached).toBe(true);
|
||||
expect(response.data).toBe(true);
|
||||
|
||||
// Sleep entire max age time.
|
||||
await sleep(1000);
|
||||
|
||||
const response2 = await axios.get('');
|
||||
// from revalidation
|
||||
expect(response2.cached).toBe(true);
|
||||
expect(response2.status).toBe(200);
|
||||
});
|
||||
|
||||
it('tests modifiedSince as date', async () => {
|
||||
const axios = mockAxios({ ttl: 0 });
|
||||
|
||||
const config = {
|
||||
cache: { modifiedSince: new Date(2014, 1, 1) }
|
||||
};
|
||||
|
||||
const response = await axios.get('', config);
|
||||
expect(response.cached).toBe(false);
|
||||
expect(response.data).toBe(true);
|
||||
expect(response.config.headers?.[Header.IfModifiedSince]).toBeUndefined();
|
||||
expect(response.headers?.[Header.XAxiosCacheLastModified]).toBeDefined();
|
||||
|
||||
const response2 = await axios.get('', config);
|
||||
expect(response2.cached).toBe(true);
|
||||
expect(response2.data).toBe(true);
|
||||
expect(response2.config.headers?.[Header.IfModifiedSince]).toBeDefined();
|
||||
expect(response2.headers?.[Header.XAxiosCacheLastModified]).toBeDefined();
|
||||
});
|
||||
|
||||
it('tests modifiedSince using cache timestamp', async () => {
|
||||
const axios = mockAxios(
|
||||
{},
|
||||
{
|
||||
'cache-control': 'must-revalidate'
|
||||
}
|
||||
);
|
||||
|
||||
const config = {
|
||||
cache: { interpretHeader: true, modifiedSince: true }
|
||||
};
|
||||
|
||||
await axios.get('', config);
|
||||
const response = await axios.get('', config);
|
||||
|
||||
const modifiedSince = response.config.headers?.[Header.IfModifiedSince];
|
||||
|
||||
if (!modifiedSince) {
|
||||
throw new Error('modifiedSince is not defined');
|
||||
}
|
||||
const milliseconds = Date.parse(modifiedSince);
|
||||
|
||||
expect(typeof milliseconds).toBe('number');
|
||||
expect(milliseconds).toBeLessThan(Date.now());
|
||||
});
|
||||
});
|
||||
@ -1,4 +1,6 @@
|
||||
import { CacheRequestInterceptor } from '../../src/interceptors/request';
|
||||
import { mockAxios } from '../mocks/axios';
|
||||
import { sleep } from '../utils';
|
||||
|
||||
describe('test request interceptor', () => {
|
||||
it('tests against specified methods', async () => {
|
||||
@ -84,4 +86,53 @@ describe('test request interceptor', () => {
|
||||
const response4 = await axios.get('', { id: 'random-id' });
|
||||
expect(response4.cached).toBe(true);
|
||||
});
|
||||
|
||||
it('test cache expiration', async () => {
|
||||
const axios = mockAxios({}, { 'cache-control': 'max-age=1' });
|
||||
|
||||
await axios.get('', { cache: { interpretHeader: true } });
|
||||
|
||||
const resultCache = await axios.get('');
|
||||
expect(resultCache.cached).toBe(true);
|
||||
|
||||
// Sleep entire max age time.
|
||||
await sleep(1000);
|
||||
|
||||
const response2 = await axios.get('');
|
||||
expect(response2.cached).toBe(false);
|
||||
});
|
||||
|
||||
it('tests "must revalidate" handling without any headers to do so', async () => {
|
||||
const axios = mockAxios({}, { 'cache-control': 'must-revalidate' });
|
||||
const config = { cache: { interpretHeader: true } };
|
||||
await axios.get('', config);
|
||||
|
||||
// 0ms cache
|
||||
await sleep(1);
|
||||
|
||||
const response = await axios.get('', config);
|
||||
// nothing to use for revalidation
|
||||
expect(response.cached).toBe(false);
|
||||
});
|
||||
|
||||
it('tests validate-status function', async () => {
|
||||
const { createValidateStatus } = CacheRequestInterceptor;
|
||||
|
||||
const def = createValidateStatus();
|
||||
expect(def(200)).toBe(true);
|
||||
expect(def(345)).toBe(false);
|
||||
expect(def(304)).toBe(true);
|
||||
|
||||
const only200 = createValidateStatus((s) => s >= 200 && s < 300);
|
||||
expect(only200(200)).toBe(true);
|
||||
expect(only200(299)).toBe(true);
|
||||
expect(only200(304)).toBe(true);
|
||||
expect(only200(345)).toBe(false);
|
||||
|
||||
const randomValue = createValidateStatus((s) => s >= 405 && s <= 410);
|
||||
expect(randomValue(200)).toBe(false);
|
||||
expect(randomValue(404)).toBe(false);
|
||||
expect(randomValue(405)).toBe(true);
|
||||
expect(randomValue(304)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { AxiosCacheInstance, CacheProperties, createCache } from '../../src';
|
||||
import type { CacheInstance } from '../../src/cache/cache';
|
||||
import { Header } from '../../src/util/headers';
|
||||
|
||||
export function mockAxios(
|
||||
options: Partial<CacheInstance> & Partial<CacheProperties> = {},
|
||||
@ -13,10 +14,18 @@ export function mockAxios(
|
||||
axios.interceptors.request.use((config) => {
|
||||
config.adapter = async (config) => {
|
||||
await 0; // Jumps to next tick of nodejs event loop
|
||||
|
||||
const should304 =
|
||||
config.headers?.[Header.IfNoneMatch] || config.headers?.[Header.IfModifiedSince];
|
||||
const status = should304 ? 304 : 200;
|
||||
|
||||
// real axios would throw an error here.
|
||||
config.validateStatus?.(status);
|
||||
|
||||
return {
|
||||
data: true,
|
||||
status: 200,
|
||||
statusText: '200 OK',
|
||||
status,
|
||||
statusText: should304 ? '304 Not Modified' : '200 OK',
|
||||
headers,
|
||||
config
|
||||
};
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { AxiosStorage } from '../../src/storage/storage';
|
||||
import { EMPTY_RESPONSE } from '../constants';
|
||||
import { EMPTY_RESPONSE } from '../utils';
|
||||
|
||||
export function testStorage(name: string, Storage: () => AxiosStorage): void {
|
||||
it(`tests ${name} storage methods`, async () => {
|
||||
|
||||
@ -1,37 +1,66 @@
|
||||
import { AxiosStorage } from '../../src/storage/storage';
|
||||
import { Header } from '../../src/util/headers';
|
||||
|
||||
describe('tests common storages', () => {
|
||||
it('tests isCacheValid with empty state', () => {
|
||||
const invalid = AxiosStorage.isValid({ state: 'empty' });
|
||||
|
||||
expect(invalid).toBe(true);
|
||||
});
|
||||
|
||||
it('tests isCacheValid with loading state', () => {
|
||||
const invalid = AxiosStorage.isValid({ state: 'loading' });
|
||||
|
||||
expect(invalid).toBe(true);
|
||||
});
|
||||
|
||||
it('tests isCacheValid with overdue cached state', () => {
|
||||
const isValid = AxiosStorage.isValid({
|
||||
describe('tests abstract storages', () => {
|
||||
it('tests storage keep if stale method', () => {
|
||||
const etag = AxiosStorage.keepIfStale({
|
||||
state: 'cached',
|
||||
data: {} as any, // doesn't matter
|
||||
createdAt: Date.now() - 2000, // 2 seconds in the past
|
||||
ttl: 1000 // 1 second
|
||||
// Reverse to be ~infinity
|
||||
createdAt: 1,
|
||||
ttl: Date.now(),
|
||||
data: {
|
||||
status: 200,
|
||||
statusText: '200 OK',
|
||||
data: true,
|
||||
headers: {
|
||||
[Header.ETag]: 'W/"123"'
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(etag).toBe(true);
|
||||
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('tests isCacheValid with cached state', () => {
|
||||
const isValid = AxiosStorage.isValid({
|
||||
const modifiedSince = AxiosStorage.keepIfStale({
|
||||
state: 'cached',
|
||||
data: {} as any, // doesn't matter
|
||||
createdAt: Date.now(),
|
||||
ttl: 1000 // 1 second
|
||||
// Reverse to be ~infinity
|
||||
createdAt: 1,
|
||||
ttl: Date.now(),
|
||||
data: {
|
||||
status: 200,
|
||||
statusText: '200 OK',
|
||||
data: true,
|
||||
headers: {
|
||||
[Header.LastModified]: new Date().toUTCString()
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(modifiedSince).toBe(true);
|
||||
|
||||
expect(isValid).toBe(true);
|
||||
const empty = AxiosStorage.keepIfStale({
|
||||
state: 'cached',
|
||||
// Reverse to be ~infinity
|
||||
createdAt: 1,
|
||||
ttl: Date.now(),
|
||||
data: {
|
||||
status: 200,
|
||||
statusText: '200 OK',
|
||||
data: true,
|
||||
headers: {}
|
||||
}
|
||||
});
|
||||
expect(empty).toBe(false);
|
||||
|
||||
const rest = AxiosStorage.keepIfStale({
|
||||
state: 'cached',
|
||||
// Reverse to be ~infinity
|
||||
createdAt: 1,
|
||||
ttl: Date.now(),
|
||||
data: {
|
||||
status: 200,
|
||||
statusText: '200 OK',
|
||||
data: true,
|
||||
headers: undefined as any
|
||||
}
|
||||
});
|
||||
expect(rest).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { checkPredicateObject } from '../../src/util/cache-predicate';
|
||||
import { createResponse } from '../constants';
|
||||
import { createResponse } from '../utils';
|
||||
|
||||
describe('tests cache predicate object', () => {
|
||||
it('tests statusCheck with tuples', () => {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { AxiosCacheInstance, CachedStorageValue } from '../../src';
|
||||
import { updateCache } from '../../src/util/update-cache';
|
||||
import { EMPTY_RESPONSE } from '../constants';
|
||||
import { mockAxios } from '../mocks/axios';
|
||||
import { EMPTY_RESPONSE } from '../utils';
|
||||
|
||||
const KEY = 'cacheKey';
|
||||
const EMPTY_STATE = { state: 'empty' };
|
||||
|
||||
@ -12,3 +12,6 @@ export const createResponse = <R>(
|
||||
): AxiosResponse<R> => {
|
||||
return { ...EMPTY_RESPONSE, config: {}, data: {} as R, request: {}, ...config };
|
||||
};
|
||||
|
||||
export const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((res) => setTimeout(res, ms));
|
||||
Loading…
x
Reference in New Issue
Block a user