lion/packages/ajax/src/interceptors-cache.js
Yevgeniy Valeyev bbffd7105f feat: port caching feature to fetch proposal
Co-authored-by: Yevgeniy Valeyev <yevgeniy.valeyev@ing.com>
2021-02-11 12:28:05 +01:00

346 lines
9.8 KiB
JavaScript

/* eslint-disable consistent-return */
/* eslint-disable no-param-reassign */
import './typedef.js';
const SECOND = 1000;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
class Cache {
constructor() {
this.expiration = new Date().getTime() + HOUR;
/**
* @type {{[url: string]: CacheConfig }}
*/
this.cacheConfig = {};
/**
* @type {{[url: string]: {expires: number, data: object} }}
*/
this._cacheObject = {};
}
/**
* Store an item in the cache
* @param {string} url key by which the cache is stored
* @param {object} data the cached object
*/
set(url, data) {
this._validateCache();
this._cacheObject[url] = {
expires: new Date().getTime(),
data,
};
}
/**
* Retrieve an item from the cache
* @param {string} url key by which the cache is stored
* @param {number} timeToLive maximum time to allow cache to live
*/
get(url, timeToLive) {
this._validateCache();
const cacheResult = this._cacheObject[url];
if (!cacheResult) {
return false;
}
const cacheAge = new Date().getTime() - cacheResult.expires;
if (timeToLive !== null && cacheAge > timeToLive) {
return false;
}
return cacheResult.data;
}
/**
* Delete all items from the cache that contain the given url
* @param {string} url key by which the cache is stored
*/
delete(url) {
this._validateCache();
Object.keys(this._cacheObject).forEach(key => {
if (key.indexOf(url) > -1) {
delete this._cacheObject[key];
}
});
}
/**
* Delete all items from the cache that match given regex
* @param {RegExp} regex an regular expression to match cache entries
*/
deleteMatched(regex) {
this._validateCache();
Object.keys(this._cacheObject).forEach(key => {
const notMatch = !new RegExp(regex).test(key);
if (notMatch) return;
const isDataDeleted = delete this._cacheObject[key];
if (!isDataDeleted) {
throw new Error(`Failed to delete cache for a request '${key}'`);
}
});
}
/**
* Validate cache on each call to the Cache
* When the expiration date has passed, the _cacheObject will be replaced by an
* empty object
*/
_validateCache() {
if (new Date().getTime() > this.expiration) {
// @ts-ignore
this._cacheObject = {};
}
return true;
}
}
let caches = {};
/**
* Serialize search parameters into url query string parameters.
* If params === null, returns ''
* @param {Params} params query string parameters object
* @returns {string} of querystring parameters WITHOUT `?` or empty string ''
*/
export const searchParamSerializer = (params = {}) =>
// @ts-ignore
typeof params === 'object' ? new URLSearchParams(params).toString() : '';
/**
* Returns the active cache instance for the current session
* If 'cacheIdentifier' changes the cache is reset, we avoid situation of accessing old cache
* and proactively clean it
* @param {string} cacheIdentifier usually the refreshToken of the owner of the cache
*/
const getCache = cacheIdentifier => {
if (caches[cacheIdentifier] && caches[cacheIdentifier]._validateCache()) {
return caches[cacheIdentifier];
}
// invalidate old caches
caches = {};
// create new cache
caches[cacheIdentifier] = new Cache();
return caches[cacheIdentifier];
};
/**
* @param {CacheOptions} options Options to match cache
* @returns {ValidatedCacheOptions}
*/
export const validateOptions = ({
useCache = false,
methods = ['get'],
timeToLive,
invalidateUrls,
invalidateUrlsRegex,
requestIdentificationFn,
}) => {
// validate 'cache'
if (typeof useCache !== 'boolean') {
throw new Error('Property `useCache` should be `true` or `false`');
}
if (methods[0] !== 'get' || methods.length !== 1) {
throw new Error('Functionality to use cache on anything except "get" is not yet supported');
}
// validate 'timeToLive', default 1 hour
if (timeToLive === undefined) {
timeToLive = 0;
}
if (Number.isNaN(parseInt(String(timeToLive), 10))) {
throw new Error('Property `timeToLive` must be of type `number`');
}
// validate 'invalidateUrls', must be an `Array` or `falsy`
if (invalidateUrls) {
if (!Array.isArray(invalidateUrls)) {
throw new Error('Property `invalidateUrls` must be of type `Array` or `falsy`');
}
}
// validate 'invalidateUrlsRegex', must be an regex expression or `falsy`
if (invalidateUrlsRegex) {
if (!(invalidateUrlsRegex instanceof RegExp)) {
throw new Error('Property `invalidateUrlsRegex` must be of type `RegExp` or `falsy`');
}
}
// validate 'requestIdentificationFn', default is url + searchParams
if (requestIdentificationFn) {
if (typeof requestIdentificationFn !== 'function') {
throw new Error('Property `requestIdentificationFn` must be of type `function` or `falsy`');
}
} else {
requestIdentificationFn = /** @param {any} data */ (
{ url, params },
searchParamsSerializer,
) => {
const serializedParams = searchParamsSerializer(params);
return serializedParams ? `${url}?${serializedParams}` : url;
};
}
return {
useCache,
methods,
timeToLive,
invalidateUrls,
invalidateUrlsRegex,
requestIdentificationFn,
};
};
/**
* Request interceptor to return relevant cached requests
* @param {ValidatedCacheOptions} validatedInitialCacheOptions
* @param {CacheOptions=} configCacheOptions
* @returns {ValidatedCacheOptions}
*/
function composeCacheOptions(validatedInitialCacheOptions, configCacheOptions) {
/** @type {any} */
let actionCacheOptions = {};
actionCacheOptions =
configCacheOptions &&
validateOptions({
...validatedInitialCacheOptions,
...configCacheOptions,
});
const cacheOptions = {
...validatedInitialCacheOptions,
...actionCacheOptions,
};
return cacheOptions;
}
/**
* Request interceptor to return relevant cached requests
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions
* @returns {CachedRequestInterceptor}
*/
export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => {
const validatedInitialCacheOptions = validateOptions(globalCacheOptions);
return /** @param {CacheRequest} cacheRequest */ async cacheRequest => {
const { method, status, statusText, headers } = cacheRequest;
const cacheOptions = composeCacheOptions(
validatedInitialCacheOptions,
cacheRequest.cacheOptions,
);
cacheRequest.cacheOptions = cacheOptions;
// don't use cache if 'useCache' === false
if (!cacheOptions.useCache) {
return cacheRequest;
}
const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, searchParamSerializer);
// cacheIdentifier is used to bind the cache to the current session
const currentCache = getCache(getCacheIdentifier());
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
// don't use cache if the request method is not part of the configs methods
if (cacheOptions.methods.indexOf(method.toLowerCase()) === -1) {
// If it's NOT one of the config.methods, invalidate caches
currentCache.delete(cacheId);
// also invalidate caches matching to cacheOptions
if (cacheOptions.invalidateUrls) {
cacheOptions.invalidateUrls.forEach(
/** @type {string} */ invalidateUrl => {
currentCache.delete(invalidateUrl);
},
);
}
// also invalidate caches matching to invalidateUrlsRegex
if (cacheOptions.invalidateUrlsRegex) {
currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex);
}
return cacheRequest;
}
if (cacheResponse) {
// eslint-disable-next-line no-param-reassign
if (!cacheRequest.cacheOptions) {
cacheRequest.cacheOptions = { useCache: false };
}
cacheRequest.cacheOptions.fromCache = true;
const init = /** @type {LionRequestInit} */ ({
status,
statusText,
headers,
request: cacheRequest,
});
return /** @type {CacheResponse} */ (new Response(cacheResponse, init));
}
return cacheRequest;
};
};
/**
* Response interceptor to cache relevant requests
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions
* @returns {CachedResponseInterceptor}
*/
export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => {
const validatedInitialCacheOptions = validateOptions(globalCacheOptions);
/**
* Axios response https://github.com/axios/axios#response-schema
*/
return /** @param {CacheResponse} cacheResponse */ async cacheResponse => {
if (!getCacheIdentifier()) {
throw new Error(`getCacheIdentifier returns falsy`);
}
const cacheOptions = composeCacheOptions(
validatedInitialCacheOptions,
cacheResponse.request?.cacheOptions,
);
const isAlreadyFromCache = !!cacheOptions.fromCache;
// caching all responses with not default `timeToLive`
const isCacheActive = cacheOptions.timeToLive > 0;
if (isAlreadyFromCache || !isCacheActive) {
return cacheResponse;
}
// if the request is one of the options.methods; store response in cache
if (
cacheResponse.request &&
cacheOptions.methods.indexOf(cacheResponse.request.method.toLowerCase()) > -1
) {
// string that identifies cache entry
const cacheId = cacheOptions.requestIdentificationFn(
cacheResponse.request,
searchParamSerializer,
);
// store the response data in the cache
getCache(getCacheIdentifier()).set(cacheId, cacheResponse.body);
} else {
// don't store in cache if the request method is not part of the configs methods
return cacheResponse;
}
return cacheResponse;
};
};