@cloudflare/pages-shared 0.11.31 → 0.11.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,9 +25,18 @@ type BodyEncoding = "manual" | "automatic";
25
25
  // Before serving a 404, we check the cache to see if we've served this asset recently
26
26
  // and if so, serve it from the cache instead of responding with a 404.
27
27
  // This gives a bit of a grace period between deployments for any clients browsing the old deployment.
28
- export const ASSET_PRESERVATION_CACHE = "assetPreservationCache";
28
+ export const ASSET_PRESERVATION_CACHE_V1 = "assetPreservationCache";
29
+ // V2 stores the content hash instead of the asset.
30
+ // TODO: Remove V1 once we've fully migrated to V2
31
+ export const ASSET_PRESERVATION_CACHE_V2 = "assetPreservationCacheV2";
29
32
  const CACHE_CONTROL_PRESERVATION = "public, s-maxage=604800"; // 1 week
30
33
 
34
+ /** The preservation cache should be periodically
35
+ * written to so that the age / expiration is reset.
36
+ * Note: Up to 12 hours of jitter added to this value.
37
+ */
38
+ export const CACHE_PRESERVATION_WRITE_FREQUENCY = 86_400; // 1 day
39
+
31
40
  export const CACHE_CONTROL_BROWSER = "public, max-age=0, must-revalidate"; // have the browser check in with the server to make sure its local cache is valid before using it
32
41
  export const REDIRECTS_VERSION = 1;
33
42
  export const HEADERS_VERSION = 2;
@@ -311,6 +320,10 @@ export async function generateHandler<
311
320
  }
312
321
  }
313
322
 
323
+ function isNullBodyStatus(status: number): boolean {
324
+ return [101, 204, 205, 304].includes(status);
325
+ }
326
+
314
327
  async function attachHeaders(response: Response) {
315
328
  const existingHeaders = new Headers(response.headers);
316
329
 
@@ -456,7 +469,7 @@ export async function generateHandler<
456
469
 
457
470
  // https://fetch.spec.whatwg.org/#null-body-status
458
471
  return new Response(
459
- [101, 204, 205, 304].includes(response.status) ? null : response.body,
472
+ isNullBodyStatus(response.status) ? null : response.body,
460
473
  {
461
474
  headers: headers,
462
475
  status: response.status,
@@ -467,6 +480,13 @@ export async function generateHandler<
467
480
 
468
481
  return await attachHeaders(await generateResponse());
469
482
 
483
+ /** We have non-standard cache behavior, so strip out all headers but keep the method */
484
+ function getCacheKey(): Request {
485
+ return new Request(request.url, {
486
+ method: request.method,
487
+ });
488
+ }
489
+
470
490
  async function serveAsset(
471
491
  servingAssetEntry: AssetEntry,
472
492
  options = { preserve: true }
@@ -532,32 +552,43 @@ export async function generateHandler<
532
552
  response.headers.set("x-robots-tag", "noindex");
533
553
  }
534
554
 
535
- if (options.preserve) {
536
- // https://fetch.spec.whatwg.org/#null-body-status
537
- const preservedResponse = new Response(
538
- [101, 204, 205, 304].includes(response.status)
539
- ? null
540
- : response.clone().body,
541
- response
542
- );
543
- preservedResponse.headers.set(
544
- "cache-control",
545
- CACHE_CONTROL_PRESERVATION
546
- );
547
- preservedResponse.headers.set("x-robots-tag", "noindex");
555
+ if (options.preserve && waitUntil && caches) {
556
+ waitUntil(
557
+ (async () => {
558
+ try {
559
+ const assetPreservationCacheV2 = await caches.open(
560
+ ASSET_PRESERVATION_CACHE_V2
561
+ );
548
562
 
549
- if (waitUntil && caches) {
550
- waitUntil(
551
- caches
552
- .open(ASSET_PRESERVATION_CACHE)
553
- .then((assetPreservationCache) =>
554
- assetPreservationCache.put(request.url, preservedResponse)
555
- )
556
- .catch((err) => {
557
- logError(err);
558
- })
559
- );
560
- }
563
+ // Check if the asset has changed since last written to cache
564
+ // or if the cached entry is getting too old and should have
565
+ // it's expiration reset.
566
+ const match = await assetPreservationCacheV2.match(request);
567
+ if (
568
+ !match ||
569
+ assetKey !== (await match.text()) ||
570
+ isPreservationCacheResponseExpiring(match)
571
+ ) {
572
+ // cache the asset key in the cache with all the headers.
573
+ // When we read it back, we'll re-fetch the body but use the
574
+ // cached headers.
575
+ const preservedResponse = new Response(assetKey, response);
576
+ preservedResponse.headers.set(
577
+ "cache-control",
578
+ CACHE_CONTROL_PRESERVATION
579
+ );
580
+ preservedResponse.headers.set("x-robots-tag", "noindex");
581
+
582
+ await assetPreservationCacheV2.put(
583
+ getCacheKey(),
584
+ preservedResponse
585
+ );
586
+ }
587
+ } catch (err) {
588
+ logError(err as Error);
589
+ }
590
+ })()
591
+ );
561
592
  }
562
593
 
563
594
  if (
@@ -585,15 +616,66 @@ export async function generateHandler<
585
616
 
586
617
  async function notFound(): Promise<Response> {
587
618
  if (caches) {
588
- const assetPreservationCache = await caches.open(
589
- ASSET_PRESERVATION_CACHE
590
- );
591
- const preservedResponse = await assetPreservationCache.match(request.url);
592
- if (preservedResponse) {
593
- if (setMetrics) setMetrics({ preservationCacheResult: "checked-hit" });
594
- return preservedResponse;
595
- } else {
596
- if (setMetrics) setMetrics({ preservationCacheResult: "checked-miss" });
619
+ try {
620
+ const assetPreservationCacheV2 = await caches.open(
621
+ ASSET_PRESERVATION_CACHE_V2
622
+ );
623
+ let preservedResponse = await assetPreservationCacheV2.match(
624
+ getCacheKey()
625
+ );
626
+
627
+ // Continue serving from V1 preservation cache for some time to
628
+ // prevent 404s during the migration to V2
629
+ const cutoffDate = new Date("2024-05-10");
630
+ if (!preservedResponse && Date.now() < cutoffDate.getTime()) {
631
+ const assetPreservationCacheV1 = await caches.open(
632
+ ASSET_PRESERVATION_CACHE_V1
633
+ );
634
+ preservedResponse = await assetPreservationCacheV1.match(request.url);
635
+ if (preservedResponse) {
636
+ // V1 cache contains full response bodies so we return it directly
637
+ if (setMetrics) {
638
+ setMetrics({ preservationCacheResult: "checked-hit" });
639
+ }
640
+ return preservedResponse;
641
+ }
642
+ }
643
+
644
+ // V2 cache only contains the asset key, rather than the asset body:
645
+ if (preservedResponse) {
646
+ if (setMetrics) {
647
+ setMetrics({ preservationCacheResult: "checked-hit" });
648
+ }
649
+ // Always read the asset key to prevent hanging responses
650
+ const assetKey = await preservedResponse.text();
651
+ if (isNullBodyStatus(preservedResponse.status)) {
652
+ // We know the asset hasn't changed, so use the cached headers.
653
+ return new Response(null, preservedResponse);
654
+ }
655
+ if (assetKey) {
656
+ const asset = await fetchAsset(assetKey);
657
+ if (asset) {
658
+ // We know the asset hasn't changed, so use the cached headers.
659
+ return new Response(asset.body, preservedResponse);
660
+ } else {
661
+ logError(
662
+ new Error(
663
+ `preservation cache contained assetKey that does not exist in storage: ${assetKey}`
664
+ )
665
+ );
666
+ }
667
+ } else {
668
+ logError(new Error(`cached response had no assetKey: ${assetKey}`));
669
+ }
670
+ } else {
671
+ if (setMetrics) {
672
+ setMetrics({ preservationCacheResult: "checked-miss" });
673
+ }
674
+ }
675
+ } catch (err) {
676
+ // Don't throw an error because preservation cache is best effort.
677
+ // But log it because we should be able to fetch the asset here.
678
+ logError(err as Error);
597
679
  }
598
680
  } else {
599
681
  if (setMetrics) setMetrics({ preservationCacheResult: "disabled" });
@@ -662,6 +744,26 @@ function isPreview(url: URL): boolean {
662
744
  return false;
663
745
  }
664
746
 
747
+ /** Checks if a response is older than CACHE_PRESERVATION_WRITE_FREQUENCY
748
+ * and should be written to cache again to reset it's expiration.
749
+ */
750
+ export function isPreservationCacheResponseExpiring(
751
+ response: Response
752
+ ): boolean {
753
+ const ageHeader = response.headers.get("age");
754
+ if (!ageHeader) return false;
755
+ try {
756
+ const age = parseInt(ageHeader);
757
+ // Add up to 12 hours of jitter to help prevent a
758
+ // thundering heard when a lot of assets expire at once.
759
+ const jitter = Math.floor(Math.random() * 43_200);
760
+ if (age > CACHE_PRESERVATION_WRITE_FREQUENCY + jitter) return true;
761
+ } catch {
762
+ return false;
763
+ }
764
+ return false;
765
+ }
766
+
665
767
  /**
666
768
  * Whether or not the passed in string looks like an HTML
667
769
  * Content-Type header
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudflare/pages-shared",
3
- "version": "0.11.31",
3
+ "version": "0.11.33",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/cloudflare/workers-sdk.git",