diff --git a/.changeset/khaki-dots-sleep.md b/.changeset/khaki-dots-sleep.md new file mode 100644 index 0000000..7f4ad62 --- /dev/null +++ b/.changeset/khaki-dots-sleep.md @@ -0,0 +1,6 @@ +--- +'@web-widget/shared-cache': patch +--- + +- The stale-while-revalidate and stale-if-error directives are not supported when using the cache.put or cache.match methods. +- Support HEAD requests. diff --git a/src/cache-key.test.ts b/src/cache-key.test.ts index 6dbc57d..c06c8de 100644 --- a/src/cache-key.test.ts +++ b/src/cache-key.test.ts @@ -37,13 +37,12 @@ test('should support built-in rules', async () => { include: ['x-id'], }, host: true, - method: true, pathname: true, search: true, }, } ); - expect(key).toBe('localhost/?a=1#a=356a19:desktop:x-id=a9993e:GET'); + expect(key).toBe('localhost/?a=1#a=356a19:desktop:x-id=a9993e'); }); test('should support filtering', async () => { @@ -410,51 +409,6 @@ describe('should support host', () => { }); }); -describe('should support method', () => { - test('basic', async () => { - const keyGenerator = createCacheKeyGenerator(); - const key = await keyGenerator(new Request('http://localhost/'), { - cacheKeyRules: { - method: true, - }, - }); - expect(key).toBe('#GET'); - }); - - test('should support filtering', async () => { - const keyGenerator = createCacheKeyGenerator(); - const key = await keyGenerator( - new Request('http://localhost/', { method: 'POST' }), - { - cacheKeyRules: { - method: { include: ['GET'] }, - }, - } - ); - expect(key).toBe(''); - }); - - test('the body of the POST, PATCH and PUT methods should be used as part of the key', async () => { - await Promise.all( - ['POST', 'PATCH', 'PUT'].map(async (method) => { - const keyGenerator = createCacheKeyGenerator(); - const key = await keyGenerator( - new Request('http://localhost/', { - method, - body: 'hello', - }), - { - cacheKeyRules: { - method: true, - }, - } - ); - expect(key).toBe(`#${method}=aaf4c6`); - }) - ); - }); -}); - describe('should support pathname', () => { test('basic', async () => { const keyGenerator = createCacheKeyGenerator(); diff --git a/src/cache-key.ts b/src/cache-key.ts index 7182a6f..2563444 100644 --- a/src/cache-key.ts +++ b/src/cache-key.ts @@ -20,8 +20,6 @@ export interface SharedCacheKeyRules { header?: FilterOptions | boolean; /** Use host as part of cache key. */ host?: FilterOptions | boolean; - /** Use method as part of cache key. */ - method?: FilterOptions | boolean; /** Use pathname as part of cache key. */ pathname?: FilterOptions | boolean; /** Use search as part of cache key. */ @@ -121,18 +119,6 @@ export function host(url: URL, options?: FilterOptions) { .join(''); } -export async function method(request: Request, options?: FilterOptions) { - const hasBody = - request.body && ['POST', 'PATCH', 'PUT'].includes(request.method); - return ( - await Promise.all( - filter([[request.method, '']], options).map(async ([key]) => - hasBody ? `${key}=${await shortHash(request.body)}` : key - ) - ) - ).join(''); -} - export function pathname(url: URL, options?: FilterOptions) { const pathname = url.pathname; return filter([[pathname, '']], options) @@ -218,12 +204,10 @@ const BUILT_IN_EXPANDED_PART_DEFINERS: BuiltInExpandedCacheKeyPartDefiners = { cookie, device, header, - method, }; export const DEFAULT_CACHE_KEY_RULES: SharedCacheKeyRules = { host: true, - method: true, pathname: true, search: true, }; @@ -251,10 +235,6 @@ export function createCacheKeyGenerator( const urlRules: SharedCacheKeyRules = { host, pathname, search }; const url = new URL(request.url); - if (options.ignoreMethod) { - fragmentRules.method = false; - } - const urlPart: string[] = BUILT_IN_URL_PART_KEYS.filter( (name) => urlRules[name] ).map((name) => { @@ -306,6 +286,6 @@ export function createCacheKeyGenerator( // eslint-disable-next-line @typescript-eslint/no-explicit-any function notImplemented(options: any, name: string) { if (name in options) { - throw new Error(`Not Implemented: "${name}" option.`); + throw new Error(`Not implemented: "${name}" option.`); } } diff --git a/src/cache-storage.ts b/src/cache-storage.ts index 1d14b21..adb5ec7 100644 --- a/src/cache-storage.ts +++ b/src/cache-storage.ts @@ -13,17 +13,17 @@ export class SharedCacheStorage implements CacheStorage { /** @private */ async delete(_cacheName: string): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** @private */ async has(_cacheName: string): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** @private */ async keys(): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** @private */ @@ -31,7 +31,7 @@ export class SharedCacheStorage implements CacheStorage { _request: RequestInfo, _options?: MultiCacheQueryOptions ): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** diff --git a/src/cache.ts b/src/cache.ts index 86123b9..b34c711 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -18,15 +18,13 @@ import { STALE, } from './constants'; -const ORIGINAL_FETCH = globalThis.fetch; - export class SharedCache implements Cache { #cacheKeyGenerator: ( request: Request, options?: SharedCacheQueryOptions ) => Promise; #cacheKeyRules?: SharedCacheKeyRules; - #fetch: typeof fetch; + #fetch?: typeof fetch; #logger?: Logger; #storage: KVStorage; #waitUntil: (promise: Promise) => void; @@ -48,7 +46,7 @@ export class SharedCache implements Cache { resolveOptions.cacheKeyPartDefiners ); this.#cacheKeyRules = resolveOptions.cacheKeyRules; - this.#fetch = resolveOptions.fetch ?? ORIGINAL_FETCH; + this.#fetch = resolveOptions.fetch; this.#logger = resolveOptions.logger; this.#storage = storage; this.#waitUntil = resolveOptions.waitUntil; @@ -56,12 +54,12 @@ export class SharedCache implements Cache { /** @private */ async add(_request: RequestInfo): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** @private */ async addAll(_requests: RequestInfo[]): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** @@ -111,7 +109,7 @@ export class SharedCache implements Cache { _request?: RequestInfo, _options?: SharedCacheQueryOptions ): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** @@ -160,23 +158,29 @@ export class SharedCache implements Cache { } const fetch = options?._fetch ?? this.#fetch; - const forceCache = options?.forceCache; - const { body, status, statusText } = cacheItem.response; const policy = CachePolicy.fromObject(cacheItem.policy); + + const { body, status, statusText } = cacheItem.response; const headers = policy.responseHeaders(); - let response = new Response(body, { + const stale = policy.stale(); + const response = new Response(body, { status, statusText, headers, }); if ( - !forceCache && !policy.satisfiesWithoutRevalidation(r, { + ignoreRequestCacheControl: options?.ignoreRequestCacheControl, + ignoreMethod: true, ignoreSearch: true, - }) + ignoreVary: true, + }) || + stale ) { - if (policy.stale() && policy.useStaleWhileRevalidate()) { + if (!fetch) { + return; + } else if (stale && policy.useStaleWhileRevalidate()) { // Well actually, in this case it's fine to return the stale response. // But we'll update the cache in the background. this.#waitUntil( @@ -192,8 +196,9 @@ export class SharedCache implements Cache { ) ); this.#setCacheStatus(response, STALE); + return response; } else { - response = await this.#revalidate( + return this.#revalidate( r, { response, @@ -204,10 +209,9 @@ export class SharedCache implements Cache { options ); } - } else { - this.#setCacheStatus(response, HIT); } + this.#setCacheStatus(response, HIT); return response; } @@ -216,7 +220,7 @@ export class SharedCache implements Cache { _request?: RequestInfo, _options?: SharedCacheQueryOptions ): Promise { - throw new Error('Not Implemented.'); + throw new Error('Not implemented.'); } /** @@ -263,7 +267,7 @@ export class SharedCache implements Cache { !urlIsHttpHttpsScheme(innerRequest.url) || innerRequest.method !== 'GET' ) { - new TypeError( + throw new TypeError( `Cache.put: Expected an http/s scheme when method is not GET.` ); } @@ -301,6 +305,8 @@ export class SharedCache implements Cache { // 9. const clonedResponse = innerResponse.clone(); + // TODO: 10. - 19. + const policy = new CachePolicy(innerRequest, clonedResponse); const ttl = policy.timeToLive(); @@ -343,10 +349,10 @@ export class SharedCache implements Cache { ): Promise { const revalidationRequest = new Request(request, { headers: resolveCacheItem.policy.revalidationHeaders(request, { - ignoreRequestCacheControl: options?.ignoreRequestCacheControl ?? true, + ignoreRequestCacheControl: options?.ignoreRequestCacheControl, ignoreMethod: true, ignoreSearch: true, - ignoreVary: false, + ignoreVary: true, }), }); let revalidationResponse: Response; diff --git a/src/fetch.test.ts b/src/fetch.test.ts index e538c8f..f98ccc8 100644 --- a/src/fetch.test.ts +++ b/src/fetch.test.ts @@ -136,7 +136,7 @@ describe('multiple duplicate requests', () => { }); expect(res.status).toBe(200); expect(res.headers.get('content-type')).toBe('text/lol; charset=utf-8'); - expect(res.headers.get('x-cache-status')).toBe(MISS); + expect(res.headers.get('x-cache-status')).toBe(DYNAMIC); expect(res.headers.get('etag')).toBe('"v1"'); expect(await res.text()).toBe('lol'); }); @@ -194,7 +194,7 @@ test('when no cache control is set the latest content should be loaded', async ( expect(await res.text()).toBe('lol'); }); -test('should respect cache control directives from requests', async () => { +test.only('should respect cache control directives from requests', async () => { const store = createCacheStore(); const cache = new SharedCache(store); const fetch = createSharedCacheFetch(cache, { @@ -210,6 +210,9 @@ test('should respect cache control directives from requests', async () => { headers: { 'cache-control': 'no-cache', }, + sharedCache: { + ignoreRequestCacheControl: false, + }, }); expect(res.status).toBe(200); @@ -222,6 +225,9 @@ test('should respect cache control directives from requests', async () => { headers: { 'cache-control': 'no-cache', }, + sharedCache: { + ignoreRequestCacheControl: false, + }, }); expect(res.status).toBe(200); @@ -251,6 +257,34 @@ test('when body is a string it should cache the response', async () => { expect(await cachedRes?.text()).toBe('lol'); }); +test('when the method is HEAD, it should read the cache of the GET request', async () => { + const store = createCacheStore(); + const cache = new SharedCache(store); + const fetch = createSharedCacheFetch(cache, { + async fetch(input, init) { + const req = new Request(input, init); + return new Response(req.method, { + headers: { + 'cache-control': 'max-age=300', + }, + }); + }, + }); + const get = new Request(TEST_URL, { + method: 'GET', + }); + await fetch(get); + const head = new Request(TEST_URL, { + method: 'HEAD', + }); + const res = await fetch(head); + + expect(res.status).toBe(200); + expect(await res.text()).toBe('GET'); + expect(res.headers.get('x-cache-status')).toBe(HIT); + expect(await cache.match(head)).toBeUndefined(); +}); + test('when the method is POST it should not cache the response', async () => { const store = createCacheStore(); const cache = new SharedCache(store); @@ -275,7 +309,7 @@ test('when the method is POST it should not cache the response', async () => { expect(res.status).toBe(200); expect(await res.text()).toBe('POST'); - expect(res.headers.get('x-cache-status')).toBe(MISS); + expect(res.headers.get('x-cache-status')).toBe(DYNAMIC); expect(await cache.match(post)).toBeUndefined(); }); diff --git a/src/fetch.ts b/src/fetch.ts index 7888331..86744aa 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -39,19 +39,19 @@ export function createSharedCacheFetch( const request = new Request(input, init); const requestCache = getRequestCacheMode(request, init?.cache); const sharedCache = init?.sharedCache; + const ignoreRequestCacheControl = + sharedCache?.ignoreRequestCacheControl ?? true; const interceptor = createInterceptor(fetcher, sharedCache); - if (requestCache === 'no-store') { - const fetchedResponse = await interceptor(input, init); - setCacheStatus(fetchedResponse, BYPASS); - return fetchedResponse; + if (requestCache) { + throw new Error(`Not implemented: "cache" option.`); } const cachedResponse = await cache.match(request, { ...sharedCache, _fetch: interceptor, - forceCache: - requestCache === 'force-cache' || requestCache === 'only-if-cached', + ignoreMethod: request.method === 'HEAD', + ignoreRequestCacheControl, }); if (cachedResponse) { @@ -59,10 +59,6 @@ export function createSharedCacheFetch( return cachedResponse; } - if (requestCache === 'only-if-cached') { - throw TypeError('Failed to fetch.'); - } - const fetchedResponse = await interceptor(request); const cacheControl = fetchedResponse.headers.get('cache-control'); diff --git a/src/types.ts b/src/types.ts index dba77e9..93ecd7c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,7 +25,7 @@ export interface SharedCacheOptions { waitUntil?: (promise: Promise) => void; /** - * @default globalThis.fetch + * Method to initiate a request after cache expiration. */ fetch?: typeof fetch; @@ -71,10 +71,6 @@ export type SharedCacheStatus = export type SharedCacheQueryOptions = { cacheKeyRules?: SharedCacheKeyRules; - /** - * Force cache to be used even if it's stale. - */ - forceCache?: boolean; ignoreRequestCacheControl?: boolean; ignoreMethod?: boolean; /** @private */