@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.
- package/package.json +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- package/src/vite/plugin.test.ts +125 -1
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
import { setBlocks } from "@decocms/blocks/cms";
|
|
2
|
-
import {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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", {
|
|
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", {
|
|
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: () => ({
|
|
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: () => ({
|
|
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
|
+
});
|