feat(ajax): add maxCacheSize option
This commit is contained in:
parent
915de370d7
commit
447383bd14
11 changed files with 355 additions and 100 deletions
5
.changeset/run-forrest-run.md
Normal file
5
.changeset/run-forrest-run.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ajax': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a `maxCacheSize` cache option to specify a max size for the whole cache
|
||||||
|
|
@ -207,7 +207,7 @@ export class Ajax {
|
||||||
for (const intercept of this._responseInterceptors) {
|
for (const intercept of this._responseInterceptors) {
|
||||||
// In this instance we actually do want to await for each sequence
|
// In this instance we actually do want to await for each sequence
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
interceptedResponse = await intercept(interceptedResponse);
|
interceptedResponse = await intercept(/** @type CacheResponse */ (interceptedResponse));
|
||||||
}
|
}
|
||||||
return interceptedResponse;
|
return interceptedResponse;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,40 +3,72 @@ import './typedef.js';
|
||||||
export default class Cache {
|
export default class Cache {
|
||||||
constructor() {
|
constructor() {
|
||||||
/**
|
/**
|
||||||
* @type {{ [requestId: string]: { createdAt: number, response: CacheResponse } }}
|
* @type CachedRequests
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
this._cachedRequests = {};
|
this._cachedRequests = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {number}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
this._size = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store an item in the cache
|
* Store an item in the cache
|
||||||
* @param {string} requestId key by which the request is stored
|
* @param {string} requestId key by which the request is stored
|
||||||
* @param {Response} response the cached response
|
* @param {CacheResponse} response the cached response
|
||||||
|
* @param {number} size the response size
|
||||||
*/
|
*/
|
||||||
set(requestId, response) {
|
set(requestId, response, size = 0) {
|
||||||
|
if (this._cachedRequests[requestId]) {
|
||||||
|
this.delete(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
this._cachedRequests[requestId] = {
|
this._cachedRequests[requestId] = {
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
size,
|
||||||
response,
|
response,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this._size += size;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retrieve an item from the cache
|
* Retrieve an item from the cache
|
||||||
* @param {string} requestId key by which the cache is stored
|
* @param {string} requestId key by which the cache is stored
|
||||||
* @param {number} maxAge maximum age of a cached request to serve from cache, in milliseconds
|
* @param {object} options
|
||||||
|
* @param {number} [options.maxAge] maximum age of a cached request to serve from cache, in milliseconds
|
||||||
|
* @param {number} [options.maxResponseSize] maximum size of a cached request to serve from cache, in bytes
|
||||||
* @returns {CacheResponse | undefined}
|
* @returns {CacheResponse | undefined}
|
||||||
*/
|
*/
|
||||||
get(requestId, maxAge = 0) {
|
get(requestId, { maxAge = Infinity, maxResponseSize = Infinity } = {}) {
|
||||||
|
const isNumber = (/** @type any */ num) => Number(num) === num;
|
||||||
|
|
||||||
const cachedRequest = this._cachedRequests[requestId];
|
const cachedRequest = this._cachedRequests[requestId];
|
||||||
if (!cachedRequest) {
|
if (!cachedRequest) {
|
||||||
return;
|
return undefined;
|
||||||
}
|
}
|
||||||
const cachedRequestAge = Date.now() - cachedRequest.createdAt;
|
|
||||||
if (Number.isFinite(maxAge) && cachedRequestAge < maxAge) {
|
// maxAge and maxResponseSize should both be numbers
|
||||||
// eslint-disable-next-line consistent-return
|
if (!isNumber(maxAge)) {
|
||||||
return cachedRequest.response;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isNumber(maxResponseSize)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() >= cachedRequest.createdAt + maxAge) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedRequest.size > maxResponseSize) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cachedRequest.response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -44,6 +76,13 @@ export default class Cache {
|
||||||
* @param {string } requestId the request id to delete from the cache
|
* @param {string } requestId the request id to delete from the cache
|
||||||
*/
|
*/
|
||||||
delete(requestId) {
|
delete(requestId) {
|
||||||
|
const cachedRequest = this._cachedRequests[requestId];
|
||||||
|
|
||||||
|
if (!cachedRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._size -= cachedRequest.size;
|
||||||
delete this._cachedRequests[requestId];
|
delete this._cachedRequests[requestId];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,7 +98,28 @@ export default class Cache {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Truncate the cache to the given size, according to a First-In-First-Out (FIFO) policy
|
||||||
|
*
|
||||||
|
* @param {number} maxAllowedCacheSize
|
||||||
|
*/
|
||||||
|
truncateTo(maxAllowedCacheSize) {
|
||||||
|
if (this._size <= maxAllowedCacheSize) return;
|
||||||
|
|
||||||
|
const requests = this._cachedRequests;
|
||||||
|
|
||||||
|
const sortedRequestIds = Object.keys(requests).sort(
|
||||||
|
(a, b) => requests[a].createdAt - requests[b].createdAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const requestId of sortedRequestIds) {
|
||||||
|
this.delete(requestId);
|
||||||
|
if (this._size <= maxAllowedCacheSize) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this._cachedRequests = {};
|
this._cachedRequests = {};
|
||||||
|
this._size = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ export const extendCacheOptions = ({
|
||||||
invalidateUrlsRegex,
|
invalidateUrlsRegex,
|
||||||
contentTypes,
|
contentTypes,
|
||||||
maxResponseSize,
|
maxResponseSize,
|
||||||
|
maxCacheSize,
|
||||||
}) => ({
|
}) => ({
|
||||||
useCache,
|
useCache,
|
||||||
methods,
|
methods,
|
||||||
|
|
@ -93,6 +94,7 @@ export const extendCacheOptions = ({
|
||||||
invalidateUrlsRegex,
|
invalidateUrlsRegex,
|
||||||
contentTypes,
|
contentTypes,
|
||||||
maxResponseSize,
|
maxResponseSize,
|
||||||
|
maxCacheSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -107,6 +109,7 @@ export const validateCacheOptions = ({
|
||||||
invalidateUrlsRegex,
|
invalidateUrlsRegex,
|
||||||
contentTypes,
|
contentTypes,
|
||||||
maxResponseSize,
|
maxResponseSize,
|
||||||
|
maxCacheSize,
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
if (useCache !== undefined && typeof useCache !== 'boolean') {
|
if (useCache !== undefined && typeof useCache !== 'boolean') {
|
||||||
throw new Error('Property `useCache` must be a `boolean`');
|
throw new Error('Property `useCache` must be a `boolean`');
|
||||||
|
|
@ -132,6 +135,9 @@ export const validateCacheOptions = ({
|
||||||
if (maxResponseSize !== undefined && !Number.isFinite(maxResponseSize)) {
|
if (maxResponseSize !== undefined && !Number.isFinite(maxResponseSize)) {
|
||||||
throw new Error('Property `maxResponseSize` must be a finite `number`');
|
throw new Error('Property `maxResponseSize` must be a finite `number`');
|
||||||
}
|
}
|
||||||
|
if (maxCacheSize !== undefined && !Number.isFinite(maxCacheSize)) {
|
||||||
|
throw new Error('Property `maxCacheSize` must be a finite `number`');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -2,44 +2,48 @@
|
||||||
import '../typedef.js';
|
import '../typedef.js';
|
||||||
import {
|
import {
|
||||||
ajaxCache,
|
ajaxCache,
|
||||||
resetCacheSession,
|
|
||||||
extendCacheOptions,
|
extendCacheOptions,
|
||||||
validateCacheOptions,
|
|
||||||
invalidateMatchingCache,
|
invalidateMatchingCache,
|
||||||
pendingRequestStore,
|
|
||||||
isCurrentSessionId,
|
isCurrentSessionId,
|
||||||
|
pendingRequestStore,
|
||||||
|
resetCacheSession,
|
||||||
|
validateCacheOptions,
|
||||||
} from '../cacheManager.js';
|
} from '../cacheManager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests whether the request method is supported according to the `cacheOptions`
|
* Tests whether the request method is supported according to the `cacheOptions`
|
||||||
* @param {ValidatedCacheOptions} cacheOptions
|
* @param {string[]} methods
|
||||||
* @param {string} method
|
* @param {string} method
|
||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
const isMethodSupported = (cacheOptions, method) =>
|
const isMethodSupported = (methods, method) => methods.includes(method.toLowerCase());
|
||||||
cacheOptions.methods.includes(method.toLowerCase());
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests whether the response content type is supported by the `contentTypes` whitelist
|
* Tests whether the response content type is supported by the `contentTypes` whitelist
|
||||||
* @param {Response} response
|
* @param {Response} response
|
||||||
* @param {CacheOptions} cacheOptions
|
* @param {string[]|undefined} contentTypes
|
||||||
* @returns {boolean} `true` if the contentTypes property is not an array, or if the value of the Content-Type header is in the array
|
* @returns {boolean} `true` if the contentTypes property is not an array, or if the value of the Content-Type header is in the array
|
||||||
*/
|
*/
|
||||||
const isResponseContentTypeSupported = (response, { contentTypes } = {}) => {
|
const isResponseContentTypeSupported = (response, contentTypes) => {
|
||||||
if (!Array.isArray(contentTypes)) return true;
|
if (!Array.isArray(contentTypes)) return true;
|
||||||
|
|
||||||
return contentTypes.includes(String(response.headers.get('Content-Type')));
|
return contentTypes.includes(String(response.headers.get('Content-Type')));
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tests whether the response size is not too large to be cached according to the `maxResponseSize` property
|
|
||||||
* @param {Response} response
|
* @param {Response} response
|
||||||
* @param {CacheOptions} cacheOptions
|
* @returns {Promise<number>}
|
||||||
* @returns {boolean} `true` if the `maxResponseSize` property is not larger than zero, or if the Content-Length header is not present, or if the value of the header is not larger than the `maxResponseSize` property
|
|
||||||
*/
|
*/
|
||||||
const isResponseSizeSupported = (response, { maxResponseSize } = {}) => {
|
const getResponseSize = async response =>
|
||||||
const responseSize = +(response.headers.get('Content-Length') || 0);
|
Number(response.headers.get('Content-Length')) || (await response.clone().blob()).size || 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests whether the response size is not too large to be cached according to the `maxResponseSize` property
|
||||||
|
* @param {number|undefined} responseSize
|
||||||
|
* @param {number|undefined} maxResponseSize
|
||||||
|
* @returns {boolean} `true` if the `maxResponseSize` property is not larger than zero, or if the response size is not known, or if the value of the header is not larger than the `maxResponseSize` property
|
||||||
|
*/
|
||||||
|
const isResponseSizeSupported = (responseSize, maxResponseSize) => {
|
||||||
if (!maxResponseSize) return true;
|
if (!maxResponseSize) return true;
|
||||||
if (!responseSize) return true;
|
if (!responseSize) return true;
|
||||||
|
|
||||||
|
|
@ -63,17 +67,20 @@ const createCacheRequestInterceptor =
|
||||||
...request.cacheOptions,
|
...request.cacheOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { useCache, requestIdFunction, methods, contentTypes, maxAge, maxResponseSize } =
|
||||||
|
cacheOptions;
|
||||||
|
|
||||||
// store cacheOptions and cacheSessionId in the request, to use it in the response interceptor.
|
// store cacheOptions and cacheSessionId in the request, to use it in the response interceptor.
|
||||||
request.cacheOptions = cacheOptions;
|
request.cacheOptions = cacheOptions;
|
||||||
request.cacheSessionId = cacheSessionId;
|
request.cacheSessionId = cacheSessionId;
|
||||||
|
|
||||||
if (!cacheOptions.useCache) {
|
if (!useCache) {
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestId = cacheOptions.requestIdFunction(request);
|
const requestId = requestIdFunction(request);
|
||||||
|
|
||||||
if (!isMethodSupported(cacheOptions, request.method)) {
|
if (!isMethodSupported(methods, request.method)) {
|
||||||
invalidateMatchingCache(requestId, cacheOptions);
|
invalidateMatchingCache(requestId, cacheOptions);
|
||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
@ -84,15 +91,11 @@ const createCacheRequestInterceptor =
|
||||||
await pendingRequest;
|
await pendingRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge);
|
const cachedResponse = ajaxCache.get(requestId, { maxAge, maxResponseSize });
|
||||||
if (
|
if (cachedResponse && isResponseContentTypeSupported(cachedResponse, contentTypes)) {
|
||||||
cachedResponse &&
|
|
||||||
isResponseContentTypeSupported(cachedResponse, cacheOptions) &&
|
|
||||||
isResponseSizeSupported(cachedResponse, cacheOptions)
|
|
||||||
) {
|
|
||||||
// Return the response from cache
|
// Return the response from cache
|
||||||
request.cacheOptions = request.cacheOptions ?? { useCache: false };
|
request.cacheOptions = request.cacheOptions ?? { useCache: false };
|
||||||
/** @type {CacheResponse} */
|
|
||||||
const response = cachedResponse.clone();
|
const response = cachedResponse.clone();
|
||||||
response.request = request;
|
response.request = request;
|
||||||
response.fromCache = true;
|
response.fromCache = true;
|
||||||
|
|
@ -109,35 +112,43 @@ const createCacheRequestInterceptor =
|
||||||
* @param {CacheOptions} globalCacheOptions
|
* @param {CacheOptions} globalCacheOptions
|
||||||
* @returns {ResponseInterceptor}
|
* @returns {ResponseInterceptor}
|
||||||
*/
|
*/
|
||||||
const createCacheResponseInterceptor =
|
const createCacheResponseInterceptor = globalCacheOptions => async responseParam => {
|
||||||
globalCacheOptions => /** @param {CacheResponse} response */ async response => {
|
const response = /** @type {CacheResponse} */ (responseParam);
|
||||||
if (!response.request) {
|
|
||||||
throw new Error('Missing request in response');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheOptions = extendCacheOptions({
|
if (!response.request) {
|
||||||
|
throw new Error('Missing request in response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { requestIdFunction, methods, contentTypes, maxResponseSize, maxCacheSize } =
|
||||||
|
extendCacheOptions({
|
||||||
...globalCacheOptions,
|
...globalCacheOptions,
|
||||||
...response.request.cacheOptions,
|
...response.request.cacheOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.fromCache && isMethodSupported(cacheOptions, response.request.method)) {
|
if (!response.fromCache && isMethodSupported(methods, response.request.method)) {
|
||||||
const requestId = cacheOptions.requestIdFunction(response.request);
|
const requestId = requestIdFunction(response.request);
|
||||||
|
const responseSize = maxCacheSize || maxResponseSize ? await getResponseSize(response) : 0;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
isCurrentSessionId(response.request.cacheSessionId) &&
|
isCurrentSessionId(response.request.cacheSessionId) &&
|
||||||
isResponseContentTypeSupported(response, cacheOptions) &&
|
isResponseContentTypeSupported(response, contentTypes) &&
|
||||||
isResponseSizeSupported(response, cacheOptions)
|
isResponseSizeSupported(responseSize, maxResponseSize)
|
||||||
) {
|
) {
|
||||||
// Cache the response
|
// Cache the response
|
||||||
ajaxCache.set(requestId, response.clone());
|
ajaxCache.set(requestId, response.clone(), responseSize);
|
||||||
|
|
||||||
|
// Truncate the cache if needed
|
||||||
|
if (maxCacheSize) {
|
||||||
|
ajaxCache.truncateTo(maxCacheSize);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark the pending request as resolved
|
|
||||||
pendingRequestStore.resolve(requestId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return response;
|
// Mark the pending request as resolved
|
||||||
};
|
pendingRequestStore.resolve(requestId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Response interceptor to cache relevant requests
|
* Response interceptor to cache relevant requests
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
* @typedef {import('../types/types').CacheResponseRequest} CacheResponseRequest
|
* @typedef {import('../types/types').CacheResponseRequest} CacheResponseRequest
|
||||||
* @typedef {import('../types/types').CacheRequest} CacheRequest
|
* @typedef {import('../types/types').CacheRequest} CacheRequest
|
||||||
* @typedef {import('../types/types').CacheResponse} CacheResponse
|
* @typedef {import('../types/types').CacheResponse} CacheResponse
|
||||||
|
* @typedef {import('../types/types').CachedRequests} CachedRequests
|
||||||
* @typedef {import('../types/types').CachedRequestInterceptor} CachedRequestInterceptor
|
* @typedef {import('../types/types').CachedRequestInterceptor} CachedRequestInterceptor
|
||||||
* @typedef {import('../types/types').CachedResponseInterceptor} CachedResponseInterceptor
|
* @typedef {import('../types/types').CachedResponseInterceptor} CachedResponseInterceptor
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,23 @@ describe('Ajax', () => {
|
||||||
// When
|
// When
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const ajax1 = new Ajax(config);
|
const ajax1 = new Ajax(config);
|
||||||
const result = ajax1.options?.cacheOptions?.getCacheIdentifier;
|
const defaultCacheIdentifierFunction = ajax1.options?.cacheOptions?.getCacheIdentifier;
|
||||||
// Then
|
// Then
|
||||||
expect(result).not.to.be.undefined;
|
expect(defaultCacheIdentifierFunction).not.to.be.undefined;
|
||||||
expect(result).to.be.a('function');
|
expect(defaultCacheIdentifierFunction).to.be.a('function');
|
||||||
|
expect(defaultCacheIdentifierFunction()).to.equal('_default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can set options through a setter after the object has been created', () => {
|
||||||
|
// Given
|
||||||
|
const ajax1 = new Ajax({ jsonPrefix: 'prefix1' });
|
||||||
|
expect(ajax1.options.jsonPrefix).to.equal('prefix1');
|
||||||
|
|
||||||
|
// When
|
||||||
|
ajax1.options = { ...ajax1.options, jsonPrefix: 'prefix2' };
|
||||||
|
|
||||||
|
// Then
|
||||||
|
expect(ajax1.options.jsonPrefix).to.equal('prefix2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -168,6 +181,22 @@ describe('Ajax', () => {
|
||||||
expect(response.body).to.eql({ a: 1, b: 2 });
|
expect(response.body).to.eql({ a: 1, b: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws on invalid JSON responses', async () => {
|
||||||
|
fetchStub.returns(Promise.resolve(new Response('invalid-json')));
|
||||||
|
|
||||||
|
let thrown = false;
|
||||||
|
try {
|
||||||
|
await ajax.fetchJson('/foo');
|
||||||
|
} catch (e) {
|
||||||
|
// https://github.com/microsoft/TypeScript/issues/20024 open issue, can't type catch clause in param
|
||||||
|
const _e = /** @type {Error} */ (e);
|
||||||
|
expect(_e).to.be.an.instanceOf(Error);
|
||||||
|
expect(_e.message).to.equal('Failed to parse response from as JSON.');
|
||||||
|
thrown = true;
|
||||||
|
}
|
||||||
|
expect(thrown).to.be.true;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('request and response interceptors', () => {
|
describe('request and response interceptors', () => {
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,12 @@ describe('Cache', () => {
|
||||||
const cache = new Cache();
|
const cache = new Cache();
|
||||||
|
|
||||||
cache._cachedRequests = {
|
cache._cachedRequests = {
|
||||||
requestId1: { createdAt: Date.now() - TWO_MINUTES_IN_MS, response: 'cached data 1' },
|
requestId1: {
|
||||||
requestId2: { createdAt: Date.now(), response: 'cached data 2' },
|
createdAt: Date.now() - TWO_MINUTES_IN_MS,
|
||||||
|
response: 'cached data 1',
|
||||||
|
size: 1000,
|
||||||
|
},
|
||||||
|
requestId2: { createdAt: Date.now(), response: 'cached data 2', size: 100 },
|
||||||
};
|
};
|
||||||
|
|
||||||
it('returns undefined if no cached request found for requestId', () => {
|
it('returns undefined if no cached request found for requestId', () => {
|
||||||
|
|
@ -41,7 +45,7 @@ describe('Cache', () => {
|
||||||
const maxAge = TEN_MINUTES_IN_MS;
|
const maxAge = TEN_MINUTES_IN_MS;
|
||||||
const expected = undefined;
|
const expected = undefined;
|
||||||
// When
|
// When
|
||||||
const result = cache.get('nonCachedRequestId', maxAge);
|
const result = cache.get('nonCachedRequestId', { maxAge });
|
||||||
// Then
|
// Then
|
||||||
expect(result).to.equal(expected);
|
expect(result).to.equal(expected);
|
||||||
});
|
});
|
||||||
|
|
@ -51,17 +55,7 @@ describe('Cache', () => {
|
||||||
const maxAge = 'some string';
|
const maxAge = 'some string';
|
||||||
const expected = undefined;
|
const expected = undefined;
|
||||||
// When
|
// When
|
||||||
const result = cache.get('requestId1', maxAge);
|
const result = cache.get('requestId1', { maxAge });
|
||||||
// Then
|
|
||||||
expect(result).to.equal(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns undefined if maxAge is not finite', () => {
|
|
||||||
// Given
|
|
||||||
const maxAge = 1 / 0;
|
|
||||||
const expected = undefined;
|
|
||||||
// When
|
|
||||||
const result = cache.get('requestId1', maxAge);
|
|
||||||
// Then
|
// Then
|
||||||
expect(result).to.equal(expected);
|
expect(result).to.equal(expected);
|
||||||
});
|
});
|
||||||
|
|
@ -71,7 +65,7 @@ describe('Cache', () => {
|
||||||
const maxAge = -10;
|
const maxAge = -10;
|
||||||
const expected = undefined;
|
const expected = undefined;
|
||||||
// When
|
// When
|
||||||
const result = cache.get('requestId1', maxAge);
|
const result = cache.get('requestId1', { maxAge });
|
||||||
// Then
|
// Then
|
||||||
expect(result).to.equal(expected);
|
expect(result).to.equal(expected);
|
||||||
});
|
});
|
||||||
|
|
@ -81,7 +75,7 @@ describe('Cache', () => {
|
||||||
const maxAge = A_MINUTE_IN_MS;
|
const maxAge = A_MINUTE_IN_MS;
|
||||||
const expected = undefined;
|
const expected = undefined;
|
||||||
// When
|
// When
|
||||||
const result = cache.get('requestId1', maxAge);
|
const result = cache.get('requestId1', { maxAge });
|
||||||
// Then
|
// Then
|
||||||
expect(result).to.equal(expected);
|
expect(result).to.equal(expected);
|
||||||
});
|
});
|
||||||
|
|
@ -91,7 +85,59 @@ describe('Cache', () => {
|
||||||
const maxAge = TEN_MINUTES_IN_MS;
|
const maxAge = TEN_MINUTES_IN_MS;
|
||||||
const expected = cache._cachedRequests?.requestId1?.response;
|
const expected = cache._cachedRequests?.requestId1?.response;
|
||||||
// When
|
// When
|
||||||
const result = cache.get('requestId1', maxAge);
|
const result = cache.get('requestId1', { maxAge });
|
||||||
|
// Then
|
||||||
|
expect(result).to.deep.equal(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the cached value if maxAge is Infinity', () => {
|
||||||
|
// Given
|
||||||
|
const maxAge = 1 / 0;
|
||||||
|
const expected = 'cached data 1';
|
||||||
|
// When
|
||||||
|
const result = cache.get('requestId1', { maxAge });
|
||||||
|
// Then
|
||||||
|
expect(result).to.equal(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the cached value if neither maxAge nor maxSize is specified', () => {
|
||||||
|
// Given
|
||||||
|
const expected = 'cached data 1';
|
||||||
|
// When
|
||||||
|
const result = cache.get('requestId1');
|
||||||
|
// Then
|
||||||
|
expect(result).to.equal(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if maxResponseSize is set and is smaller than the recorded size of the cache item', () => {
|
||||||
|
// Given
|
||||||
|
const maxAge = TEN_MINUTES_IN_MS;
|
||||||
|
const maxResponseSize = 100;
|
||||||
|
const expected = undefined;
|
||||||
|
// When
|
||||||
|
const result = cache.get('requestId1', { maxAge, maxResponseSize });
|
||||||
|
// Then
|
||||||
|
expect(result).to.equal(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined if maxResponseSize is set and is not a number', () => {
|
||||||
|
// Given
|
||||||
|
const maxAge = TEN_MINUTES_IN_MS;
|
||||||
|
const maxResponseSize = 'nine thousand';
|
||||||
|
const expected = undefined;
|
||||||
|
// When
|
||||||
|
const result = cache.get('requestId1', { maxAge, maxResponseSize });
|
||||||
|
// Then
|
||||||
|
expect(result).to.equal(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets the cached request by requestId if maxResponseSize is set and is greater than the recorded size of the cache item', () => {
|
||||||
|
// Given
|
||||||
|
const maxAge = TEN_MINUTES_IN_MS;
|
||||||
|
const maxResponseSize = 10000;
|
||||||
|
const expected = cache._cachedRequests?.requestId1?.response;
|
||||||
|
// When
|
||||||
|
const result = cache.get('requestId1', { maxAge, maxResponseSize });
|
||||||
// Then
|
// Then
|
||||||
expect(result).to.deep.equal(expected);
|
expect(result).to.deep.equal(expected);
|
||||||
});
|
});
|
||||||
|
|
@ -108,8 +154,8 @@ describe('Cache', () => {
|
||||||
cache.set('requestId1', response1);
|
cache.set('requestId1', response1);
|
||||||
cache.set('requestId2', response2);
|
cache.set('requestId2', response2);
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
expect(cache.get('requestId1', { maxAge })).to.equal(response1);
|
||||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
expect(cache.get('requestId2', { maxAge })).to.equal(response2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates the `response` for the given `requestId`, if already cached', () => {
|
it('updates the `response` for the given `requestId`, if already cached', () => {
|
||||||
|
|
@ -121,11 +167,11 @@ describe('Cache', () => {
|
||||||
// When
|
// When
|
||||||
cache.set('requestId1', response);
|
cache.set('requestId1', response);
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.equal(response);
|
expect(cache.get('requestId1', { maxAge })).to.equal(response);
|
||||||
// When
|
// When
|
||||||
cache.set('requestId1', updatedResponse);
|
cache.set('requestId1', updatedResponse);
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.equal(updatedResponse);
|
expect(cache.get('requestId1', { maxAge })).to.equal(updatedResponse);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -140,13 +186,13 @@ describe('Cache', () => {
|
||||||
cache.set('requestId1', response1);
|
cache.set('requestId1', response1);
|
||||||
cache.set('requestId2', response2);
|
cache.set('requestId2', response2);
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
expect(cache.get('requestId1', { maxAge })).to.equal(response1);
|
||||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
expect(cache.get('requestId2', { maxAge })).to.equal(response2);
|
||||||
// When
|
// When
|
||||||
cache.delete('requestId1');
|
cache.delete('requestId1');
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.be.undefined;
|
expect(cache.get('requestId1', { maxAge })).to.be.undefined;
|
||||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
expect(cache.get('requestId2', { maxAge })).to.equal(response2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes cache by regex', () => {
|
it('deletes cache by regex', () => {
|
||||||
|
|
@ -161,15 +207,15 @@ describe('Cache', () => {
|
||||||
cache.set('requestId2', response2);
|
cache.set('requestId2', response2);
|
||||||
cache.set('anotherRequestId', response3);
|
cache.set('anotherRequestId', response3);
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
expect(cache.get('requestId1', { maxAge })).to.equal(response1);
|
||||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
expect(cache.get('requestId2', { maxAge })).to.equal(response2);
|
||||||
expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
|
expect(cache.get('anotherRequestId', { maxAge })).to.equal(response3);
|
||||||
// When
|
// When
|
||||||
cache.deleteMatching(/^requestId/);
|
cache.deleteMatching(/^requestId/);
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.be.undefined;
|
expect(cache.get('requestId1', { maxAge })).to.be.undefined;
|
||||||
expect(cache.get('requestId2', maxAge)).to.be.undefined;
|
expect(cache.get('requestId2', { maxAge })).to.be.undefined;
|
||||||
expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
|
expect(cache.get('anotherRequestId', { maxAge })).to.equal(response3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -181,16 +227,54 @@ describe('Cache', () => {
|
||||||
const response1 = 'response of request1';
|
const response1 = 'response of request1';
|
||||||
const response2 = 'response of request2';
|
const response2 = 'response of request2';
|
||||||
// When
|
// When
|
||||||
cache.set('requestId1', response1);
|
cache.set('requestId1', response1, { maxAge: 1 });
|
||||||
cache.set('requestId2', response2);
|
cache.set('requestId2', response2, { maxAge: 2 });
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.equal(response1);
|
expect(cache.get('requestId1', { maxAge })).to.equal(response1, 3);
|
||||||
expect(cache.get('requestId2', maxAge)).to.equal(response2);
|
expect(cache.get('requestId2', { maxAge })).to.equal(response2, 4);
|
||||||
// When
|
// When
|
||||||
cache.reset();
|
cache.reset();
|
||||||
// Then
|
// Then
|
||||||
expect(cache.get('requestId1', maxAge)).to.be.undefined;
|
expect(cache.get('requestId1', { maxAge })).to.be.undefined;
|
||||||
expect(cache.get('requestId2', maxAge)).to.be.undefined;
|
expect(cache.get('requestId2', { maxAge })).to.be.undefined;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cache.truncateTo', () => {
|
||||||
|
let cache;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
cache = new Cache();
|
||||||
|
|
||||||
|
cache.set('requestId1', 'response1', 123);
|
||||||
|
cache.set('requestId2', 'response2', 321);
|
||||||
|
cache.set('requestId3', 'response3', 111);
|
||||||
|
|
||||||
|
expect(cache._size).to.equal(555);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the oldest item if the cache is too large', () => {
|
||||||
|
cache.truncateTo(500);
|
||||||
|
|
||||||
|
expect(cache._size).to.equal(432);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes items until the specified size is reached', () => {
|
||||||
|
cache.truncateTo(200);
|
||||||
|
|
||||||
|
expect(cache._size).to.equal(111);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes everything if the size is smaller than the newest thing', () => {
|
||||||
|
cache.truncateTo(100);
|
||||||
|
|
||||||
|
expect(cache._size).to.equal(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when the size is larger than the current size', () => {
|
||||||
|
cache.truncateTo(600);
|
||||||
|
|
||||||
|
expect(cache._size).to.equal(555);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,7 @@ describe('cacheManager', () => {
|
||||||
invalidateUrlsRegex: invalidateUrlsRegexResult,
|
invalidateUrlsRegex: invalidateUrlsRegexResult,
|
||||||
contentTypes,
|
contentTypes,
|
||||||
maxResponseSize,
|
maxResponseSize,
|
||||||
|
maxCacheSize,
|
||||||
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
|
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
|
||||||
// Assert
|
// Assert
|
||||||
expect(useCache).to.be.false;
|
expect(useCache).to.be.false;
|
||||||
|
|
@ -86,6 +87,7 @@ describe('cacheManager', () => {
|
||||||
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
|
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
|
||||||
expect(contentTypes).to.be.undefined;
|
expect(contentTypes).to.be.undefined;
|
||||||
expect(maxResponseSize).to.be.undefined;
|
expect(maxResponseSize).to.be.undefined;
|
||||||
|
expect(maxCacheSize).to.be.undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
|
it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
|
||||||
|
|
@ -295,6 +297,26 @@ describe('cacheManager', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('the maxCacheSize property', () => {
|
||||||
|
it('accepts a finite number', () => {
|
||||||
|
expect(() => validateCacheOptions({ maxCacheSize: 42 })).not.to.throw;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts undefined', () => {
|
||||||
|
expect(() => validateCacheOptions({ maxCacheSize: undefined })).not.to.throw;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not accept anything else', () => {
|
||||||
|
// @ts-ignore
|
||||||
|
expect(() => validateCacheOptions({ maxCacheSize: 'string' })).to.throw(
|
||||||
|
'Property `maxCacheSize` must be a finite `number`',
|
||||||
|
);
|
||||||
|
expect(() => validateCacheOptions({ maxCacheSize: Infinity })).to.throw(
|
||||||
|
'Property `maxCacheSize` must be a finite `number`',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('invalidateMatchingCache', () => {
|
describe('invalidateMatchingCache', () => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import { Ajax } from '../../index.js';
|
||||||
import { extendCacheOptions, resetCacheSession, ajaxCache } from '../../src/cacheManager.js';
|
import { extendCacheOptions, resetCacheSession, ajaxCache } from '../../src/cacheManager.js';
|
||||||
import { createCacheInterceptors } from '../../src/interceptors/cacheInterceptors.js';
|
import { createCacheInterceptors } from '../../src/interceptors/cacheInterceptors.js';
|
||||||
|
|
||||||
|
const MOCK_RESPONSE = 'mock response';
|
||||||
|
|
||||||
|
const getUrl = (/** @type {string} */ url) => new URL(url, window.location.href).toString();
|
||||||
|
|
||||||
/** @type {Ajax} */
|
/** @type {Ajax} */
|
||||||
let ajax;
|
let ajax;
|
||||||
|
|
||||||
|
|
@ -53,7 +57,7 @@ describe('cache interceptors', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
ajax = new Ajax();
|
ajax = new Ajax();
|
||||||
mockResponse = new Response('mock response');
|
mockResponse = new Response(MOCK_RESPONSE);
|
||||||
fetchStub = sinon.stub(window, 'fetch');
|
fetchStub = sinon.stub(window, 'fetch');
|
||||||
fetchStub.resolves(mockResponse);
|
fetchStub.resolves(mockResponse);
|
||||||
ajaxRequestSpy = sinon.spy(ajax, 'fetch');
|
ajaxRequestSpy = sinon.spy(ajax, 'fetch');
|
||||||
|
|
@ -361,7 +365,7 @@ describe('cache interceptors', () => {
|
||||||
newCacheId();
|
newCacheId();
|
||||||
fetchStub.returns(
|
fetchStub.returns(
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
|
new Response(MOCK_RESPONSE, { status: 206, headers: { 'x-foo': 'x-bar' } }),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -654,7 +658,7 @@ describe('cache interceptors', () => {
|
||||||
const cacheOptions = {
|
const cacheOptions = {
|
||||||
invalidateUrls: [
|
invalidateUrls: [
|
||||||
requestIdFunction({
|
requestIdFunction({
|
||||||
url: new URL('/test-invalid-url', window.location.href).toString(),
|
url: getUrl('/test-invalid-url'),
|
||||||
params: { foo: 1, bar: 2 },
|
params: { foo: 1, bar: 2 },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|
@ -719,10 +723,39 @@ describe('cache interceptors', () => {
|
||||||
|
|
||||||
const firstResponse = await firstRequest;
|
const firstResponse = await firstRequest;
|
||||||
|
|
||||||
expect(firstResponse).to.equal('mock response');
|
expect(firstResponse).to.equal(MOCK_RESPONSE);
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
expect(ajaxCache._cachedRequests).to.deep.equal({});
|
expect(ajaxCache._cachedRequests).to.deep.equal({});
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when maxCacheSize is set', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
maxCacheSize: MOCK_RESPONSE.length * 3 + 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('discards the last added request when the cache is full', async () => {
|
||||||
|
// @ts-ignore use this private field
|
||||||
|
expect(ajaxCache._size).to.equal(0);
|
||||||
|
|
||||||
|
await ajax.fetch('/test1');
|
||||||
|
await ajax.fetch('/test2');
|
||||||
|
await ajax.fetch('/test3');
|
||||||
|
await ajax.fetch('/test4');
|
||||||
|
|
||||||
|
// @ts-ignore use this private field
|
||||||
|
expect(Object.keys(ajaxCache._cachedRequests)).to.deep.equal(
|
||||||
|
['/test2', '/test3', '/test4'].map(getUrl),
|
||||||
|
);
|
||||||
|
expect(fetchStub.callCount).to.equal(4);
|
||||||
|
// @ts-ignore use this private field
|
||||||
|
expect(ajaxCache._size).to.equal(MOCK_RESPONSE.length * 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
8
packages/ajax/types/types.d.ts
vendored
8
packages/ajax/types/types.d.ts
vendored
|
|
@ -42,6 +42,7 @@ export interface CacheOptions {
|
||||||
requestIdFunction?: RequestIdFunction;
|
requestIdFunction?: RequestIdFunction;
|
||||||
contentTypes?: string[];
|
contentTypes?: string[];
|
||||||
maxResponseSize?: number;
|
maxResponseSize?: number;
|
||||||
|
maxCacheSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheOptionsWithIdentifier extends CacheOptions {
|
export interface CacheOptionsWithIdentifier extends CacheOptions {
|
||||||
|
|
@ -72,13 +73,16 @@ export interface CacheResponseRequest {
|
||||||
|
|
||||||
export interface CacheResponseExtension {
|
export interface CacheResponseExtension {
|
||||||
request: CacheResponseRequest;
|
request: CacheResponseRequest;
|
||||||
data: object | string;
|
|
||||||
fromCache?: boolean;
|
fromCache?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CacheRequest = Request & Partial<CacheRequestExtension>;
|
export type CacheRequest = Request & Partial<CacheRequestExtension>;
|
||||||
|
|
||||||
export type CacheResponse = Response & Partial<CacheResponseExtension>;
|
export interface CacheResponse extends Response, CacheResponseExtension {
|
||||||
|
clone: () => CacheResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CachedRequests = { [requestId: string]: { createdAt: number, size: number, response: CacheResponse } };
|
||||||
|
|
||||||
export type CachedRequestInterceptor = (
|
export type CachedRequestInterceptor = (
|
||||||
request: CacheRequest,
|
request: CacheRequest,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue