@decocms/tanstack 8.0.0 → 8.1.0-next.0

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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
@@ -1,7 +1,13 @@
1
- import { setBlocks } from "@decocms/blocks/cms";
2
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { computeRevision, revisionKey, setBlocks, snapshotKey } from "@decocms/blocks/cms";
2
+ import {
3
+ __resetAutoconfigStateForTests,
4
+ autoconfigApps,
5
+ } from "@decocms/blocks-admin/apps/autoconfig";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+ import { segmentToken } from "./cdnSegment";
3
8
  import { __resetKvHydrationStateForTests } from "./kvHydration";
4
9
  import {
10
+ buildEnforcedCsp,
5
11
  buildGeoCacheParam,
6
12
  createDecoWorkerEntry,
7
13
  DECO_ADMIN_FRAME_ANCESTORS,
@@ -214,7 +220,9 @@ describe("CMS redirects", () => {
214
220
  redirects: [{ from: "/old", to: "/new", type: "permanent" }],
215
221
  },
216
222
  });
217
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
223
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
224
+ observability: false,
225
+ });
218
226
  const res = await worker.fetch(new Request("https://example.com/old"), EMPTY_ENV, MOCK_CTX);
219
227
  expect(res.status).toBe(301);
220
228
  expect(res.headers.get("Location")).toBe("/new");
@@ -227,7 +235,9 @@ describe("CMS redirects", () => {
227
235
  redirects: [{ from: "/old", to: "/new", type: "permanent" }],
228
236
  },
229
237
  });
230
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
238
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
239
+ observability: false,
240
+ });
231
241
  const res = await worker.fetch(
232
242
  new Request("https://example.com/old?asJson"),
233
243
  EMPTY_ENV,
@@ -244,7 +254,9 @@ describe("CMS redirects", () => {
244
254
  redirects: [{ from: "/promo", to: "/promoção", type: "temporary" }],
245
255
  },
246
256
  });
247
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
257
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
258
+ observability: false,
259
+ });
248
260
  const res = await worker.fetch(new Request("https://example.com/promo"), EMPTY_ENV, MOCK_CTX);
249
261
  expect(res.status).toBe(302);
250
262
  expect(res.headers.get("Location")).toBe("/promo%C3%A7%C3%A3o");
@@ -257,7 +269,9 @@ describe("CMS redirects", () => {
257
269
  redirects: [{ from: "/promo", to: "/sale", type: "temporary" }],
258
270
  },
259
271
  });
260
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
272
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
273
+ observability: false,
274
+ });
261
275
  const res = await worker.fetch(new Request("https://example.com/promo"), EMPTY_ENV, MOCK_CTX);
262
276
  expect(res.status).toBe(302);
263
277
  expect(res.headers.get("Location")).toBe("/sale");
@@ -270,14 +284,18 @@ describe("CMS redirects", () => {
270
284
  redirects: [{ from: "/old", to: "/new", type: "permanent" }],
271
285
  },
272
286
  });
273
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
287
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
288
+ observability: false,
289
+ });
274
290
  const res = await worker.fetch(new Request("https://example.com/other"), EMPTY_ENV, MOCK_CTX);
275
291
  expect(res.status).toBe(200);
276
292
  });
277
293
 
278
294
  it("picks up new redirects after setBlocks hot-reload", async () => {
279
295
  setBlocks({});
280
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
296
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
297
+ observability: false,
298
+ });
281
299
 
282
300
  // First request with no redirects — falls through
283
301
  const res1 = await worker.fetch(new Request("https://example.com/v1"), EMPTY_ENV, MOCK_CTX);
@@ -313,7 +331,9 @@ describe("degraded origin (anti-cache-poisoning)", () => {
313
331
  headers: { "X-Deco-Degraded": "true" },
314
332
  }),
315
333
  };
316
- const worker = createDecoWorkerEntry(degradedEntry, { observability: false });
334
+ const worker = createDecoWorkerEntry(degradedEntry, {
335
+ observability: false,
336
+ });
317
337
  const res = await worker.fetch(
318
338
  new Request("https://example.com/some-category"),
319
339
  EMPTY_ENV,
@@ -327,7 +347,9 @@ describe("degraded origin (anti-cache-poisoning)", () => {
327
347
 
328
348
  it("serves a healthy 200 origin without the degraded bypass", async () => {
329
349
  setBlocks({});
330
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
350
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
351
+ observability: false,
352
+ });
331
353
  const res = await worker.fetch(
332
354
  new Request("https://example.com/some-category"),
333
355
  EMPTY_ENV,
@@ -392,11 +414,86 @@ describe("security headers — frame-ancestors default", () => {
392
414
  });
393
415
 
394
416
  it("omits all security headers when securityHeaders is false", async () => {
395
- const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, { securityHeaders: false });
417
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
418
+ securityHeaders: false,
419
+ });
396
420
  const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
397
421
  expect(res.headers.get("Content-Security-Policy")).toBeNull();
398
422
  expect(res.headers.get("X-Content-Type-Options")).toBeNull();
399
423
  });
424
+
425
+ it("keeps report-only by default even when a site passes csp (cspMode unset)", async () => {
426
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
427
+ csp: ["default-src 'self'"],
428
+ });
429
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
430
+ expect(res.headers.get("Content-Security-Policy")).toBe(DEFAULT_FRAME_ANCESTORS_CSP);
431
+ expect(res.headers.get("Content-Security-Policy-Report-Only")).toBe("default-src 'self'");
432
+ });
433
+
434
+ it("enforce mode emits a nonce CSP (no report-only, no unsafe-inline) on a no-store HTML response", async () => {
435
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
436
+ csp: ["default-src 'self'", "script-src 'self' 'unsafe-inline' https://gtm.example"],
437
+ cspMode: "enforce",
438
+ });
439
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
440
+ const csp = res.headers.get("Content-Security-Policy") ?? "";
441
+ // Promoted to enforced — report-only is gone.
442
+ expect(res.headers.get("Content-Security-Policy-Report-Only")).toBeNull();
443
+ expect(csp).toContain("default-src 'self'");
444
+ // script-src hardened: unsafe-inline dropped, per-request nonce +
445
+ // inline-speculation-rules added, the host allowlist kept.
446
+ expect(csp).toMatch(/script-src[^;]*'nonce-[A-Za-z0-9+/=]+'/);
447
+ expect(csp).toContain("'inline-speculation-rules'");
448
+ expect(csp).toContain("https://gtm.example");
449
+ expect(csp).not.toContain("'unsafe-inline'");
450
+ // Framing protection preserved.
451
+ expect(csp).toContain("frame-ancestors");
452
+ });
453
+
454
+ it("enforce mode leaves 'unsafe-eval' in place (opt-out is explicit)", async () => {
455
+ const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
456
+ csp: ["script-src 'self' 'unsafe-inline' 'unsafe-eval'"],
457
+ cspMode: "enforce",
458
+ });
459
+ const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
460
+ const csp = res.headers.get("Content-Security-Policy") ?? "";
461
+ expect(csp).toContain("'unsafe-eval'");
462
+ expect(csp).not.toContain("'unsafe-inline'");
463
+ });
464
+ });
465
+
466
+ describe("buildEnforcedCsp", () => {
467
+ const FA = "frame-ancestors 'self' https://studio.decocms.com";
468
+
469
+ it("hardens script-src: drops unsafe-inline, adds nonce + inline-speculation-rules, keeps hosts + unsafe-eval", () => {
470
+ const csp = buildEnforcedCsp(
471
+ ["script-src 'self' 'unsafe-inline' 'unsafe-eval' https://gtm.example"],
472
+ FA,
473
+ "abc123",
474
+ );
475
+ expect(csp).toContain("script-src");
476
+ expect(csp).toContain("'self'");
477
+ expect(csp).toContain("https://gtm.example");
478
+ expect(csp).toContain("'unsafe-eval'");
479
+ expect(csp).toContain("'nonce-abc123'");
480
+ expect(csp).toContain("'inline-speculation-rules'");
481
+ expect(csp).not.toContain("'unsafe-inline'");
482
+ expect(csp).toContain(FA);
483
+ });
484
+
485
+ it("adds a script-src when the site declared none", () => {
486
+ const csp = buildEnforcedCsp(["default-src 'self'"], FA, "n0nce");
487
+ expect(csp).toContain("default-src 'self'");
488
+ expect(csp).toContain("script-src 'self' 'nonce-n0nce' 'inline-speculation-rules'");
489
+ expect(csp).toContain(FA);
490
+ });
491
+
492
+ it("drops any frame-ancestors from the site csp in favour of the canonical one", () => {
493
+ const csp = buildEnforcedCsp(["frame-ancestors 'none'", "default-src 'self'"], FA, "x");
494
+ expect(csp).not.toContain("frame-ancestors 'none'");
495
+ expect(csp).toContain(FA);
496
+ });
400
497
  });
401
498
 
402
499
  describe("POST /_cache/purge-loaders", () => {
@@ -406,9 +503,14 @@ describe("POST /_cache/purge-loaders", () => {
406
503
  });
407
504
 
408
505
  const purge = (env: Record<string, unknown>, headers: Record<string, string> = {}) => {
409
- const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
506
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
507
+ observability: false,
508
+ });
410
509
  return worker.fetch(
411
- new Request("https://example.com/_cache/purge-loaders", { method: "POST", headers }),
510
+ new Request("https://example.com/_cache/purge-loaders", {
511
+ method: "POST",
512
+ headers,
513
+ }),
412
514
  env,
413
515
  MOCK_CTX,
414
516
  );
@@ -436,7 +538,9 @@ describe("POST /_cache/purge-loaders", () => {
436
538
  purgeTokenEnv: false,
437
539
  });
438
540
  const res = await worker.fetch(
439
- new Request("https://example.com/_cache/purge-loaders", { method: "POST" }),
541
+ new Request("https://example.com/_cache/purge-loaders", {
542
+ method: "POST",
543
+ }),
440
544
  EMPTY_ENV,
441
545
  MOCK_CTX,
442
546
  );
@@ -461,7 +565,11 @@ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
461
565
  it("includes a custom segment field in X-Cache-Segment instead of dropping it", async () => {
462
566
  const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
463
567
  observability: false,
464
- buildSegment: () => ({ device: "desktop", regionId: "RJ", delivery: "store-42" }),
568
+ buildSegment: () => ({
569
+ device: "desktop",
570
+ regionId: "RJ",
571
+ delivery: "store-42",
572
+ }),
465
573
  });
466
574
  const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
467
575
  expect(res.headers.get("X-Cache-Segment")).toBe("desktop|r=RJ|delivery=store-42");
@@ -487,9 +595,439 @@ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
487
595
  it("keeps the existing hash format for segments using only known fields (back-compat)", async () => {
488
596
  const worker = createDecoWorkerEntry(HTML_SERVER_ENTRY, {
489
597
  observability: false,
490
- buildSegment: () => ({ device: "desktop", salesChannel: "1", flags: ["b", "a"] }),
598
+ buildSegment: () => ({
599
+ device: "desktop",
600
+ salesChannel: "1",
601
+ flags: ["b", "a"],
602
+ }),
491
603
  });
492
604
  const res = await worker.fetch(new Request("https://example.com/"), EMPTY_ENV, MOCK_CTX);
493
605
  expect(res.headers.get("X-Cache-Segment")).toBe("desktop|sc=1|f=a,b");
494
606
  });
495
607
  });
608
+
609
+ describe("draft preview (pull-based)", () => {
610
+ const POINTER = "abc.localhost:1234/api/o/decofile/m/main?token=t@v1";
611
+ const SANDBOX_URL = "http://abc.localhost:1234/api/o/decofile/m/main?token=t";
612
+ const DRAFT_BLOCKS = {
613
+ "pages-home": { name: "home", path: "/", title: "DRAFTED" },
614
+ };
615
+
616
+ // A serverEntry that reflects the currently-loaded home title, so a test can
617
+ // prove the draft override actually reached the render (not just the headers).
618
+ const REFLECT_ENTRY = {
619
+ fetch: async () => {
620
+ const { loadBlocks } = await import("@decocms/blocks/cms");
621
+ const home = (loadBlocks()["pages-home"] as { title?: string } | undefined) ?? {};
622
+ return new Response(home.title ?? "PUBLISHED", { status: 200 });
623
+ },
624
+ };
625
+
626
+ let realFetch: typeof fetch;
627
+ afterEach(async () => {
628
+ globalThis.fetch = realFetch;
629
+ const { setDraftPreviewHosts, clearDraftCache } = await import("@decocms/blocks/cms");
630
+ setDraftPreviewHosts([]);
631
+ clearDraftCache();
632
+ __resetKvHydrationStateForTests();
633
+ setBlocks({});
634
+ });
635
+
636
+ async function enable() {
637
+ const { setDraftPreviewHosts, clearDraftCache } = await import("@decocms/blocks/cms");
638
+ setBlocks({
639
+ "pages-home": { name: "home", path: "/", title: "PUBLISHED" },
640
+ });
641
+ setDraftPreviewHosts(["example.com"]);
642
+ clearDraftCache();
643
+ realFetch = globalThis.fetch;
644
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
645
+ if (String(input) === SANDBOX_URL) {
646
+ return new Response(JSON.stringify(DRAFT_BLOCKS), {
647
+ status: 200,
648
+ headers: { "content-type": "application/json" },
649
+ });
650
+ }
651
+ throw new Error(`unexpected fetch: ${String(input)}`);
652
+ }) as unknown as typeof fetch;
653
+ }
654
+
655
+ it("binds the draft, bypasses the edge cache, and stamps the anti-leak headers", async () => {
656
+ await enable();
657
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
658
+ observability: false,
659
+ });
660
+ const res = await worker.fetch(
661
+ new Request(`https://example.com/?__draft=${POINTER}`, {
662
+ headers: { host: "example.com" },
663
+ }),
664
+ EMPTY_ENV,
665
+ MOCK_CTX,
666
+ );
667
+
668
+ // The render saw the merged draft decofile.
669
+ expect(await res.text()).toBe("DRAFTED");
670
+ // Never cached under the shared URL.
671
+ expect(res.headers.get("X-Cache")).toBe("BYPASS");
672
+ expect(res.headers.get("Cache-Control")).toContain("no-store");
673
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
674
+ // Entry persists the navigation cookie.
675
+ const cookie = res.headers.get("set-cookie") ?? "";
676
+ expect(cookie).toContain("__deco_draft=");
677
+ expect(cookie).toContain("SameSite=None");
678
+ expect(cookie).toContain("Partitioned");
679
+ });
680
+
681
+ it("carries the draft across navigation via the cookie alone", async () => {
682
+ await enable();
683
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
684
+ observability: false,
685
+ });
686
+ const res = await worker.fetch(
687
+ new Request("https://example.com/other", {
688
+ headers: { host: "example.com", cookie: `__deco_draft=${POINTER}` },
689
+ }),
690
+ EMPTY_ENV,
691
+ MOCK_CTX,
692
+ );
693
+ expect(await res.text()).toBe("DRAFTED");
694
+ expect(res.headers.get("X-Cache")).toBe("BYPASS");
695
+ });
696
+
697
+ it("is structurally inert on a non-preview host", async () => {
698
+ await enable(); // allowlist = example.com only
699
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
700
+ observability: false,
701
+ });
702
+ const res = await worker.fetch(
703
+ new Request(`https://prod.example/?__draft=${POINTER}`, {
704
+ headers: { host: "prod.example" },
705
+ }),
706
+ EMPTY_ENV,
707
+ MOCK_CTX,
708
+ );
709
+ expect(await res.text()).toBe("PUBLISHED");
710
+ expect(res.headers.get("X-Robots-Tag")).toBeNull();
711
+ expect(res.headers.get("set-cookie")).toBeNull();
712
+ });
713
+
714
+ it("stays inert on any host when no allowlist is configured", async () => {
715
+ setBlocks({
716
+ "pages-home": { name: "home", path: "/", title: "PUBLISHED" },
717
+ });
718
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, {
719
+ observability: false,
720
+ });
721
+ const res = await worker.fetch(
722
+ new Request(`https://example.com/?__draft=${POINTER}`, {
723
+ headers: { host: "example.com" },
724
+ }),
725
+ EMPTY_ENV,
726
+ MOCK_CTX,
727
+ );
728
+ expect(await res.text()).toBe("PUBLISHED");
729
+ expect(res.headers.get("X-Robots-Tag")).toBeNull();
730
+ });
731
+ });
732
+
733
+ describe('cdnCacheControl: "serverfn-segment"', () => {
734
+ const BUILD = "abc123";
735
+ const ENV = { BUILD_HASH: BUILD };
736
+ // The token is a hash the server issues, so tests derive it the same way the
737
+ // worker does instead of hardcoding a shape.
738
+ const tok = (seg: string) => segmentToken(seg, false, BUILD) as string;
739
+
740
+ function worker(overrides: Record<string, unknown> = {}) {
741
+ return createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
742
+ observability: false,
743
+ cdnCacheControl: "serverfn-segment",
744
+ buildSegment: (req: Request) => ({
745
+ device: req.headers.get("user-agent")?.includes("iPhone")
746
+ ? ("mobile" as const)
747
+ : ("desktop" as const),
748
+ ...(req.headers.get("cookie")?.includes("auth=1") ? { loggedIn: true } : {}),
749
+ }),
750
+ ...overrides,
751
+ });
752
+ }
753
+
754
+ const sfnUrl = (marker?: string) =>
755
+ `https://example.com/_serverFn/loadCmsPage${marker ? `?__cseg=${marker}` : ""}`;
756
+
757
+ async function cdnHeader(url: string, headers: Record<string, string> = {}) {
758
+ const res = await worker().fetch(new Request(url, { headers }), ENV, MOCK_CTX);
759
+ return res.headers.get("CDN-Cache-Control");
760
+ }
761
+
762
+ it("releases the CDN when the marker matches the recomputed segment", async () => {
763
+ expect(await cdnHeader(sfnUrl(tok("desktop")))).toMatch(/^public, max-age=\d+$/);
764
+ });
765
+
766
+ it("keeps no-store without a marker (bot, curl, old client)", async () => {
767
+ expect(await cdnHeader(sfnUrl())).toBe("no-store");
768
+ });
769
+
770
+ it("keeps no-store when the marker is for another device", async () => {
771
+ // The forged/diverging case: a desktop request claiming a mobile entry
772
+ // would let the CDN serve mobile HTML to desktop.
773
+ expect(await cdnHeader(sfnUrl(tok("mobile")))).toBe("no-store");
774
+ });
775
+
776
+ it("keeps no-store when the marker is from an older build", async () => {
777
+ // Deploying does not purge the CDN, so a stale build token must not match.
778
+ expect(await cdnHeader(sfnUrl(segmentToken("desktop", false, "oldbuild") as string))).toBe(
779
+ "no-store",
780
+ );
781
+ });
782
+
783
+ it("keeps no-store when there is no build hash", async () => {
784
+ const res = await worker().fetch(new Request(sfnUrl(tok("desktop"))), {}, MOCK_CTX);
785
+ expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
786
+ });
787
+
788
+ it("keeps no-store for a bot UA even with a valid marker", async () => {
789
+ // Bots render every section eagerly (~10x payload). Sharing one CDN entry
790
+ // would serve that to humans, or the deferred one to crawlers.
791
+ expect(
792
+ await cdnHeader(sfnUrl(tok("desktop")), {
793
+ "user-agent": "Mozilla/5.0 (compatible; Googlebot/2.1)",
794
+ }),
795
+ ).toBe("no-store");
796
+ });
797
+
798
+ it("keeps no-store for an A/B cohort cookie even with a valid marker", async () => {
799
+ expect(
800
+ await cdnHeader(sfnUrl(tok("desktop")), {
801
+ cookie: "deco_segment=eyJhY3RpdmUiOlsiYSJdfQ==",
802
+ }),
803
+ ).toBe("no-store");
804
+ });
805
+
806
+ it("keeps no-store for a logged-in request", async () => {
807
+ expect(await cdnHeader(sfnUrl(tok("desktop")), { cookie: "auth=1" })).toBe("no-store");
808
+ });
809
+
810
+ it("keeps no-store on HTML documents — they carry no marker", async () => {
811
+ expect(await cdnHeader("https://example.com/some-category")).toBe("no-store");
812
+ });
813
+
814
+ // NOTE: the end-to-end geo case is not exercised here. `injectGeoCookies`
815
+ // rebuilds the Request, and `new Request(req, init)` only carries `cf`
816
+ // through on workerd — under Node it is dropped, so a test would assert the
817
+ // absence of geo rather than its handling. The unit case below covers the
818
+ // property that matters, and the real path is verified against a built site.
819
+ it("never lets two regions share a token", () => {
820
+ // The leak this guards: RJ and SP resolving to the same colo must not
821
+ // share an entry, since their regionalized pricing and stock differ.
822
+ expect(tok("desktop|geo=BR|RJ")).not.toBe(tok("desktop|geo=BR|SP"));
823
+ });
824
+
825
+ it("keeps no-store without buildSegment, since the logged-in bypass is inert", async () => {
826
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
827
+ observability: false,
828
+ cdnCacheControl: "serverfn-segment",
829
+ });
830
+ const res = await w.fetch(new Request(sfnUrl(tok("desktop"))), ENV, MOCK_CTX);
831
+ expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
832
+ });
833
+ });
834
+
835
+ describe("cdnCacheControl default", () => {
836
+ const BUILD_D = "abc123";
837
+ const tokD = (seg: string) => segmentToken(seg, false, BUILD_D) as string;
838
+
839
+ // The default flipped from "no-store" to "serverfn-segment". That is only
840
+ // safe because it is inert until a verified marker arrives — these two tests
841
+ // are what make that claim checkable rather than asserted in a comment.
842
+ it("is inert for a client that never sends a marker", async () => {
843
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
844
+ observability: false,
845
+ buildSegment: () => ({ device: "desktop" as const }),
846
+ });
847
+ const html = await w.fetch(
848
+ new Request("https://example.com/"),
849
+ { BUILD_HASH: BUILD_D },
850
+ MOCK_CTX,
851
+ );
852
+ expect(html.headers.get("CDN-Cache-Control")).toBe("no-store");
853
+
854
+ const sfn = await w.fetch(
855
+ new Request("https://example.com/_serverFn/loadCmsPage"),
856
+ { BUILD_HASH: BUILD_D },
857
+ MOCK_CTX,
858
+ );
859
+ expect(sfn.headers.get("CDN-Cache-Control")).toBe("no-store");
860
+ });
861
+
862
+ it("engages once the client sends a valid marker, with no site config", async () => {
863
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
864
+ observability: false,
865
+ buildSegment: () => ({ device: "desktop" as const }),
866
+ });
867
+ const res = await w.fetch(
868
+ new Request(`https://example.com/_serverFn/loadCmsPage?__cseg=${tokD("desktop")}`),
869
+ { BUILD_HASH: BUILD_D },
870
+ MOCK_CTX,
871
+ );
872
+ expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/);
873
+ });
874
+
875
+ it('still honours an explicit opt-out with "no-store"', async () => {
876
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
877
+ observability: false,
878
+ cdnCacheControl: "no-store",
879
+ buildSegment: () => ({ device: "desktop" as const }),
880
+ });
881
+ const res = await w.fetch(
882
+ new Request(`https://example.com/_serverFn/loadCmsPage?__cseg=${tokD("desktop")}`),
883
+ { BUILD_HASH: BUILD_D },
884
+ MOCK_CTX,
885
+ );
886
+ expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
887
+ });
888
+ });
889
+
890
+ describe("CDN-Cache-Control at the single response exit", () => {
891
+ it("defaults to no-store on early returns that never reach dressResponse", async () => {
892
+ // `?asJson` returns the fully resolved page — loaders run with the caller's
893
+ // cookies — and returns before dressResponse, so it expresses no opinion on
894
+ // CDN caching. Without a default here it would inherit whatever the CDN
895
+ // decides for a 200 with no cache directives.
896
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
897
+ const res = await w.fetch(
898
+ new Request("https://example.com/some-category?asJson"),
899
+ EMPTY_ENV,
900
+ MOCK_CTX,
901
+ );
902
+ expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
903
+ });
904
+
905
+ it("leaves a value dressResponse already decided", async () => {
906
+ // The cacheable path is the one branch that reasoned about whether the CDN
907
+ // key matches the worker key — the default must not stomp it.
908
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
909
+ observability: false,
910
+ cdnCacheControl: "match-profile",
911
+ deviceSpecificKeys: false,
912
+ geoCacheKey: "off",
913
+ });
914
+ const res = await w.fetch(
915
+ new Request("https://example.com/some-category"),
916
+ EMPTY_ENV,
917
+ MOCK_CTX,
918
+ );
919
+ expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/);
920
+ });
921
+ });
922
+
923
+ describe('cdnCacheControl: "match-profile" guard', () => {
924
+ it("is ignored while the cache key is segmented (deviceSpecificKeys defaults to true)", async () => {
925
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
926
+ observability: false,
927
+ cdnCacheControl: "match-profile",
928
+ });
929
+ const res = await w.fetch(new Request("https://example.com/some-category"), {}, MOCK_CTX);
930
+ expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
931
+ });
932
+
933
+ it("is honored only when the key really is the raw URL", async () => {
934
+ const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
935
+ observability: false,
936
+ cdnCacheControl: "match-profile",
937
+ deviceSpecificKeys: false,
938
+ geoCacheKey: "off",
939
+ });
940
+ const res = await w.fetch(new Request("https://example.com/some-category"), {}, MOCK_CTX);
941
+ expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/);
942
+ });
943
+ });
944
+
945
+ /**
946
+ * Fast-deploy hydration must run BEFORE `reconfigureAppsOnce()`.
947
+ *
948
+ * `reconfigureAppsOnce()` configures apps from the blocks it sees and then
949
+ * latches its promise forever. If it ran first, a `fastDeploy` build — whose
950
+ * bundled decofile is `{}` — would configure ZERO apps, and the only recovery
951
+ * is `setBlocks()`'s onChange listener, whose promise `setBlocks` drops
952
+ * unawaited. The request would proceed with no app loaders registered.
953
+ */
954
+ describe("fast-deploy hydration ordering", () => {
955
+ // Reset BEFORE as well: both flags are module-level and latching, so an
956
+ // earlier test in this file that fired a request leaves `kvHydrated` true
957
+ // and hydration would be skipped entirely here.
958
+ beforeEach(() => {
959
+ __resetKvHydrationStateForTests();
960
+ __resetAutoconfigStateForTests();
961
+ });
962
+
963
+ afterEach(() => {
964
+ __resetKvHydrationStateForTests();
965
+ __resetAutoconfigStateForTests();
966
+ setBlocks({});
967
+ });
968
+
969
+ it("configures apps from the KV snapshot, not the stubbed bundle", async () => {
970
+ const KV_BLOCKS = { "deco-test": { "@type": "test-app", token: "from-kv" } };
971
+ const ID = "sha-ordering";
972
+
973
+ /** Blocks each configure() call observed, in order. */
974
+ const seen: unknown[] = [];
975
+ const registry = [
976
+ {
977
+ blockKey: "deco-test",
978
+ module: () =>
979
+ Promise.resolve({
980
+ configure: (block: unknown) => {
981
+ seen.push(block);
982
+ return Promise.resolve({
983
+ name: "test-app",
984
+ manifest: { name: "test-app", loaders: {}, actions: {} },
985
+ state: {},
986
+ });
987
+ },
988
+ }),
989
+ },
990
+ ];
991
+
992
+ // autoconfigApps and its onChange listener are server-only guarded on
993
+ // `typeof document`. This suite runs under jsdom, so drop `document` for
994
+ // the duration to exercise the worker path.
995
+ const realDocument = globalThis.document;
996
+ delete (globalThis as { document?: unknown }).document;
997
+ try {
998
+ // Simulates the stubbed server bundle: autoconfigApps runs at module init
999
+ // with an empty decofile, exactly as it would in a fastDeploy build.
1000
+ setBlocks({});
1001
+ await autoconfigApps({}, registry as never);
1002
+ expect(seen).toEqual([]); // nothing to configure yet — this is the trap
1003
+
1004
+ const store = new Map<string, string>([
1005
+ [snapshotKey(ID), JSON.stringify(KV_BLOCKS)],
1006
+ [revisionKey(ID), computeRevision(KV_BLOCKS)],
1007
+ ]);
1008
+ const env = {
1009
+ DECO_KV: {
1010
+ get: (k: string) => Promise.resolve(store.get(k) ?? null),
1011
+ put: () => Promise.resolve(),
1012
+ delete: () => Promise.resolve(),
1013
+ },
1014
+ DECO_FAST_DEPLOY: "1",
1015
+ DECO_DEPLOYMENT_ID: ID,
1016
+ };
1017
+
1018
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
1019
+ await worker.fetch(new Request("https://example.com/"), env, MOCK_CTX);
1020
+
1021
+ // The app must have been configured with the block that came from KV.
1022
+ // Not asserting a call COUNT: both the onChange listener and the
1023
+ // one-shot reinit legitimately configure, and how many passes happen is
1024
+ // not the contract. The contract is that no pass saw the empty bundle.
1025
+ // Deep equality, not identity: the snapshot arrives via JSON.parse, so
1026
+ // the block is a structurally-equal copy.
1027
+ expect(seen.length).toBeGreaterThan(0);
1028
+ for (const block of seen) expect(block).toEqual(KV_BLOCKS["deco-test"]);
1029
+ } finally {
1030
+ globalThis.document = realDocument;
1031
+ }
1032
+ });
1033
+ });