mirror of
https://github.com/arthurfiorette/axios-cache-interceptor.git
synced 2025-12-08 17:36:16 +00:00
feat: cache working
This commit is contained in:
parent
5d8b698ddb
commit
9f6e1a469c
@ -28,12 +28,14 @@
|
||||
"homepage": "https://github.com/ArthurFiorette/axios-cache-interceptor#readme",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.7.10",
|
||||
"add": "^2.0.6",
|
||||
"axios": "^0.21.1",
|
||||
"prettier": "^2.3.2",
|
||||
"prettier-plugin-jsdoc": "^0.3.23",
|
||||
"prettier-plugin-organize-imports": "^2.3.3",
|
||||
"typescript": "^4.4.2",
|
||||
"yarn": "^1.22.11"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tusbar/cache-control": "^0.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { applyRequestInterceptor } from '#/interceptors/request';
|
||||
import { applyResponseInterceptor } from '#/interceptors/response';
|
||||
import { MemoryStorage } from '#/storage/memory';
|
||||
import { defaultKeyGenerator } from '#/utils/key-generator';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { applyRequestInterceptor } from '../interceptors/request';
|
||||
import { applyResponseInterceptor } from '../interceptors/response';
|
||||
import { MemoryStorage } from '../storage/memory';
|
||||
import { AxiosCacheInstance, CacheInstance, CacheRequestConfig } from './types';
|
||||
|
||||
type Options = CacheRequestConfig['cache'] & Partial<CacheInstance>;
|
||||
@ -13,6 +14,7 @@ export function createCache(
|
||||
const axiosCache = axios as AxiosCacheInstance;
|
||||
|
||||
axiosCache.storage = options.storage || new MemoryStorage();
|
||||
axiosCache.generateKey = defaultKeyGenerator;
|
||||
|
||||
// CacheRequestConfig values
|
||||
axiosCache.defaults = {
|
||||
@ -21,6 +23,8 @@ export function createCache(
|
||||
maxAge: 1000 * 60 * 5,
|
||||
interpretHeader: false,
|
||||
methods: ['get'],
|
||||
shouldCache: ({ status }) => status >= 200 && status < 300,
|
||||
update: {},
|
||||
...options
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { CacheStorage } from '#/storage/types';
|
||||
import type {
|
||||
AxiosInstance,
|
||||
AxiosInterceptorManager,
|
||||
@ -6,12 +7,19 @@ import type {
|
||||
AxiosResponse,
|
||||
Method
|
||||
} from 'axios';
|
||||
import { CacheStorage } from '../storage/types';
|
||||
|
||||
/**
|
||||
* Options that can be overridden per request
|
||||
*/
|
||||
export type CacheRequestConfig = AxiosRequestConfig & {
|
||||
/**
|
||||
* An id for this request, if this request is used in cache, only the last request made with this id will be returned.
|
||||
*
|
||||
* @see cacheKey
|
||||
* @default undefined
|
||||
*/
|
||||
id?: string | number | symbol;
|
||||
|
||||
/**
|
||||
* All cache options for the request
|
||||
*/
|
||||
@ -37,6 +45,29 @@ export type CacheRequestConfig = AxiosRequestConfig & {
|
||||
* @default ['get']
|
||||
*/
|
||||
methods?: Lowercase<Method>[];
|
||||
|
||||
/**
|
||||
* The function to check if the response code permit being cached.
|
||||
*
|
||||
* @default ({ status }) => status >= 200 && status < 300
|
||||
*/
|
||||
shouldCache?: (response: AxiosResponse) => boolean;
|
||||
|
||||
/**
|
||||
* Once the request is resolved, this specifies what requests should we change the cache.
|
||||
* Can be used to update the request or delete other caches.
|
||||
*
|
||||
* If the function returns void, the entry is deleted
|
||||
*
|
||||
* This is independent if the request made was cached or not.
|
||||
*
|
||||
* The id used is the same as the id on `CacheRequestConfig['id']`, auto-generated or not.
|
||||
*
|
||||
* @default {}
|
||||
*/
|
||||
update?: {
|
||||
[id: string]: 'delete' | ((oldValue: any, atual: any) => any | void);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@ -47,6 +78,13 @@ export interface CacheInstance {
|
||||
* @default new MemoryStorage()
|
||||
*/
|
||||
storage: CacheStorage;
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
generateKey: (options: CacheRequestConfig) => string;
|
||||
}
|
||||
|
||||
export interface AxiosCacheInstance extends AxiosInstance, CacheInstance {
|
||||
|
||||
2
src/constants.ts
Normal file
2
src/constants.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const CACHED_RESPONSE_STATUS = 304;
|
||||
export const CACHED_RESPONSE_STATUS_TEXT = '304 Cached by axios-cache-adapter';
|
||||
@ -1,2 +1,3 @@
|
||||
export { createCache } from './axios/cache';
|
||||
export * from './constants';
|
||||
export * from './storage';
|
||||
|
||||
@ -1,7 +1,54 @@
|
||||
import { AxiosCacheInstance } from '../axios/types';
|
||||
import { AxiosCacheInstance } from '#/axios/types';
|
||||
import {
|
||||
CACHED_RESPONSE_STATUS,
|
||||
CACHED_RESPONSE_STATUS_TEXT
|
||||
} from '#/constants';
|
||||
import { Deferred } from '#/utils/deferred';
|
||||
|
||||
export function applyRequestInterceptor(axios: AxiosCacheInstance) {
|
||||
axios.interceptors.request.use(async (config) => {
|
||||
// Only cache specified methods
|
||||
if (
|
||||
config.cache?.methods?.some(
|
||||
(method) => (config.method || 'get').toLowerCase() == method
|
||||
)
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
|
||||
const key = axios.generateKey(config);
|
||||
const cache = await axios.storage.get(key);
|
||||
|
||||
// Not cached, continue the request, and mark it as fetching
|
||||
if (cache.state == 'empty') {
|
||||
await axios.storage.set(key, {
|
||||
state: 'loading',
|
||||
data: new Deferred(),
|
||||
// The cache header will be set after the response has been read, until that time, the expiration will be -1
|
||||
expiration: config.cache?.interpretHeader
|
||||
? -1
|
||||
: config.cache?.maxAge! || axios.defaults.cache?.maxAge!
|
||||
});
|
||||
return config;
|
||||
}
|
||||
|
||||
// Only check for expiration if the cache exists, because if it is loading, the expiration value may be -1.
|
||||
if (cache.state === 'cached' && cache.expiration < Date.now()) {
|
||||
await axios.storage.remove(key);
|
||||
return config;
|
||||
}
|
||||
|
||||
const { body, headers } = await cache.data;
|
||||
|
||||
config.adapter = () =>
|
||||
Promise.resolve({
|
||||
data: body,
|
||||
config,
|
||||
headers,
|
||||
status: CACHED_RESPONSE_STATUS,
|
||||
statusText: CACHED_RESPONSE_STATUS_TEXT
|
||||
});
|
||||
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,7 +1,77 @@
|
||||
import { AxiosCacheInstance } from '../axios/types';
|
||||
import { AxiosCacheInstance } from '#/axios/types';
|
||||
import { parse } from '@tusbar/cache-control';
|
||||
|
||||
export function applyResponseInterceptor(axios: AxiosCacheInstance) {
|
||||
axios.interceptors.response.use(async (config) => {
|
||||
return config;
|
||||
axios.interceptors.response.use(async (response) => {
|
||||
// Update other entries before updating himself
|
||||
for (const [cacheKey, value] of Object.entries(
|
||||
response.config.cache?.update || {}
|
||||
)) {
|
||||
if (value == 'delete') {
|
||||
await axios.storage.remove(cacheKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
const oldValue = await axios.storage.get(cacheKey);
|
||||
const newValue = value(oldValue, response.data);
|
||||
if(newValue !== undefined) {
|
||||
await axios.storage.set(cacheKey, newValue);
|
||||
} else {
|
||||
await axios.storage.remove(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Config told that this response should be cached.
|
||||
if (!response.config.cache?.shouldCache!(response)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const key = axios.generateKey(response.config);
|
||||
const cache = await axios.storage.get(key);
|
||||
|
||||
if (
|
||||
// Response already is in cache.
|
||||
cache.state === 'cached' ||
|
||||
// Received response without being intercepted in the response
|
||||
cache.state === 'empty'
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
if (response.config.cache?.interpretHeader) {
|
||||
const cacheControl = response.headers['cache-control'] || '';
|
||||
const { noCache, noStore, maxAge } = parse(cacheControl);
|
||||
|
||||
// Header told that this response should not be cached.
|
||||
if (noCache || noStore) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const expirationTime = maxAge
|
||||
? // Header max age in seconds
|
||||
Date.now() + maxAge * 1000
|
||||
: response.config.cache?.maxAge || axios.defaults.cache?.maxAge!;
|
||||
|
||||
cache.expiration = expirationTime;
|
||||
} else {
|
||||
// If the cache expiration has not been set, use the default expiration.
|
||||
cache.expiration =
|
||||
cache.expiration ||
|
||||
response.config.cache?.maxAge ||
|
||||
axios.defaults.cache?.maxAge!;
|
||||
}
|
||||
|
||||
const data = { body: response.data, headers: response.headers };
|
||||
|
||||
// Resolve this deferred to update the cache after it
|
||||
cache.data.resolve(data);
|
||||
|
||||
await axios.storage.set(key, {
|
||||
data,
|
||||
expiration: cache.expiration,
|
||||
state: 'cached'
|
||||
});
|
||||
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
export * from './memory';
|
||||
export * from './types';
|
||||
export * from './web';
|
||||
export * from './wrapper';
|
||||
|
||||
@ -1,11 +1,5 @@
|
||||
import { CacheStorage, StorageValue } from './types';
|
||||
|
||||
const emptyValue: StorageValue = {
|
||||
data: null,
|
||||
expires: -1,
|
||||
state: 'empty'
|
||||
};
|
||||
|
||||
export class MemoryStorage implements CacheStorage {
|
||||
readonly storage: Map<string, StorageValue> = new Map();
|
||||
|
||||
@ -17,8 +11,7 @@ export class MemoryStorage implements CacheStorage {
|
||||
}
|
||||
|
||||
// Fresh copy to prevent code duplication
|
||||
const empty = { ...emptyValue };
|
||||
|
||||
const empty = { data: null, expiration: -1, state: 'empty' } as const;
|
||||
this.storage.set(key, empty);
|
||||
return empty;
|
||||
};
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { Deferred } from '../utils/deferred';
|
||||
|
||||
export interface CacheStorage {
|
||||
/**
|
||||
* Returns the cached value for the given key or a new empty
|
||||
* Returns the cached value for the given key. Should return a 'empty'
|
||||
* state StorageValue if the key does not exist.
|
||||
*/
|
||||
get: (key: string) => Promise<StorageValue>;
|
||||
/**
|
||||
@ -13,20 +16,30 @@ export interface CacheStorage {
|
||||
remove: (key: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface StorageValue {
|
||||
/**
|
||||
* The value of the cached response
|
||||
*/
|
||||
data: any | null;
|
||||
export type CachedResponse = {
|
||||
headers: any;
|
||||
body: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* The time when the cached response expires
|
||||
* -1 means not cached
|
||||
*/
|
||||
expires: number;
|
||||
|
||||
/**
|
||||
* The status of this value.
|
||||
*/
|
||||
state: 'cached' | 'empty' | 'loading';
|
||||
}
|
||||
/**
|
||||
* The value returned for a given key.
|
||||
*/
|
||||
export type StorageValue =
|
||||
| {
|
||||
data: CachedResponse;
|
||||
expiration: number;
|
||||
state: 'cached';
|
||||
}
|
||||
| {
|
||||
data: Deferred<CachedResponse>;
|
||||
/**
|
||||
* If interpretHeader is used, this value will be `-1`until the response is received
|
||||
*/
|
||||
expiration: number;
|
||||
state: 'loading';
|
||||
}
|
||||
| {
|
||||
data: null;
|
||||
expiration: -1;
|
||||
state: 'empty';
|
||||
};
|
||||
|
||||
@ -1,4 +1,29 @@
|
||||
import { WindowStorageWrapper } from './wrapper';
|
||||
import { CacheStorage, StorageValue } from './types';
|
||||
/**
|
||||
* A storage that uses any {@link Storage} as his storage.
|
||||
*/
|
||||
export abstract class WindowStorageWrapper implements CacheStorage {
|
||||
constructor(
|
||||
readonly storage: Storage,
|
||||
readonly prefix: string = 'axios-cache:'
|
||||
) {}
|
||||
|
||||
get = async (key: string): Promise<StorageValue> => {
|
||||
const json = this.storage.getItem(this.prefix + key);
|
||||
return json
|
||||
? JSON.parse(json)
|
||||
: { data: null, expiration: -1, state: 'empty' };
|
||||
};
|
||||
|
||||
set = async (key: string, value: StorageValue): Promise<void> => {
|
||||
const json = JSON.stringify(value);
|
||||
this.storage.setItem(this.prefix + key, json);
|
||||
};
|
||||
|
||||
remove = async (key: string): Promise<void> => {
|
||||
this.storage.removeItem(this.prefix + key);
|
||||
};
|
||||
}
|
||||
|
||||
export class LocalCacheStorage extends WindowStorageWrapper {
|
||||
constructor(prefix?: string) {
|
||||
|
||||
@ -1,24 +0,0 @@
|
||||
import { CacheStorage, StorageValue } from './types';
|
||||
/**
|
||||
* A storage that uses any {@link Storage} as his storage.
|
||||
*/
|
||||
export abstract class WindowStorageWrapper implements CacheStorage {
|
||||
constructor(
|
||||
readonly storage: Storage,
|
||||
readonly prefix: string = 'axios-cache:'
|
||||
) {}
|
||||
|
||||
get = async (key: string): Promise<StorageValue> => {
|
||||
const json = this.storage.getItem(this.prefix + key);
|
||||
return json ? JSON.parse(json) : null;
|
||||
};
|
||||
|
||||
set = async (key: string, value: StorageValue): Promise<void> => {
|
||||
const json = JSON.stringify(value);
|
||||
this.storage.setItem(this.prefix + key, json);
|
||||
};
|
||||
|
||||
remove = async (key: string): Promise<void> => {
|
||||
this.storage.removeItem(this.prefix + key);
|
||||
};
|
||||
}
|
||||
76
src/utils/deferred.ts
Normal file
76
src/utils/deferred.ts
Normal file
@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Represents the completion of an asynchronous operation that can be completed later.
|
||||
*/
|
||||
export class Deferred<T> {
|
||||
readonly promise: Promise<T>;
|
||||
private _resolve: (value: T | PromiseLike<T>) => void = () => {};
|
||||
private _reject: (reason?: any) => void = () => {};
|
||||
|
||||
constructor() {
|
||||
this.promise = new Promise((resolve, reject) => {
|
||||
this._resolve = resolve;
|
||||
this._reject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve this deferred promise with the given value.
|
||||
* @param the value to resolve
|
||||
*/
|
||||
public readonly resolve = (value: T | PromiseLike<T>): void => {
|
||||
this._resolve(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Reject this deferred promise with the given reason.
|
||||
* @param reason the reason to reject this deferred promise
|
||||
*/
|
||||
public readonly reject = (reason?: any): void => {
|
||||
this._reject(reason);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||
* @param onrejected The callback to execute when the Promise is rejected.
|
||||
* @returns A Promise for the completion of which ever callback is executed.
|
||||
*/
|
||||
public readonly then = <TResult1 = T, TResult2 = never>(
|
||||
onfulfilled?:
|
||||
| ((value: T) => TResult1 | PromiseLike<TResult1>)
|
||||
| undefined
|
||||
| null,
|
||||
onrejected?:
|
||||
| ((reason: any) => TResult2 | PromiseLike<TResult2>)
|
||||
| undefined
|
||||
| null
|
||||
): Promise<TResult1 | TResult2> => {
|
||||
return this.promise.then(onfulfilled, onrejected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attaches a callback for only the rejection of the Promise.
|
||||
* @param onrejected The callback to execute when the Promise is rejected.
|
||||
* @returns A Promise for the completion of the callback.
|
||||
*/
|
||||
public readonly catch = <TResult = never>(
|
||||
onrejected?:
|
||||
| ((reason: any) => TResult | PromiseLike<TResult>)
|
||||
| undefined
|
||||
| null
|
||||
): Promise<T | TResult> => {
|
||||
return this.promise.catch(onrejected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Attaches a callback that is invoked when the Promise is settled (fulfilled or rejected). The
|
||||
* resolved value cannot be modified from the callback.
|
||||
* @param onfinally The callback to execute when the Promise is settled (fulfilled or rejected).
|
||||
* @returns A Promise for the completion of the callback.
|
||||
*/
|
||||
public readonly finally = (
|
||||
onfinally?: (() => void) | undefined | null
|
||||
): Promise<T> => {
|
||||
return this.promise.finally(onfinally);
|
||||
};
|
||||
}
|
||||
15
src/utils/key-generator.ts
Normal file
15
src/utils/key-generator.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { CacheRequestConfig } from '#/axios/types';
|
||||
|
||||
export function defaultKeyGenerator({
|
||||
baseURL,
|
||||
url,
|
||||
method,
|
||||
params,
|
||||
id
|
||||
}: CacheRequestConfig): string {
|
||||
return id
|
||||
? `id::${String(id)}`
|
||||
: `${method?.toLowerCase() || 'get'}::${baseURL}::${url}::${JSON.stringify(
|
||||
params || '{}'
|
||||
)}`;
|
||||
}
|
||||
10
yarn.lock
10
yarn.lock
@ -2,6 +2,11 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@tusbar/cache-control@^0.6.0":
|
||||
version "0.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@tusbar/cache-control/-/cache-control-0.6.0.tgz#9d057b393db24bfbf7d39e6e2bf766b945602e54"
|
||||
integrity sha512-szxZ+I62MYILpuGywjShgE4e1WemNfNq5zu57ZP4gLXnQD7ng8vssrJ8MBseHPDNheJBLFsn2yfdHZIAHnmuvQ==
|
||||
|
||||
"@types/mdast@^3.0.0":
|
||||
version "3.0.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.10.tgz#4724244a82a4598884cbbe9bcfd73dff927ee8af"
|
||||
@ -19,11 +24,6 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d"
|
||||
integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==
|
||||
|
||||
add@^2.0.6:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/add/-/add-2.0.6.tgz#248f0a9f6e5a528ef2295dbeec30532130ae2235"
|
||||
integrity sha1-JI8Kn25aUo7yKV2+7DBTITCuIjU=
|
||||
|
||||
axios@^0.21.1:
|
||||
version "0.21.1"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user