diff --git a/.changeset/run-forrest-run.md b/.changeset/run-forrest-run.md new file mode 100644 index 000000000..acf0655cb --- /dev/null +++ b/.changeset/run-forrest-run.md @@ -0,0 +1,5 @@ +--- +'@lion/ajax': minor +--- + +Add a `maxCacheSize` cache option to specify a max size for the whole cache diff --git a/packages/ajax/src/Ajax.js b/packages/ajax/src/Ajax.js index e2a64fe4d..663a65b5a 100644 --- a/packages/ajax/src/Ajax.js +++ b/packages/ajax/src/Ajax.js @@ -207,7 +207,7 @@ export class Ajax { for (const intercept of this._responseInterceptors) { // In this instance we actually do want to await for each sequence // eslint-disable-next-line no-await-in-loop - interceptedResponse = await intercept(interceptedResponse); + interceptedResponse = await intercept(/** @type CacheResponse */ (interceptedResponse)); } return interceptedResponse; } diff --git a/packages/ajax/src/Cache.js b/packages/ajax/src/Cache.js index 4f64a7011..2aa5bea0f 100644 --- a/packages/ajax/src/Cache.js +++ b/packages/ajax/src/Cache.js @@ -3,40 +3,72 @@ import './typedef.js'; export default class Cache { constructor() { /** - * @type {{ [requestId: string]: { createdAt: number, response: CacheResponse } }} + * @type CachedRequests * @private */ this._cachedRequests = {}; + + /** + * @type {number} + * @private + */ + this._size = 0; } /** * Store an item in the cache * @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] = { createdAt: Date.now(), + size, response, }; + + this._size += size; } /** * Retrieve an item from the cache * @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} */ - get(requestId, maxAge = 0) { + get(requestId, { maxAge = Infinity, maxResponseSize = Infinity } = {}) { + const isNumber = (/** @type any */ num) => Number(num) === num; + const cachedRequest = this._cachedRequests[requestId]; if (!cachedRequest) { - return; + return undefined; } - const cachedRequestAge = Date.now() - cachedRequest.createdAt; - if (Number.isFinite(maxAge) && cachedRequestAge < maxAge) { - // eslint-disable-next-line consistent-return - return cachedRequest.response; + + // maxAge and maxResponseSize should both be numbers + if (!isNumber(maxAge)) { + 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 */ delete(requestId) { + const cachedRequest = this._cachedRequests[requestId]; + + if (!cachedRequest) { + return; + } + + this._size -= cachedRequest.size; 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() { this._cachedRequests = {}; + this._size = 0; } } diff --git a/packages/ajax/src/cacheManager.js b/packages/ajax/src/cacheManager.js index 933287b4a..557cb5698 100644 --- a/packages/ajax/src/cacheManager.js +++ b/packages/ajax/src/cacheManager.js @@ -84,6 +84,7 @@ export const extendCacheOptions = ({ invalidateUrlsRegex, contentTypes, maxResponseSize, + maxCacheSize, }) => ({ useCache, methods, @@ -93,6 +94,7 @@ export const extendCacheOptions = ({ invalidateUrlsRegex, contentTypes, maxResponseSize, + maxCacheSize, }); /** @@ -107,6 +109,7 @@ export const validateCacheOptions = ({ invalidateUrlsRegex, contentTypes, maxResponseSize, + maxCacheSize, } = {}) => { if (useCache !== undefined && typeof useCache !== 'boolean') { throw new Error('Property `useCache` must be a `boolean`'); @@ -132,6 +135,9 @@ export const validateCacheOptions = ({ if (maxResponseSize !== undefined && !Number.isFinite(maxResponseSize)) { 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`'); + } }; /** diff --git a/packages/ajax/src/interceptors/cacheInterceptors.js b/packages/ajax/src/interceptors/cacheInterceptors.js index bf7f3df28..51430433a 100644 --- a/packages/ajax/src/interceptors/cacheInterceptors.js +++ b/packages/ajax/src/interceptors/cacheInterceptors.js @@ -2,44 +2,48 @@ import '../typedef.js'; import { ajaxCache, - resetCacheSession, extendCacheOptions, - validateCacheOptions, invalidateMatchingCache, - pendingRequestStore, isCurrentSessionId, + pendingRequestStore, + resetCacheSession, + validateCacheOptions, } from '../cacheManager.js'; /** * Tests whether the request method is supported according to the `cacheOptions` - * @param {ValidatedCacheOptions} cacheOptions + * @param {string[]} methods * @param {string} method * @returns {boolean} */ -const isMethodSupported = (cacheOptions, method) => - cacheOptions.methods.includes(method.toLowerCase()); +const isMethodSupported = (methods, method) => methods.includes(method.toLowerCase()); /** * Tests whether the response content type is supported by the `contentTypes` whitelist * @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 */ -const isResponseContentTypeSupported = (response, { contentTypes } = {}) => { +const isResponseContentTypeSupported = (response, contentTypes) => { if (!Array.isArray(contentTypes)) return true; 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 {CacheOptions} cacheOptions - * @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 + * @returns {Promise} */ -const isResponseSizeSupported = (response, { maxResponseSize } = {}) => { - const responseSize = +(response.headers.get('Content-Length') || 0); +const getResponseSize = async response => + 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 (!responseSize) return true; @@ -63,17 +67,20 @@ const createCacheRequestInterceptor = ...request.cacheOptions, }); + const { useCache, requestIdFunction, methods, contentTypes, maxAge, maxResponseSize } = + cacheOptions; + // store cacheOptions and cacheSessionId in the request, to use it in the response interceptor. request.cacheOptions = cacheOptions; request.cacheSessionId = cacheSessionId; - if (!cacheOptions.useCache) { + if (!useCache) { return request; } - const requestId = cacheOptions.requestIdFunction(request); + const requestId = requestIdFunction(request); - if (!isMethodSupported(cacheOptions, request.method)) { + if (!isMethodSupported(methods, request.method)) { invalidateMatchingCache(requestId, cacheOptions); return request; } @@ -84,15 +91,11 @@ const createCacheRequestInterceptor = await pendingRequest; } - const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge); - if ( - cachedResponse && - isResponseContentTypeSupported(cachedResponse, cacheOptions) && - isResponseSizeSupported(cachedResponse, cacheOptions) - ) { + const cachedResponse = ajaxCache.get(requestId, { maxAge, maxResponseSize }); + if (cachedResponse && isResponseContentTypeSupported(cachedResponse, contentTypes)) { // Return the response from cache request.cacheOptions = request.cacheOptions ?? { useCache: false }; - /** @type {CacheResponse} */ + const response = cachedResponse.clone(); response.request = request; response.fromCache = true; @@ -109,35 +112,43 @@ const createCacheRequestInterceptor = * @param {CacheOptions} globalCacheOptions * @returns {ResponseInterceptor} */ -const createCacheResponseInterceptor = - globalCacheOptions => /** @param {CacheResponse} response */ async response => { - if (!response.request) { - throw new Error('Missing request in response'); - } +const createCacheResponseInterceptor = globalCacheOptions => async responseParam => { + const response = /** @type {CacheResponse} */ (responseParam); - const cacheOptions = extendCacheOptions({ + if (!response.request) { + throw new Error('Missing request in response'); + } + + const { requestIdFunction, methods, contentTypes, maxResponseSize, maxCacheSize } = + extendCacheOptions({ ...globalCacheOptions, ...response.request.cacheOptions, }); - if (!response.fromCache && isMethodSupported(cacheOptions, response.request.method)) { - const requestId = cacheOptions.requestIdFunction(response.request); + if (!response.fromCache && isMethodSupported(methods, response.request.method)) { + const requestId = requestIdFunction(response.request); + const responseSize = maxCacheSize || maxResponseSize ? await getResponseSize(response) : 0; - if ( - isCurrentSessionId(response.request.cacheSessionId) && - isResponseContentTypeSupported(response, cacheOptions) && - isResponseSizeSupported(response, cacheOptions) - ) { - // Cache the response - ajaxCache.set(requestId, response.clone()); + if ( + isCurrentSessionId(response.request.cacheSessionId) && + isResponseContentTypeSupported(response, contentTypes) && + isResponseSizeSupported(responseSize, maxResponseSize) + ) { + // Cache the response + 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 diff --git a/packages/ajax/src/typedef.js b/packages/ajax/src/typedef.js index 62c31af80..d1d5721bf 100644 --- a/packages/ajax/src/typedef.js +++ b/packages/ajax/src/typedef.js @@ -13,6 +13,7 @@ * @typedef {import('../types/types').CacheResponseRequest} CacheResponseRequest * @typedef {import('../types/types').CacheRequest} CacheRequest * @typedef {import('../types/types').CacheResponse} CacheResponse + * @typedef {import('../types/types').CachedRequests} CachedRequests * @typedef {import('../types/types').CachedRequestInterceptor} CachedRequestInterceptor * @typedef {import('../types/types').CachedResponseInterceptor} CachedResponseInterceptor */ diff --git a/packages/ajax/test/Ajax.test.js b/packages/ajax/test/Ajax.test.js index 8679e2955..1ab548f29 100644 --- a/packages/ajax/test/Ajax.test.js +++ b/packages/ajax/test/Ajax.test.js @@ -71,10 +71,23 @@ describe('Ajax', () => { // When // @ts-expect-error const ajax1 = new Ajax(config); - const result = ajax1.options?.cacheOptions?.getCacheIdentifier; + const defaultCacheIdentifierFunction = ajax1.options?.cacheOptions?.getCacheIdentifier; // Then - expect(result).not.to.be.undefined; - expect(result).to.be.a('function'); + expect(defaultCacheIdentifierFunction).not.to.be.undefined; + 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 }); }); }); + + 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', () => { diff --git a/packages/ajax/test/Cache.test.js b/packages/ajax/test/Cache.test.js index 7e27fb77c..f5100445a 100644 --- a/packages/ajax/test/Cache.test.js +++ b/packages/ajax/test/Cache.test.js @@ -32,8 +32,12 @@ describe('Cache', () => { const cache = new Cache(); cache._cachedRequests = { - requestId1: { createdAt: Date.now() - TWO_MINUTES_IN_MS, response: 'cached data 1' }, - requestId2: { createdAt: Date.now(), response: 'cached data 2' }, + requestId1: { + 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', () => { @@ -41,7 +45,7 @@ describe('Cache', () => { const maxAge = TEN_MINUTES_IN_MS; const expected = undefined; // When - const result = cache.get('nonCachedRequestId', maxAge); + const result = cache.get('nonCachedRequestId', { maxAge }); // Then expect(result).to.equal(expected); }); @@ -51,17 +55,7 @@ describe('Cache', () => { const maxAge = 'some string'; const expected = undefined; // When - 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); + const result = cache.get('requestId1', { maxAge }); // Then expect(result).to.equal(expected); }); @@ -71,7 +65,7 @@ describe('Cache', () => { const maxAge = -10; const expected = undefined; // When - const result = cache.get('requestId1', maxAge); + const result = cache.get('requestId1', { maxAge }); // Then expect(result).to.equal(expected); }); @@ -81,7 +75,7 @@ describe('Cache', () => { const maxAge = A_MINUTE_IN_MS; const expected = undefined; // When - const result = cache.get('requestId1', maxAge); + const result = cache.get('requestId1', { maxAge }); // Then expect(result).to.equal(expected); }); @@ -91,7 +85,59 @@ describe('Cache', () => { const maxAge = TEN_MINUTES_IN_MS; const expected = cache._cachedRequests?.requestId1?.response; // 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 expect(result).to.deep.equal(expected); }); @@ -108,8 +154,8 @@ describe('Cache', () => { cache.set('requestId1', response1); cache.set('requestId2', response2); // Then - expect(cache.get('requestId1', maxAge)).to.equal(response1); - expect(cache.get('requestId2', maxAge)).to.equal(response2); + expect(cache.get('requestId1', { maxAge })).to.equal(response1); + expect(cache.get('requestId2', { maxAge })).to.equal(response2); }); it('updates the `response` for the given `requestId`, if already cached', () => { @@ -121,11 +167,11 @@ describe('Cache', () => { // When cache.set('requestId1', response); // Then - expect(cache.get('requestId1', maxAge)).to.equal(response); + expect(cache.get('requestId1', { maxAge })).to.equal(response); // When cache.set('requestId1', updatedResponse); // 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('requestId2', response2); // Then - expect(cache.get('requestId1', maxAge)).to.equal(response1); - expect(cache.get('requestId2', maxAge)).to.equal(response2); + expect(cache.get('requestId1', { maxAge })).to.equal(response1); + expect(cache.get('requestId2', { maxAge })).to.equal(response2); // When cache.delete('requestId1'); // Then - expect(cache.get('requestId1', maxAge)).to.be.undefined; - expect(cache.get('requestId2', maxAge)).to.equal(response2); + expect(cache.get('requestId1', { maxAge })).to.be.undefined; + expect(cache.get('requestId2', { maxAge })).to.equal(response2); }); it('deletes cache by regex', () => { @@ -161,15 +207,15 @@ describe('Cache', () => { cache.set('requestId2', response2); cache.set('anotherRequestId', response3); // Then - expect(cache.get('requestId1', maxAge)).to.equal(response1); - expect(cache.get('requestId2', maxAge)).to.equal(response2); - expect(cache.get('anotherRequestId', maxAge)).to.equal(response3); + expect(cache.get('requestId1', { maxAge })).to.equal(response1); + expect(cache.get('requestId2', { maxAge })).to.equal(response2); + expect(cache.get('anotherRequestId', { maxAge })).to.equal(response3); // When cache.deleteMatching(/^requestId/); // Then - expect(cache.get('requestId1', maxAge)).to.be.undefined; - expect(cache.get('requestId2', maxAge)).to.be.undefined; - expect(cache.get('anotherRequestId', maxAge)).to.equal(response3); + expect(cache.get('requestId1', { maxAge })).to.be.undefined; + expect(cache.get('requestId2', { maxAge })).to.be.undefined; + expect(cache.get('anotherRequestId', { maxAge })).to.equal(response3); }); }); @@ -181,16 +227,54 @@ describe('Cache', () => { const response1 = 'response of request1'; const response2 = 'response of request2'; // When - cache.set('requestId1', response1); - cache.set('requestId2', response2); + cache.set('requestId1', response1, { maxAge: 1 }); + cache.set('requestId2', response2, { maxAge: 2 }); // Then - expect(cache.get('requestId1', maxAge)).to.equal(response1); - expect(cache.get('requestId2', maxAge)).to.equal(response2); + expect(cache.get('requestId1', { maxAge })).to.equal(response1, 3); + expect(cache.get('requestId2', { maxAge })).to.equal(response2, 4); // When cache.reset(); // Then - expect(cache.get('requestId1', maxAge)).to.be.undefined; - expect(cache.get('requestId2', maxAge)).to.be.undefined; + expect(cache.get('requestId1', { 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); }); }); }); diff --git a/packages/ajax/test/cacheManager.test.js b/packages/ajax/test/cacheManager.test.js index 2b00e0260..0f84a7bfc 100644 --- a/packages/ajax/test/cacheManager.test.js +++ b/packages/ajax/test/cacheManager.test.js @@ -76,6 +76,7 @@ describe('cacheManager', () => { invalidateUrlsRegex: invalidateUrlsRegexResult, contentTypes, maxResponseSize, + maxCacheSize, } = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex }); // Assert expect(useCache).to.be.false; @@ -86,6 +87,7 @@ describe('cacheManager', () => { expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex); expect(contentTypes).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', () => { @@ -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', () => { diff --git a/packages/ajax/test/interceptors/cacheInterceptors.test.js b/packages/ajax/test/interceptors/cacheInterceptors.test.js index 8d38bab87..8c14c3cda 100644 --- a/packages/ajax/test/interceptors/cacheInterceptors.test.js +++ b/packages/ajax/test/interceptors/cacheInterceptors.test.js @@ -5,6 +5,10 @@ import { Ajax } from '../../index.js'; import { extendCacheOptions, resetCacheSession, ajaxCache } from '../../src/cacheManager.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} */ let ajax; @@ -53,7 +57,7 @@ describe('cache interceptors', () => { beforeEach(() => { ajax = new Ajax(); - mockResponse = new Response('mock response'); + mockResponse = new Response(MOCK_RESPONSE); fetchStub = sinon.stub(window, 'fetch'); fetchStub.resolves(mockResponse); ajaxRequestSpy = sinon.spy(ajax, 'fetch'); @@ -361,7 +365,7 @@ describe('cache interceptors', () => { newCacheId(); fetchStub.returns( 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 = { invalidateUrls: [ requestIdFunction({ - url: new URL('/test-invalid-url', window.location.href).toString(), + url: getUrl('/test-invalid-url'), params: { foo: 1, bar: 2 }, }), ], @@ -719,10 +723,39 @@ describe('cache interceptors', () => { const firstResponse = await firstRequest; - expect(firstResponse).to.equal('mock response'); + expect(firstResponse).to.equal(MOCK_RESPONSE); // @ts-ignore expect(ajaxCache._cachedRequests).to.deep.equal({}); 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); + }); + }); }); }); diff --git a/packages/ajax/types/types.d.ts b/packages/ajax/types/types.d.ts index ee1125839..c4b3ec36b 100644 --- a/packages/ajax/types/types.d.ts +++ b/packages/ajax/types/types.d.ts @@ -42,6 +42,7 @@ export interface CacheOptions { requestIdFunction?: RequestIdFunction; contentTypes?: string[]; maxResponseSize?: number; + maxCacheSize?: number; } export interface CacheOptionsWithIdentifier extends CacheOptions { @@ -72,13 +73,16 @@ export interface CacheResponseRequest { export interface CacheResponseExtension { request: CacheResponseRequest; - data: object | string; fromCache?: boolean; } export type CacheRequest = Request & Partial; -export type CacheResponse = Response & Partial; +export interface CacheResponse extends Response, CacheResponseExtension { + clone: () => CacheResponse; +} + +export type CachedRequests = { [requestId: string]: { createdAt: number, size: number, response: CacheResponse } }; export type CachedRequestInterceptor = ( request: CacheRequest,