[must-revalidate] properly revalidate based on eTag (#824)

* [must-revalidate] implement logic to properly revalidate requests with must-revalidate cache-control header

* [lint] fix sorting of imports

* [pull 824] apply feedback

---------

Co-authored-by: Edwin Veldhuizen <edwin@pxr.nl>
This commit is contained in:
Edwin Veldhuizen 2024-04-23 18:06:04 +02:00 committed by GitHub
parent b8f30b535f
commit 6cba59cc1c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 102 additions and 62 deletions

4
src/cache/cache.ts vendored
View File

@ -207,7 +207,9 @@ export interface CacheProperties<R = unknown, D = unknown> {
| undefined | undefined
| (( | ((
cache: cache:
| (LoadingStorageValue & { previous: 'stale' }) | (LoadingStorageValue & {
previous: 'stale' | 'must-revalidate';
})
| CachedStorageValue | CachedStorageValue
| StaleStorageValue | StaleStorageValue
) => void | Promise<void>); ) => void | Promise<void>);

View File

@ -85,7 +85,12 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
// Not cached, continue the request, and mark it as fetching // Not cached, continue the request, and mark it as fetching
// biome-ignore lint/suspicious/noConfusingLabels: required to break condition in simultaneous accesses // biome-ignore lint/suspicious/noConfusingLabels: required to break condition in simultaneous accesses
ignoreAndRequest: if (cache.state === 'empty' || cache.state === 'stale' || overrideCache) { ignoreAndRequest: if (
cache.state === 'empty' ||
cache.state === 'stale' ||
cache.state === 'must-revalidate' ||
overrideCache
) {
// This checks for simultaneous access to a new key. The js event loop jumps on the // This checks for simultaneous access to a new key. The js event loop jumps on the
// first await statement, so the second (asynchronous call) request may have already // first await statement, so the second (asynchronous call) request may have already
// started executing. // started executing.
@ -98,7 +103,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
// say by a `axios.storage.delete(key)` and has a concurrent loading request. // say by a `axios.storage.delete(key)` and has a concurrent loading request.
// Because in this case, the cache will be empty and may still has a pending key // Because in this case, the cache will be empty and may still has a pending key
// on waiting map. // on waiting map.
if (cache.state !== 'empty') { if (cache.state !== 'empty' && cache.state !== 'must-revalidate') {
if (__ACI_DEV__) { if (__ACI_DEV__) {
axios.debug({ axios.debug({
id: config.id, id: config.id,
@ -128,7 +133,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
? 'stale' ? 'stale'
: 'empty' : 'empty'
: // Typescript doesn't know that cache.state here can only be 'empty' or 'stale' : // Typescript doesn't know that cache.state here can only be 'empty' or 'stale'
(cache.state as 'stale'), (cache.state as 'stale' | 'must-revalidate'),
data: cache.data as any, data: cache.data as any,
@ -138,7 +143,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
config config
); );
if (cache.state === 'stale') { if (cache.state === 'stale' || cache.state === 'must-revalidate') {
updateStaleRequest(cache, config as ConfigWithCache<unknown>); updateStaleRequest(cache, config as ConfigWithCache<unknown>);
if (__ACI_DEV__) { if (__ACI_DEV__) {
@ -163,7 +168,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
} }
// Hydrates any UI temporarily, if cache is available // Hydrates any UI temporarily, if cache is available
if (cache.state === 'stale' || cache.data) { if (cache.state === 'stale' || (cache.data && cache.state !== 'must-revalidate')) {
await config.cache.hydrate?.(cache); await config.cache.hydrate?.(cache);
} }

View File

@ -2,7 +2,11 @@ import type { Method } from 'axios';
import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios.js'; import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios.js';
import type { CacheProperties } from '../cache/cache.js'; import type { CacheProperties } from '../cache/cache.js';
import { Header } from '../header/headers.js'; import { Header } from '../header/headers.js';
import type { CachedResponse, StaleStorageValue } from '../storage/types.js'; import type {
CachedResponse,
MustRevalidateStorageValue,
StaleStorageValue
} from '../storage/types.js';
/** /**
* Creates a new validateStatus function that will use the one already used and also * Creates a new validateStatus function that will use the one already used and also
@ -33,7 +37,10 @@ export interface ConfigWithCache<D> extends CacheRequestConfig<unknown, D> {
* This function updates the cache when the request is stale. So, the next request to the * This function updates the cache when the request is stale. So, the next request to the
* server will be made with proper header / settings. * server will be made with proper header / settings.
*/ */
export function updateStaleRequest<D>(cache: StaleStorageValue, config: ConfigWithCache<D>): void { export function updateStaleRequest<D>(
cache: StaleStorageValue | MustRevalidateStorageValue,
config: ConfigWithCache<D>
): void {
config.headers ||= {}; config.headers ||= {};
const { etag, modifiedSince } = config.cache; const { etag, modifiedSince } = config.cache;

View File

@ -18,17 +18,15 @@ function hasUniqueIdentifierHeader(value: CachedStorageValue | StaleStorageValue
); );
} }
/** Returns true if value must be revalidated */
export function mustRevalidate(value: CachedStorageValue | StaleStorageValue): boolean {
// Must revalidate is a special case and should not serve stale values
// We could use cache-control's parse function, but this is way faster and simpler
return String(value.data.headers[Header.CacheControl]).includes('must-revalidate');
}
/** Returns true if this has sufficient properties to stale instead of expire. */ /** Returns true if this has sufficient properties to stale instead of expire. */
export function canStale(value: CachedStorageValue): boolean { export function canStale(value: CachedStorageValue): boolean {
// Must revalidate is a special case and should not be staled
if (
String(value.data.headers[Header.CacheControl])
// We could use cache-control's parse function, but this is way faster and simpler
.includes('must-revalidate')
) {
return false;
}
if (hasUniqueIdentifierHeader(value)) { if (hasUniqueIdentifierHeader(value)) {
return true; return true;
} }
@ -48,7 +46,7 @@ export function canStale(value: CachedStorageValue): boolean {
/** /**
* Checks if the provided cache is expired. You should also check if the cache * Checks if the provided cache is expired. You should also check if the cache
* {@link canStale} * {@link canStale} and {@link mayUseStale}
*/ */
export function isExpired(value: CachedStorageValue | StaleStorageValue): boolean { export function isExpired(value: CachedStorageValue | StaleStorageValue): boolean {
return value.ttl !== undefined && value.createdAt + value.ttl <= Date.now(); return value.ttl !== undefined && value.createdAt + value.ttl <= Date.now();
@ -105,7 +103,11 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage
return { state: 'empty' }; return { state: 'empty' };
} }
if (value.state === 'empty' || value.state === 'loading') { if (
value.state === 'empty' ||
value.state === 'loading' ||
value.state === 'must-revalidate'
) {
return value; return value;
} }
@ -129,6 +131,11 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage
}; };
await set(key, value, config); await set(key, value, config);
// Must revalidate is a special case and should not serve stale values
if (mustRevalidate(value)) {
return { ...value, state: 'must-revalidate' };
}
} }
// A second check in case the new stale value was created already expired. // A second check in case the new stale value was created already expired.

View File

@ -13,7 +13,8 @@ export type StorageValue =
| StaleStorageValue | StaleStorageValue
| CachedStorageValue | CachedStorageValue
| LoadingStorageValue | LoadingStorageValue
| EmptyStorageValue; | EmptyStorageValue
| MustRevalidateStorageValue;
export type NotEmptyStorageValue = Exclude<StorageValue, EmptyStorageValue>; export type NotEmptyStorageValue = Exclude<StorageValue, EmptyStorageValue>;
@ -25,6 +26,14 @@ export interface StaleStorageValue {
state: 'stale'; state: 'stale';
} }
export interface MustRevalidateStorageValue {
data: CachedResponse;
ttl?: number;
staleTtl?: undefined;
createdAt: number;
state: 'must-revalidate';
}
export interface CachedStorageValue { export interface CachedStorageValue {
data: CachedResponse; data: CachedResponse;
/** /**
@ -37,7 +46,10 @@ export interface CachedStorageValue {
state: 'cached'; state: 'cached';
} }
export type LoadingStorageValue = LoadingEmptiedStorageValue | LoadingStaledStorageValue; export type LoadingStorageValue =
| LoadingEmptiedStorageValue
| LoadingStaledStorageValue
| LoadingRevalidateStorageValue;
export interface LoadingEmptiedStorageValue { export interface LoadingEmptiedStorageValue {
data?: undefined; data?: undefined;
@ -57,6 +69,15 @@ export interface LoadingStaledStorageValue {
previous: 'stale'; previous: 'stale';
} }
export interface LoadingRevalidateStorageValue {
state: 'loading';
data: CachedResponse;
ttl?: undefined;
staleTtl?: undefined;
createdAt: number;
previous: 'must-revalidate';
}
export interface EmptyStorageValue { export interface EmptyStorageValue {
data?: undefined; data?: undefined;
ttl?: undefined; ttl?: undefined;

View File

@ -89,17 +89,23 @@ describe('Request Interceptor', () => {
const response2 = await axios.get('http://test.com'); const response2 = await axios.get('http://test.com');
assert.ok(response2.cached); assert.ok(response2.cached);
const response3 = await axios.get('http://test.com', { id: 'random-id' }); const response3 = await axios.get('http://test.com', {
id: 'random-id'
});
assert.equal(response3.cached, false); assert.equal(response3.cached, false);
const response4 = await axios.get('http://test.com', { id: 'random-id' }); const response4 = await axios.get('http://test.com', {
id: 'random-id'
});
assert.ok(response4.cached); assert.ok(response4.cached);
}); });
it('Cache expiration', async () => { it('Cache expiration', async () => {
const axios = mockAxios({}, { [Header.CacheControl]: 'max-age=1,stale-while-revalidate=10' }); const axios = mockAxios({}, { [Header.CacheControl]: 'max-age=1,stale-while-revalidate=10' });
await axios.get('http://test.com', { cache: { interpretHeader: true } }); await axios.get('http://test.com', {
cache: { interpretHeader: true }
});
const resultCache = await axios.get('http://test.com'); const resultCache = await axios.get('http://test.com');
assert.ok(resultCache.cached); assert.ok(resultCache.cached);
@ -134,6 +140,9 @@ describe('Request Interceptor', () => {
const res3 = await axios.get('url', config); const res3 = await axios.get('url', config);
assert.equal(res1.cached, false); assert.equal(res1.cached, false);
const headers1 = res1.headers as Record<string, string>;
const headers2 = res2.headers as Record<string, string>;
assert.equal(headers1['x-mock-random'], headers2['x-mock-random']);
assert.ok(res2.cached); assert.ok(res2.cached);
assert.ok(res3.cached); assert.ok(res3.cached);
@ -142,8 +151,9 @@ describe('Request Interceptor', () => {
const res4 = await axios.get('url', config); const res4 = await axios.get('url', config);
// Should be false because the cache couldn't be stale // Should be different because the call it may not serve stale
assert.equal(res4.cached, false); const headers4 = res4.headers as Record<string, string>;
assert.notEqual(headers1['x-mock-random'], headers4['x-mock-random']);
}); });
it('Expects two requests with different body are not cached', async () => { it('Expects two requests with different body are not cached', async () => {

View File

@ -1,9 +1,9 @@
import assert from 'node:assert'; import assert from 'node:assert';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import { Axios } from 'axios'; import { Axios } from 'axios';
import { buildStorage, canStale, isStorage } from '../../src/storage/build.js'; import { buildStorage, canStale, isStorage, mustRevalidate } from '../../src/storage/build.js';
import { buildMemoryStorage } from '../../src/storage/memory.js'; import { buildMemoryStorage } from '../../src/storage/memory.js';
import type { AxiosStorage, StorageValue } from '../../src/storage/types.js'; import type { AxiosStorage, CachedStorageValue, StorageValue } from '../../src/storage/types.js';
import { buildWebStorage } from '../../src/storage/web-api.js'; import { buildWebStorage } from '../../src/storage/web-api.js';
import { localStorage } from '../dom.js'; import { localStorage } from '../dom.js';
import { mockAxios } from '../mocks/axios.js'; import { mockAxios } from '../mocks/axios.js';
@ -150,41 +150,29 @@ describe('General storage functions', () => {
}); });
it('canStale() function with MustRevalidate', () => { it('canStale() function with MustRevalidate', () => {
// Normal request, but without must-revalidate // Normal request, without must-revalidate
assert.ok( const entry: CachedStorageValue = {
canStale({ data: {
data: { headers: {
headers: { 'Cache-Control': 'max-age=1'
'Cache-Control': 'max-age=1'
},
data: true,
status: 200,
statusText: 'OK'
}, },
createdAt: Date.now(), data: true,
state: 'cached', status: 200,
ttl: 1000, statusText: 'OK'
staleTtl: 1000 },
}) createdAt: Date.now(),
); state: 'cached',
ttl: 1000,
staleTtl: 1000
};
// Normal request, but with must-revalidate assert.ok(canStale(entry));
assert.equal( assert.equal(mustRevalidate(entry), false);
canStale({
data: { // Now with must-revalidate
headers: { entry.data.headers['cache-control'] = 'must-revalidate, max-age=1';
'cache-control': 'must-revalidate, max-age=1'
}, assert.equal(canStale(entry), true);
data: true, assert.equal(mustRevalidate(entry), true);
status: 200,
statusText: 'OK'
},
createdAt: Date.now(),
state: 'cached',
ttl: 1000,
staleTtl: 1000
}),
false
);
}); });
}); });