diff --git a/packages/cache/src/cache.ts b/packages/cache/src/cache.ts index e7245bc37..a0adf9d36 100644 --- a/packages/cache/src/cache.ts +++ b/packages/cache/src/cache.ts @@ -110,6 +110,15 @@ function getExpirationTtl( } } +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag#syntax +const etagRegexp = /^(W\/)?"(.+)"$/; +export function parseETag(value: string): string | undefined { + // As we only use this for `If-None-Match` handling, which always use the weak + // comparison algorithm, ignore "W/" directives: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match + return etagRegexp.exec(value.trim())?.[2] ?? undefined; +} + // https://datatracker.ietf.org/doc/html/rfc7231#section-7.1.1.1 const utcDateRegexp = /^(Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d\d (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d\d\d\d \d\d:\d\d:\d\d GMT$/; @@ -123,7 +132,26 @@ function getMatchResponse( resHeaders: Headers, resBody: Uint8Array ): Response { - // If `If-Modified-Since` is set, perform a conditional request + // If `If-None-Match` is set, perform a conditional request: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match + const reqIfNoneMatchHeader = reqHeaders.get("If-None-Match"); + const resETagHeader = resHeaders.get("ETag"); + if (reqIfNoneMatchHeader !== null && resETagHeader !== null) { + const resETag = parseETag(resETagHeader); + if (resETag !== undefined) { + if (reqIfNoneMatchHeader.trim() === "*") { + return new Response(null, { status: 304, headers: resHeaders }); + } + for (const reqIfNoneMatch of reqIfNoneMatchHeader.split(",")) { + if (resETag === parseETag(reqIfNoneMatch)) { + return new Response(null, { status: 304, headers: resHeaders }); + } + } + } + } + + // If `If-Modified-Since` is set, perform a conditional request: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since const reqIfModifiedSinceHeader = reqHeaders.get("If-Modified-Since"); const resLastModifiedHeader = resHeaders.get("Last-Modified"); if (reqIfModifiedSinceHeader !== null && resLastModifiedHeader !== null) { @@ -131,14 +159,12 @@ function getMatchResponse( const resLastModified = parseUTCDate(resLastModifiedHeader); // Comparison of NaN's (invalid dates), will always result in `false` if (resLastModified <= reqIfModifiedSince) { - return new Response(null, { - status: 304, // Not Modified - headers: resHeaders, - }); + return new Response(null, { status: 304, headers: resHeaders }); } } - // If `Range` is set, return a partial response + // If `Range` is set, return a partial response: + // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range const reqRangeHeader = reqHeaders.get("Range"); if (reqRangeHeader !== null) { return _getRangeResponse(reqRangeHeader, resStatus, resHeaders, resBody); diff --git a/packages/cache/test/cache.spec.ts b/packages/cache/test/cache.spec.ts index 47f3087a0..24de48de2 100644 --- a/packages/cache/test/cache.spec.ts +++ b/packages/cache/test/cache.spec.ts @@ -238,6 +238,37 @@ test("Cache: match throws if attempting to load cached response created with Min "load cached data created with Miniflare 1 and must delete it.", }); }); +test("Cache: match respects If-None-Match header", async (t) => { + const { cache } = t.context; + const res = new Response("value", { + headers: { + ETag: '"thing"', + "Cache-Control": "max-age=3600", + }, + }); + await cache.put("http://localhost:8787/test", res); + + const ifNoneMatch = (value: string) => + new BaseRequest("http://localhost:8787/test", { + headers: { "If-None-Match": value }, + }); + + // Check returns 304 only if an ETag in `If-Modified-Since` matches + let cacheRes = await cache.match(ifNoneMatch('"thing"')); + t.is(cacheRes?.status, 304); + cacheRes = await cache.match(ifNoneMatch(' W/"thing" ')); + t.is(cacheRes?.status, 304); + cacheRes = await cache.match(ifNoneMatch('"not the thing"')); + t.is(cacheRes?.status, 200); + cacheRes = await cache.match( + ifNoneMatch('"not the thing", "thing" , W/"still not the thing"') + ); + t.is(cacheRes?.status, 304); + cacheRes = await cache.match(ifNoneMatch("*")); + t.is(cacheRes?.status, 304); + cacheRes = await cache.match(ifNoneMatch(" * ")); + t.is(cacheRes?.status, 304); +}); test("Cache: match respects If-Modified-Since header", async (t) => { const { cache } = t.context; const res = new Response("value", {