Skip to content

Commit 756065c

Browse files
GregBrimblepenalosa
authored andcommitted
Check If-None-Match before fulfilling preservation cache (#7546)
* Refactor ETag handling * Check if-none-match before fulfilling preservation cache
1 parent cce806c commit 756065c

File tree

3 files changed

+66
-18
lines changed

3 files changed

+66
-18
lines changed

.changeset/fuzzy-nails-invent.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/pages-shared": minor
3+
---
4+
5+
feat: Return a 304 Not Modified response when matching an asset preservation cache request if appropriate

packages/pages-shared/__tests__/asset-server/handler.test.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -572,38 +572,52 @@ describe("asset-server handler", () => {
572572
'"asset-key-foo.html"'
573573
);
574574

575-
// Delete the asset from the manifest and ensure it's served from preservation cache
575+
// Delete the asset from the manifest and ensure it's served from preservation cache with a 304 when if-none-match is present
576576
findAssetEntryForPath = async (_path: string) => {
577577
return null;
578578
};
579579
const { response: response2 } = await getTestResponse({
580+
request: new Request("https://example.com/foo", {
581+
headers: { "if-none-match": expectedHeaders.etag },
582+
}),
583+
metadata,
584+
findAssetEntryForPath,
585+
caches,
586+
fetchAsset: () =>
587+
Promise.resolve(Object.assign(new Response("hello world!"))),
588+
});
589+
expect(response2.status).toBe(304);
590+
expect(await response2.text()).toMatchInlineSnapshot('""');
591+
592+
// Ensure the asset is served from preservation cache with a 200 if if-none-match is not present
593+
const { response: response3 } = await getTestResponse({
580594
request: new Request("https://example.com/foo"),
581595
metadata,
582596
findAssetEntryForPath,
583597
caches,
584598
fetchAsset: () =>
585599
Promise.resolve(Object.assign(new Response("hello world!"))),
586600
});
587-
expect(response2.status).toBe(200);
588-
expect(await response2.text()).toMatchInlineSnapshot('"hello world!"');
601+
expect(response3.status).toBe(200);
602+
expect(await response3.text()).toMatchInlineSnapshot('"hello world!"');
589603
// Cached responses have the same headers with a few changes/additions:
590-
expect(Object.fromEntries(response2.headers)).toStrictEqual({
604+
expect(Object.fromEntries(response3.headers)).toStrictEqual({
591605
...expectedHeaders,
592606
"cache-control": "public, s-maxage=604800",
593607
"x-robots-tag": "noindex",
594608
"cf-cache-status": "HIT", // new header
595609
});
596610

597611
// Serve with a fresh cache and ensure we don't get a response
598-
const { response: response3 } = await getTestResponse({
612+
const { response: response4 } = await getTestResponse({
599613
request: new Request("https://example.com/foo"),
600614
metadata,
601615
findAssetEntryForPath,
602616
fetchAsset: () =>
603617
Promise.resolve(Object.assign(new Response("hello world!"))),
604618
});
605-
expect(response3.status).toBe(404);
606-
expect(Object.fromEntries(response3.headers)).toMatchInlineSnapshot(`
619+
expect(response4.status).toBe(404);
620+
expect(Object.fromEntries(response4.headers)).toMatchInlineSnapshot(`
607621
{
608622
"access-control-allow-origin": "*",
609623
"cache-control": "no-store",

packages/pages-shared/asset-server/handler.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,25 @@ type FindAssetEntryForPath<AssetEntry> = (
7272
path: string
7373
) => Promise<null | AssetEntry>;
7474

75+
function generateETagHeader(assetKey: string) {
76+
// https://support.cloudflare.com/hc/en-us/articles/218505467-Using-ETag-Headers-with-Cloudflare
77+
// We sometimes remove etags unless they are wrapped in quotes
78+
const strongETag = `"${assetKey}"`;
79+
const weakETag = `W/"${assetKey}"`;
80+
return { strongETag, weakETag };
81+
}
82+
83+
function checkIfNoneMatch(
84+
request: Request,
85+
strongETag: string,
86+
weakETag: string
87+
) {
88+
const ifNoneMatch = request.headers.get("if-none-match");
89+
90+
// We sometimes downgrade strong etags to a weak ones, so we need to check for both
91+
return ifNoneMatch === weakETag || ifNoneMatch === strongETag;
92+
}
93+
7594
type ServeAsset<AssetEntry> = (
7695
assetEntry: AssetEntry,
7796
options?: { preserve: boolean }
@@ -80,7 +99,10 @@ type ServeAsset<AssetEntry> = (
8099
type CacheStatus = "hit" | "miss";
81100
type CacheResult<A extends string> = `${A}-${CacheStatus}`;
82101
export type HandlerMetrics = {
83-
preservationCacheResult?: CacheResult<"checked"> | "disabled";
102+
preservationCacheResult?:
103+
| CacheResult<"checked">
104+
| "not-modified"
105+
| "disabled";
84106
earlyHintsResult?: CacheResult<"used" | "notused"> | "disabled";
85107
};
86108

@@ -518,22 +540,16 @@ export async function generateHandler<
518540

519541
const assetKey = getAssetKey(servingAssetEntry, content);
520542

521-
// https://support.cloudflare.com/hc/en-us/articles/218505467-Using-ETag-Headers-with-Cloudflare
522-
// We sometimes remove etags unless they are wrapped in quotes
523-
const etag = `"${assetKey}"`;
524-
const weakEtag = `W/${etag}`;
525-
526-
const ifNoneMatch = request.headers.get("if-none-match");
527-
528-
// We sometimes downgrade strong etags to a weak ones, so we need to check for both
529-
if (ifNoneMatch === weakEtag || ifNoneMatch === etag) {
543+
const { strongETag, weakETag } = generateETagHeader(assetKey);
544+
const isIfNoneMatch = checkIfNoneMatch(request, strongETag, weakETag);
545+
if (isIfNoneMatch) {
530546
return new NotModifiedResponse();
531547
}
532548

533549
try {
534550
const asset = await fetchAsset(assetKey);
535551
const headers: Record<string, string> = {
536-
etag,
552+
etag: strongETag,
537553
"content-type": asset.contentType,
538554
};
539555
let encodeBody: BodyEncoding = "automatic";
@@ -654,6 +670,19 @@ export async function generateHandler<
654670
return new Response(null, preservedResponse);
655671
}
656672
if (assetKey) {
673+
const { strongETag, weakETag } = generateETagHeader(assetKey);
674+
const isIfNoneMatch = checkIfNoneMatch(
675+
request,
676+
strongETag,
677+
weakETag
678+
);
679+
if (isIfNoneMatch) {
680+
if (setMetrics) {
681+
setMetrics({ preservationCacheResult: "not-modified" });
682+
}
683+
return new NotModifiedResponse();
684+
}
685+
657686
const asset = await fetchAsset(assetKey);
658687

659688
if (asset) {

0 commit comments

Comments
 (0)