feat(ajax): add maxCacheSize option

This commit is contained in:
Martin Pool 2022-05-13 18:55:50 +02:00 committed by Ahmet Yeşil
parent 915de370d7
commit 447383bd14
11 changed files with 355 additions and 100 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ajax': minor
---
Add a `maxCacheSize` cache option to specify a max size for the whole cache

View file

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

View file

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

View file

@ -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`');
}
}; };
/** /**

View file

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

View file

@ -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
*/ */

View file

@ -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', () => {

View file

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

View file

@ -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', () => {

View file

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

View file

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