feat(ajax): add a maxResponseSize cache option to specify a max size for responses to be cached
This commit is contained in:
parent
2107cfb0c3
commit
558edcb686
7 changed files with 153 additions and 3 deletions
5
.changeset/good-berries-watch.md
Normal file
5
.changeset/good-berries-watch.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
'@lion/ajax': minor
|
||||
---
|
||||
|
||||
Adds a `maxResponseSize` cache option to specify a max size for responses to be cached. The option prevents caching and cache retrieval for responses that are larger than the given maximum as reported in the `Content-Length` header. If this header is missing nothing happens, that is to say caching is not prevented.
|
||||
|
|
@ -2,4 +2,4 @@
|
|||
'@lion/ajax': minor
|
||||
---
|
||||
|
||||
Add cache filter for content types
|
||||
Adds a `contentTypes` cache option to specify a whitelist of content types to be cached. The option prevents caching and cache retrieval for responses that do not have one of these values in the `Content-Type` header.
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ export const extendCacheOptions = ({
|
|||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
contentTypes,
|
||||
maxResponseSize,
|
||||
}) => ({
|
||||
useCache,
|
||||
methods,
|
||||
|
|
@ -91,6 +92,7 @@ export const extendCacheOptions = ({
|
|||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
contentTypes,
|
||||
maxResponseSize,
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
@ -104,6 +106,7 @@ export const validateCacheOptions = ({
|
|||
invalidateUrls,
|
||||
invalidateUrlsRegex,
|
||||
contentTypes,
|
||||
maxResponseSize,
|
||||
} = {}) => {
|
||||
if (useCache !== undefined && typeof useCache !== 'boolean') {
|
||||
throw new Error('Property `useCache` must be a `boolean`');
|
||||
|
|
@ -126,6 +129,9 @@ export const validateCacheOptions = ({
|
|||
if (contentTypes !== undefined && !Array.isArray(contentTypes)) {
|
||||
throw new Error('Property `contentTypes` must be an `Array` or `undefined`');
|
||||
}
|
||||
if (maxResponseSize !== undefined && !Number.isFinite(maxResponseSize)) {
|
||||
throw new Error('Property `maxResponseSize` must be a finite `number`');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -31,6 +31,21 @@ const isResponseContentTypeSupported = (response, { contentTypes } = {}) => {
|
|||
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
|
||||
*/
|
||||
const isResponseSizeSupported = (response, { maxResponseSize } = {}) => {
|
||||
const responseSize = +(response.headers.get('Content-Length') || 0);
|
||||
|
||||
if (!maxResponseSize) return true;
|
||||
if (!responseSize) return true;
|
||||
|
||||
return responseSize <= maxResponseSize;
|
||||
};
|
||||
|
||||
/**
|
||||
* Request interceptor to return relevant cached requests
|
||||
* @param {function(): string} getCacheId used to invalidate cache if identifier is changed
|
||||
|
|
@ -70,7 +85,11 @@ const createCacheRequestInterceptor =
|
|||
}
|
||||
|
||||
const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge);
|
||||
if (cachedResponse && isResponseContentTypeSupported(cachedResponse, cacheOptions)) {
|
||||
if (
|
||||
cachedResponse &&
|
||||
isResponseContentTypeSupported(cachedResponse, cacheOptions) &&
|
||||
isResponseSizeSupported(cachedResponse, cacheOptions)
|
||||
) {
|
||||
// Return the response from cache
|
||||
request.cacheOptions = request.cacheOptions ?? { useCache: false };
|
||||
/** @type {CacheResponse} */
|
||||
|
|
@ -106,7 +125,8 @@ const createCacheResponseInterceptor =
|
|||
|
||||
if (
|
||||
isCurrentSessionId(response.request.cacheSessionId) &&
|
||||
isResponseContentTypeSupported(response, cacheOptions)
|
||||
isResponseContentTypeSupported(response, cacheOptions) &&
|
||||
isResponseSizeSupported(response, cacheOptions)
|
||||
) {
|
||||
// Cache the response
|
||||
ajaxCache.set(requestId, response.clone());
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ describe('cacheManager', () => {
|
|||
invalidateUrls: invalidateUrlsResult,
|
||||
invalidateUrlsRegex: invalidateUrlsRegexResult,
|
||||
contentTypes,
|
||||
maxResponseSize,
|
||||
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
|
||||
// Assert
|
||||
expect(useCache).to.be.false;
|
||||
|
|
@ -84,6 +85,7 @@ describe('cacheManager', () => {
|
|||
expect(invalidateUrlsResult).to.equal(invalidateUrls);
|
||||
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
|
||||
expect(contentTypes).to.be.undefined;
|
||||
expect(maxResponseSize).to.be.undefined;
|
||||
});
|
||||
|
||||
it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
|
||||
|
|
@ -131,18 +133,22 @@ describe('cacheManager', () => {
|
|||
it('does not accept null as argument', () => {
|
||||
expect(() => validateCacheOptions(null)).to.throw(TypeError);
|
||||
});
|
||||
|
||||
it('accepts an empty object', () => {
|
||||
expect(() => validateCacheOptions({})).not.to.throw(
|
||||
'Property `useCache` must be a `boolean`',
|
||||
);
|
||||
});
|
||||
|
||||
describe('the useCache property', () => {
|
||||
it('accepts a boolean', () => {
|
||||
expect(() => validateCacheOptions({ useCache: false })).not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ useCache: '' })).to.throw(
|
||||
|
|
@ -150,13 +156,16 @@ describe('cacheManager', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the methods property', () => {
|
||||
it('accepts an array with the value `get`', () => {
|
||||
expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ methods: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
expect(() => validateCacheOptions({ methods: [] })).to.throw(
|
||||
'Cache can only be utilized with `GET` method',
|
||||
|
|
@ -169,13 +178,16 @@ describe('cacheManager', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the maxAge property', () => {
|
||||
it('accepts a finite number', () => {
|
||||
expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw(
|
||||
|
|
@ -186,6 +198,7 @@ describe('cacheManager', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the invalidateUrls property', () => {
|
||||
it('accepts an array', () => {
|
||||
// @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions
|
||||
|
|
@ -193,9 +206,11 @@ describe('cacheManager', () => {
|
|||
validateCacheOptions({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }),
|
||||
).not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw(
|
||||
|
|
@ -203,14 +218,17 @@ describe('cacheManager', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the invalidateUrlsRegex property', () => {
|
||||
it('accepts a regular expression', () => {
|
||||
expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ }))
|
||||
.not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() =>
|
||||
|
|
@ -218,6 +236,7 @@ describe('cacheManager', () => {
|
|||
).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the requestIdFunction property', () => {
|
||||
it('accepts a function', () => {
|
||||
// @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions
|
||||
|
|
@ -225,9 +244,11 @@ describe('cacheManager', () => {
|
|||
validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }),
|
||||
).not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw(
|
||||
|
|
@ -235,15 +256,18 @@ describe('cacheManager', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the contentTypes property', () => {
|
||||
it('accepts an array', () => {
|
||||
// @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions
|
||||
expect(() => validateCacheOptions({ contentTypes: [6, 'elements', 'in', 1, true, Array] }))
|
||||
.not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ contentTypes: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ contentTypes: 'not-an-array' })).to.throw(
|
||||
|
|
@ -251,6 +275,26 @@ describe('cacheManager', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the maxResponseSize property', () => {
|
||||
it('accepts a finite number', () => {
|
||||
expect(() => validateCacheOptions({ maxResponseSize: 42 })).not.to.throw;
|
||||
});
|
||||
|
||||
it('accepts undefined', () => {
|
||||
expect(() => validateCacheOptions({ maxResponseSize: undefined })).not.to.throw;
|
||||
});
|
||||
|
||||
it('does not accept anything else', () => {
|
||||
// @ts-ignore
|
||||
expect(() => validateCacheOptions({ maxResponseSize: 'string' })).to.throw(
|
||||
'Property `maxResponseSize` must be a finite `number`',
|
||||
);
|
||||
expect(() => validateCacheOptions({ maxResponseSize: Infinity })).to.throw(
|
||||
'Property `maxResponseSize` must be a finite `number`',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidateMatchingCache', () => {
|
||||
|
|
|
|||
|
|
@ -395,6 +395,40 @@ describe('cache interceptors', () => {
|
|||
// Then
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
});
|
||||
|
||||
it('does save to the cache when `maxResponseSize` is specified and the response size is within the threshold', async () => {
|
||||
// Given
|
||||
newCacheId();
|
||||
mockResponse.headers.set('content-length', '20000');
|
||||
addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
maxResponseSize: 50000,
|
||||
});
|
||||
|
||||
// When
|
||||
await ajax.fetch('/test');
|
||||
await ajax.fetch('/test');
|
||||
|
||||
// Then
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
});
|
||||
|
||||
it('does save to the cache when `maxResponseSize` is specified and the response size is unknown', async () => {
|
||||
// Given
|
||||
newCacheId();
|
||||
|
||||
addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
maxResponseSize: 50000,
|
||||
});
|
||||
|
||||
// When
|
||||
await ajax.fetch('/test');
|
||||
await ajax.fetch('/test');
|
||||
|
||||
// Then
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bypassing the cache', () => {
|
||||
|
|
@ -481,6 +515,46 @@ describe('cache interceptors', () => {
|
|||
// Then
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
});
|
||||
|
||||
it('does not save to the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => {
|
||||
// Given
|
||||
newCacheId();
|
||||
mockResponse.headers.set('content-length', '80000');
|
||||
addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
maxResponseSize: 50000,
|
||||
});
|
||||
|
||||
// When
|
||||
await ajax.fetch('/test');
|
||||
await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 100000 } });
|
||||
|
||||
// Then
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
});
|
||||
|
||||
it('does not read from the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => {
|
||||
// Given
|
||||
newCacheId();
|
||||
mockResponse.headers.set('content-length', '80000');
|
||||
addCacheInterceptors(ajax, {
|
||||
useCache: true,
|
||||
maxResponseSize: 100000,
|
||||
});
|
||||
|
||||
// When
|
||||
await ajax.fetch('/test');
|
||||
await ajax.fetch('/test');
|
||||
|
||||
// Then
|
||||
expect(fetchStub.callCount).to.equal(1);
|
||||
|
||||
// When
|
||||
await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 50000 } });
|
||||
|
||||
// Then
|
||||
expect(fetchStub.callCount).to.equal(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache invalidation', () => {
|
||||
|
|
|
|||
1
packages/ajax/types/types.d.ts
vendored
1
packages/ajax/types/types.d.ts
vendored
|
|
@ -41,6 +41,7 @@ export interface CacheOptions {
|
|||
invalidateUrlsRegex?: RegExp;
|
||||
requestIdFunction?: RequestIdFunction;
|
||||
contentTypes?: string[];
|
||||
maxResponseSize?: number;
|
||||
}
|
||||
|
||||
export interface CacheOptionsWithIdentifier extends CacheOptions {
|
||||
|
|
|
|||
Loading…
Reference in a new issue