feat: cache working

This commit is contained in:
Hazork 2021-09-01 16:13:59 -03:00
parent 5d8b698ddb
commit 9f6e1a469c
15 changed files with 326 additions and 65 deletions

View File

@ -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"
}
}

View File

@ -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
}
};

View File

@ -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
View File

@ -0,0 +1,2 @@
export const CACHED_RESPONSE_STATUS = 304;
export const CACHED_RESPONSE_STATUS_TEXT = '304 Cached by axios-cache-adapter';

View File

@ -1,2 +1,3 @@
export { createCache } from './axios/cache';
export * from './constants';
export * from './storage';

View File

@ -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;
});
}

View File

@ -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;
});
}

View File

@ -1,4 +1,3 @@
export * from './memory';
export * from './types';
export * from './web';
export * from './wrapper';

View File

@ -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;
};

View File

@ -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';
};

View File

@ -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) {

View File

@ -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
View 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);
};
}

View 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 || '{}'
)}`;
}

View File

@ -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"