@decocms/tanstack 7.22.2 → 7.24.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.22.2",
3
+ "version": "7.24.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -24,9 +24,9 @@
24
24
  "lint:unused": "knip"
25
25
  },
26
26
  "dependencies": {
27
- "@decocms/blocks": "7.22.2",
28
- "@decocms/blocks-admin": "7.22.2",
29
- "@decocms/blocks-cli": "7.22.2",
27
+ "@decocms/blocks": "7.24.0",
28
+ "@decocms/blocks-admin": "7.24.0",
29
+ "@decocms/blocks-cli": "7.24.0",
30
30
  "@deco-cx/warp-node": "^0.3.16",
31
31
  "fast-json-patch": "^3.1.0",
32
32
  "ws": "^8.18.0"
@@ -21,20 +21,17 @@
21
21
  * ```
22
22
  */
23
23
 
24
- import { createServerFn } from "@tanstack/react-start";
25
- import {
26
- getCookies,
27
- getRequest,
28
- getRequestHeader,
29
- getRequestUrl,
30
- setCookie,
31
- setResponseHeader,
32
- } from "@tanstack/react-start/server";
33
- import { createElement } from "react";
24
+ import type {
25
+ DeferredSection,
26
+ MatcherContext,
27
+ PageSeo,
28
+ ResolvedSection,
29
+ } from "@decocms/blocks/cms";
34
30
  import {
35
31
  extractSeoFromProps,
36
32
  extractSeoFromSections,
37
33
  getDeferredRawProps,
34
+ getDegradedSections,
38
35
  getSiteSeo,
39
36
  preloadSectionComponents,
40
37
  reExtractRawProps,
@@ -44,13 +41,6 @@ import {
44
41
  runSectionLoaders,
45
42
  runSingleSectionLoader,
46
43
  } from "@decocms/blocks/cms";
47
- import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "@decocms/blocks/cms";
48
- import {
49
- parseSegmentCookie,
50
- SEGMENT_COOKIE,
51
- serializeSegmentCookie,
52
- type StoredFlag,
53
- } from "@decocms/blocks/sdk/flags";
54
44
  import { withTracing } from "@decocms/blocks/middleware/observability";
55
45
  import {
56
46
  type CacheProfileName,
@@ -58,8 +48,24 @@ import {
58
48
  detectCacheProfile,
59
49
  routeCacheDefaults,
60
50
  } from "@decocms/blocks/sdk/cacheHeaders";
51
+ import {
52
+ parseSegmentCookie,
53
+ SEGMENT_COOKIE,
54
+ type StoredFlag,
55
+ serializeSegmentCookie,
56
+ } from "@decocms/blocks/sdk/flags";
61
57
  import { normalizeUrlsInObject } from "@decocms/blocks/sdk/normalizeUrls";
62
58
  import { type Device, detectDevice } from "@decocms/blocks/sdk/useDevice";
59
+ import { createServerFn } from "@tanstack/react-start";
60
+ import {
61
+ getCookies,
62
+ getRequest,
63
+ getRequestHeader,
64
+ getRequestUrl,
65
+ setCookie,
66
+ setResponseHeader,
67
+ } from "@tanstack/react-start/server";
68
+ import { createElement } from "react";
63
69
  import { derivePageUrl, isClientNavigation } from "./pageUrl";
64
70
  import { dedupeGlobals, resolveSiteGlobals } from "./withSiteGlobals";
65
71
  import type { SiteGlobalRef } from "./withSiteGlobals";
@@ -221,11 +227,23 @@ async function loadCmsPageInternal(fullPath: string, resolveGlobals: boolean) {
221
227
  // Destructure seoSection out — it's an internal artifact, not serialized to client
222
228
  const { seoSection: _seo, ...pageData } = page;
223
229
 
230
+ // Anti-cache-poisoning: if a critical section loader failed, this page is
231
+ // rendering with raw/empty data. Flag the response so the edge refuses to
232
+ // cache the broken 200 and serves stale instead (see workerEntry.ts). Without
233
+ // this, the empty page would be cached as healthy for the whole retention
234
+ // window and stale-if-error would never fire.
235
+ const degraded = getDegradedSections();
236
+ if (degraded.length > 0) {
237
+ setResponseHeader("X-Deco-Degraded", "true");
238
+ console.warn(`[cms] page degraded — critical sections failed: ${degraded.join(", ")}`);
239
+ }
240
+
224
241
  return {
225
242
  ...pageData,
226
243
  resolvedSections: normalizeUrlsInObject(mergedSections),
227
244
  deferredSections: normalizeUrlsInObject(page.deferredSections),
228
245
  cacheProfile,
246
+ degraded: degraded.length > 0,
229
247
  pageUrl: urlWithSearch,
230
248
  pagePath: basePath,
231
249
  seo,
@@ -324,10 +342,20 @@ export const loadCmsHomePage = createServerFn({ method: "GET" })
324
342
 
325
343
  const { seoSection: _seo, ...pageData } = page;
326
344
 
345
+ // Same anti-cache-poisoning flag as loadCmsPageInternal — the homepage is the
346
+ // highest-traffic, most-cached route, so it must not be exempt. The actual
347
+ // X-Deco-Degraded header is emitted by the route `headers:` callback from
348
+ // this `degraded` field (a reliable document-response channel).
349
+ const degraded = getDegradedSections();
350
+ if (degraded.length > 0) {
351
+ console.warn(`[cms] home degraded — critical sections failed: ${degraded.join(", ")}`);
352
+ }
353
+
327
354
  return {
328
355
  ...pageData,
329
356
  resolvedSections: normalizeUrlsInObject(mergedSections),
330
357
  deferredSections: normalizeUrlsInObject(page.deferredSections),
358
+ degraded: degraded.length > 0,
331
359
  pagePath: "/",
332
360
  pageUrl: serverUrl.toString(),
333
361
  seo,
@@ -375,14 +403,17 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
375
403
  // Resolve rawProps: prefer client-provided (backward compat), then server cache,
376
404
  // then re-extract from the page as a last resort (handles cross-isolate cache miss
377
405
  // on Cloudflare Workers and TTL expiry for slow-scrolling users).
378
- const rawProps = clientRawProps
379
- ?? (index !== undefined ? getDeferredRawProps(pagePath, component, index) : null)
380
- ?? (index !== undefined
406
+ const rawProps =
407
+ clientRawProps ??
408
+ (index !== undefined ? getDeferredRawProps(pagePath, component, index) : null) ??
409
+ (index !== undefined
381
410
  ? await reExtractRawProps(pagePath, component, index, matcherCtx)
382
411
  : null);
383
412
 
384
413
  if (!rawProps) {
385
- console.warn(`[CMS] Deferred section cache miss: ${component} at index ${index} on ${pagePath}`);
414
+ console.warn(
415
+ `[CMS] Deferred section cache miss: ${component} at index ${index} on ${pagePath}`,
416
+ );
386
417
  return null;
387
418
  }
388
419
 
@@ -405,10 +436,18 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
405
436
  });
406
437
  const enriched = await runSingleSectionLoader(section, request);
407
438
 
408
- // Signal to the worker entry that this response is safe to edge-cache.
409
- // Without this header, POST _serverFn responses are passed through
410
- // without caching (checkout actions, invoke mutations, etc.).
411
- setResponseHeader("X-Deco-Cacheable", "true");
439
+ // Anti-cache-poisoning: if this deferred section's loader failed (e.g. an
440
+ // empty product shelf during a VTEX outage), do NOT mark it cacheable —
441
+ // otherwise the empty shelf would be stored in serverFnCache and served to
442
+ // every visitor for the full retention window. Flag it degraded instead.
443
+ if (getDegradedSections().length > 0) {
444
+ setResponseHeader("X-Deco-Degraded", "true");
445
+ } else {
446
+ // Signal to the worker entry that this response is safe to edge-cache.
447
+ // Without this header, POST _serverFn responses are passed through
448
+ // without caching (checkout actions, invoke mutations, etc.).
449
+ setResponseHeader("X-Deco-Cacheable", "true");
450
+ }
412
451
 
413
452
  return normalizeUrlsInObject(enriched);
414
453
  });
@@ -443,6 +482,43 @@ export function CmsPagePendingFallback() {
443
482
  );
444
483
  }
445
484
 
485
+ /**
486
+ * Default branded error boundary for CMS routes. Rendered when page resolution
487
+ * throws (as opposed to returning no match, which is a 404 / not-found path) —
488
+ * e.g. an uncaught throw in `resolveDecoPage` / `buildPageSeo` /
489
+ * `resolveSiteGlobals` during a VTEX outage. Without an `errorComponent` the
490
+ * loader rejection surfaces as a raw 500 white screen.
491
+ *
492
+ * This is the COLD-CACHE last resort: whenever a warm edge entry exists the
493
+ * worker serves it as `STALE-ERROR` before the request ever reaches here.
494
+ * Sites override via `cmsRouteConfig({ errorComponent })`.
495
+ */
496
+ export function CmsPageErrorFallback(props: { reset?: () => void }) {
497
+ return createElement(
498
+ "div",
499
+ { className: "min-h-[60vh] flex items-center justify-center px-4 text-center" },
500
+ createElement(
501
+ "div",
502
+ { className: "max-w-md flex flex-col items-center gap-4" },
503
+ createElement("h1", { className: "text-2xl font-bold" }, "Estamos com uma instabilidade"),
504
+ createElement(
505
+ "p",
506
+ { className: "opacity-70" },
507
+ "Não conseguimos carregar esta página agora. Tente novamente em instantes.",
508
+ ),
509
+ createElement(
510
+ "button",
511
+ {
512
+ type: "button",
513
+ className: "btn btn-primary",
514
+ onClick: () => (props.reset ? props.reset() : undefined),
515
+ },
516
+ "Tentar de novo",
517
+ ),
518
+ ),
519
+ );
520
+ }
521
+
446
522
  // ---------------------------------------------------------------------------
447
523
  // Route configuration factory
448
524
  // ---------------------------------------------------------------------------
@@ -462,6 +538,13 @@ export interface CmsRouteOptions {
462
538
  ignoreSearchParams?: string[];
463
539
  /** Custom pending component shown during SPA navigation. */
464
540
  pendingComponent?: () => any;
541
+ /**
542
+ * Custom error boundary rendered when the loader / page resolution throws.
543
+ * Defaults to {@link CmsPageErrorFallback} (branded "instability" page with a
544
+ * retry button) so an outage degrades gracefully instead of a raw 500.
545
+ * Receives TanStack Router's `{ error, reset }`.
546
+ */
547
+ errorComponent?: (props: { error: Error; reset: () => void }) => any;
465
548
  /**
466
549
  * Delay (ms) before showing the pending component during SPA navigation.
467
550
  * If the loader resolves before this threshold, no pending UI is shown.
@@ -497,6 +580,7 @@ export interface CmsRouteOptions {
497
580
  type CmsPageLoaderData = {
498
581
  name?: string;
499
582
  cacheProfile?: CacheProfileName;
583
+ degraded?: boolean;
500
584
  seo?: PageSeo;
501
585
  device?: Device;
502
586
  resolvedSections?: Array<{ component: string }>;
@@ -702,6 +786,7 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
702
786
  pendingMs = 200,
703
787
  pendingMinMs = 300,
704
788
  ssr: ssrMode,
789
+ errorComponent = CmsPageErrorFallback,
705
790
  resolveGlobals,
706
791
  } = options;
707
792
 
@@ -711,8 +796,7 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
711
796
  // Catch-all search validation: preserve all URL search params so they
712
797
  // reach loaderDeps. Without this, TanStack Router may strip unknown
713
798
  // params (e.g. ?q=, filter.*, page, sort) during SPA navigation.
714
- validateSearch: (search: Record<string, unknown>) =>
715
- search as Record<string, string>,
799
+ validateSearch: (search: Record<string, unknown>) => search as Record<string, string>,
716
800
 
717
801
  loaderDeps: ({ search }: { search: Record<string, string> }) => {
718
802
  const filtered = Object.fromEntries(
@@ -763,13 +847,21 @@ export function cmsRouteConfig(options: CmsRouteOptions) {
763
847
  ...(pendingComponent ? { pendingComponent } : {}),
764
848
  pendingMs,
765
849
  pendingMinMs,
850
+ errorComponent,
766
851
  ...(ssrMode !== undefined ? { ssr: ssrMode } : {}),
767
852
 
768
853
  ...routeCacheDefaults("product"),
769
854
 
770
855
  headers: ({ loaderData }: { loaderData?: CmsPageLoaderData }) => {
771
856
  const profile = loaderData?.cacheProfile ?? "listing";
772
- return cacheHeaders(profile);
857
+ const headers = cacheHeaders(profile);
858
+ // Emit the degraded flag on the DOCUMENT response via the route headers
859
+ // callback — the reliable channel (same one cacheHeaders rides), unlike
860
+ // setResponseHeader inside the server fn which may not reach the document.
861
+ // The worker treats a 200 + X-Deco-Degraded like a 5xx (no store, serve
862
+ // stale) to prevent caching an empty page during a VTEX outage.
863
+ if (loaderData?.degraded) headers["X-Deco-Degraded"] = "true";
864
+ return headers;
773
865
  },
774
866
 
775
867
  head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
@@ -793,6 +885,8 @@ export function cmsHomeRouteConfig(options: {
793
885
  pendingMs?: number;
794
886
  /** Minimum display time (ms) for pending component. Default: 300. */
795
887
  pendingMinMs?: number;
888
+ /** Error boundary for loader throws. Defaults to {@link CmsPageErrorFallback}. */
889
+ errorComponent?: (props: { error: Error; reset: () => void }) => any;
796
890
  /** See `CmsRouteOptions.resolveGlobals` — default `true`. */
797
891
  resolveGlobals?: boolean;
798
892
  }) {
@@ -802,6 +896,7 @@ export function cmsHomeRouteConfig(options: {
802
896
  siteName,
803
897
  pendingMs = 200,
804
898
  pendingMinMs = 300,
899
+ errorComponent = CmsPageErrorFallback,
805
900
  resolveGlobals,
806
901
  } = options;
807
902
 
@@ -822,8 +917,13 @@ export function cmsHomeRouteConfig(options: {
822
917
  ...(options.pendingComponent ? { pendingComponent: options.pendingComponent } : {}),
823
918
  pendingMs,
824
919
  pendingMinMs,
920
+ errorComponent,
825
921
  ...routeCacheDefaults("static"),
826
- headers: () => cacheHeaders("static"),
922
+ headers: ({ loaderData }: { loaderData?: CmsPageLoaderData }) => {
923
+ const headers = cacheHeaders("static");
924
+ if (loaderData?.degraded) headers["X-Deco-Degraded"] = "true";
925
+ return headers;
926
+ },
827
927
  head: ({ loaderData }: { loaderData?: CmsPageLoaderData }) =>
828
928
  buildHead(loaderData, siteName ?? defaultTitle, defaultTitle, defaultDescription),
829
929
  };
@@ -1,5 +1,6 @@
1
1
  import { setBlocks } from "@decocms/blocks/cms";
2
2
  import { afterEach, describe, expect, it } from "vitest";
3
+ import { __resetKvHydrationStateForTests } from "./kvHydration";
3
4
  import {
4
5
  buildGeoCacheParam,
5
6
  createDecoWorkerEntry,
@@ -9,7 +10,6 @@ import {
9
10
  detectLocationMatcher,
10
11
  injectGeoCookies,
11
12
  } from "./workerEntry";
12
- import { __resetKvHydrationStateForTests } from "./kvHydration";
13
13
 
14
14
  const EMPTY_ENV = {};
15
15
  const MOCK_CTX = {
@@ -59,10 +59,7 @@ describe("injectGeoCookies", () => {
59
59
  });
60
60
 
61
61
  it("strips cf-ipcity from the outgoing Request headers while preserving the value in __cf_geo_city cookie", () => {
62
- const req = makeRequest(
63
- { city: "Brasília", country: "BR" },
64
- { "cf-ipcity": "Brasília" },
65
- );
62
+ const req = makeRequest({ city: "Brasília", country: "BR" }, { "cf-ipcity": "Brasília" });
66
63
 
67
64
  const out = injectGeoCookies(req);
68
65
 
@@ -91,10 +88,7 @@ describe("injectGeoCookies", () => {
91
88
  });
92
89
 
93
90
  it("preserves a pre-existing cookie header", () => {
94
- const req = makeRequest(
95
- { region: "São Paulo" },
96
- { cookie: "vtex_segment=abc; another=xyz" },
97
- );
91
+ const req = makeRequest({ region: "São Paulo" }, { cookie: "vtex_segment=abc; another=xyz" });
98
92
 
99
93
  const out = injectGeoCookies(req);
100
94
 
@@ -161,8 +155,11 @@ describe("detectLocationMatcher", () => {
161
155
  it("returns true when decofile has a website/matchers/location.ts __resolveType", () => {
162
156
  const blocks = {
163
157
  "audiences/geo-audience.json": {
164
- "__resolveType": "website/flags/audience.ts",
165
- "matcher": { "__resolveType": "website/matchers/location.ts", "includeLocations": [{ "country": "BR" }] },
158
+ __resolveType: "website/flags/audience.ts",
159
+ matcher: {
160
+ __resolveType: "website/matchers/location.ts",
161
+ includeLocations: [{ country: "BR" }],
162
+ },
166
163
  },
167
164
  };
168
165
  expect(detectLocationMatcher(blocks)).toBe(true);
@@ -171,8 +168,8 @@ describe("detectLocationMatcher", () => {
171
168
  it("returns false when decofile has no location matcher", () => {
172
169
  const blocks = {
173
170
  "audiences/device-audience.json": {
174
- "__resolveType": "website/flags/audience.ts",
175
- "matcher": { "__resolveType": "website/matchers/device.ts" },
171
+ __resolveType: "website/flags/audience.ts",
172
+ matcher: { __resolveType: "website/matchers/device.ts" },
176
173
  },
177
174
  };
178
175
  expect(detectLocationMatcher(blocks)).toBe(false);
@@ -185,8 +182,8 @@ describe("detectLocationMatcher", () => {
185
182
  it("returns true when the matcher is nested deeply", () => {
186
183
  const blocks = {
187
184
  "pages/home.json": {
188
- "variant": {
189
- "matcher": { "__resolveType": "website/matchers/location.ts" },
185
+ variant: {
186
+ matcher: { __resolveType: "website/matchers/location.ts" },
190
187
  },
191
188
  },
192
189
  };
@@ -196,8 +193,8 @@ describe("detectLocationMatcher", () => {
196
193
  it("returns false when location.ts appears only in a non-resolveType string value (no false positive)", () => {
197
194
  const blocks = {
198
195
  "content/help.json": {
199
- "__resolveType": "website/sections/RichText.tsx",
200
- "body": "This page is controlled by website/matchers/location.ts for geo targeting.",
196
+ __resolveType: "website/sections/RichText.tsx",
197
+ body: "This page is controlled by website/matchers/location.ts for geo targeting.",
201
198
  },
202
199
  };
203
200
  expect(detectLocationMatcher(blocks)).toBe(false);
@@ -218,11 +215,7 @@ describe("CMS redirects", () => {
218
215
  },
219
216
  });
220
217
  const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
221
- const res = await worker.fetch(
222
- new Request("https://example.com/old"),
223
- EMPTY_ENV,
224
- MOCK_CTX,
225
- );
218
+ const res = await worker.fetch(new Request("https://example.com/old"), EMPTY_ENV, MOCK_CTX);
226
219
  expect(res.status).toBe(301);
227
220
  expect(res.headers.get("Location")).toBe("/new");
228
221
  });
@@ -252,11 +245,7 @@ describe("CMS redirects", () => {
252
245
  },
253
246
  });
254
247
  const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
255
- const res = await worker.fetch(
256
- new Request("https://example.com/promo"),
257
- EMPTY_ENV,
258
- MOCK_CTX,
259
- );
248
+ const res = await worker.fetch(new Request("https://example.com/promo"), EMPTY_ENV, MOCK_CTX);
260
249
  expect(res.status).toBe(302);
261
250
  expect(res.headers.get("Location")).toBe("/promo%C3%A7%C3%A3o");
262
251
  });
@@ -269,11 +258,7 @@ describe("CMS redirects", () => {
269
258
  },
270
259
  });
271
260
  const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
272
- const res = await worker.fetch(
273
- new Request("https://example.com/promo"),
274
- EMPTY_ENV,
275
- MOCK_CTX,
276
- );
261
+ const res = await worker.fetch(new Request("https://example.com/promo"), EMPTY_ENV, MOCK_CTX);
277
262
  expect(res.status).toBe(302);
278
263
  expect(res.headers.get("Location")).toBe("/sale");
279
264
  });
@@ -286,11 +271,7 @@ describe("CMS redirects", () => {
286
271
  },
287
272
  });
288
273
  const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
289
- const res = await worker.fetch(
290
- new Request("https://example.com/other"),
291
- EMPTY_ENV,
292
- MOCK_CTX,
293
- );
274
+ const res = await worker.fetch(new Request("https://example.com/other"), EMPTY_ENV, MOCK_CTX);
294
275
  expect(res.status).toBe(200);
295
276
  });
296
277
 
@@ -299,11 +280,7 @@ describe("CMS redirects", () => {
299
280
  const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
300
281
 
301
282
  // First request with no redirects — falls through
302
- const res1 = await worker.fetch(
303
- new Request("https://example.com/v1"),
304
- EMPTY_ENV,
305
- MOCK_CTX,
306
- );
283
+ const res1 = await worker.fetch(new Request("https://example.com/v1"), EMPTY_ENV, MOCK_CTX);
307
284
  expect(res1.status).toBe(200);
308
285
 
309
286
  // Hot-reload: add a redirect
@@ -315,13 +292,49 @@ describe("CMS redirects", () => {
315
292
  });
316
293
 
317
294
  // Same path should now redirect
318
- const res2 = await worker.fetch(
319
- new Request("https://example.com/v1"),
295
+ const res2 = await worker.fetch(new Request("https://example.com/v1"), EMPTY_ENV, MOCK_CTX);
296
+ expect(res2.status).toBe(301);
297
+ expect(res2.headers.get("Location")).toBe("/v2");
298
+ });
299
+ });
300
+
301
+ describe("degraded origin (anti-cache-poisoning)", () => {
302
+ afterEach(() => {
303
+ setBlocks({});
304
+ __resetKvHydrationStateForTests();
305
+ });
306
+
307
+ it("never caches a degraded 200 and marks it no-store", async () => {
308
+ setBlocks({});
309
+ const degradedEntry = {
310
+ fetch: async () =>
311
+ new Response("empty shelf", {
312
+ status: 200,
313
+ headers: { "X-Deco-Degraded": "true" },
314
+ }),
315
+ };
316
+ const worker = createDecoWorkerEntry(degradedEntry, { observability: false });
317
+ const res = await worker.fetch(
318
+ new Request("https://example.com/some-category"),
320
319
  EMPTY_ENV,
321
320
  MOCK_CTX,
322
321
  );
323
- expect(res2.status).toBe(301);
324
- expect(res2.headers.get("Location")).toBe("/v2");
322
+ expect(res.status).toBe(200);
323
+ expect(res.headers.get("X-Cache")).toBe("BYPASS");
324
+ expect(res.headers.get("X-Cache-Reason")).toBe("degraded");
325
+ expect(res.headers.get("Cache-Control")).toContain("no-store");
326
+ });
327
+
328
+ it("serves a healthy 200 origin without the degraded bypass", async () => {
329
+ setBlocks({});
330
+ const worker = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { observability: false });
331
+ const res = await worker.fetch(
332
+ new Request("https://example.com/some-category"),
333
+ EMPTY_ENV,
334
+ MOCK_CTX,
335
+ );
336
+ expect(res.status).toBe(200);
337
+ expect(res.headers.get("X-Cache-Reason")).not.toBe("degraded");
325
338
  });
326
339
  });
327
340
 
@@ -25,12 +25,10 @@
25
25
  * ```
26
26
  */
27
27
 
28
- import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
29
- import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
30
28
  import {
29
+ getRevision,
31
30
  isBot,
32
31
  loadBlocks,
33
- getRevision,
34
32
  type MatcherContext,
35
33
  onChange,
36
34
  resolveDecoPage,
@@ -39,7 +37,6 @@ import {
39
37
  } from "@decocms/blocks/cms";
40
38
  import { DECO_MATCHERS_OVERRIDE_PARAM } from "@decocms/blocks/matchers/override";
41
39
  import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
42
- import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
43
40
  import {
44
41
  type CacheProfileName,
45
42
  cacheHeaders,
@@ -49,9 +46,8 @@ import {
49
46
  getCacheProfile,
50
47
  serverFnPagePath,
51
48
  } from "@decocms/blocks/sdk/cacheHeaders";
49
+ import { isDevMode } from "@decocms/blocks/sdk/env";
52
50
  import { parseSegmentCookie, SEGMENT_COOKIE, segmentCacheToken } from "@decocms/blocks/sdk/flags";
53
- import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
54
- import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
55
51
  import {
56
52
  getActiveSpan,
57
53
  logRequest,
@@ -66,13 +62,17 @@ import {
66
62
  instrumentWorker,
67
63
  type OtelOptions,
68
64
  } from "@decocms/blocks/sdk/otel";
69
- import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
70
65
  import { setRuntimeEnv } from "@decocms/blocks/sdk/otelAdapters";
71
66
  import { parseTraceparent } from "@decocms/blocks/sdk/otelHttpTracer";
67
+ import { loadRedirects, matchRedirect, type RedirectMap } from "@decocms/blocks/sdk/redirects";
72
68
  import { RequestContext } from "@decocms/blocks/sdk/requestContext";
73
69
  import { cleanPathForCacheKey } from "@decocms/blocks/sdk/urlUtils";
74
70
  import { type Device, isMobileUA } from "@decocms/blocks/sdk/useDevice";
75
- import { isDevMode } from "@decocms/blocks/sdk/env";
71
+ import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
72
+ import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
73
+ import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
74
+ import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
75
+ import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
76
76
 
77
77
  /**
78
78
  * Build-time identifier injected by `decoVitePlugin()` (see
@@ -1074,10 +1074,7 @@ export function createDecoWorkerEntry(
1074
1074
  // (skuId/idsku) that the loader ignores — otherwise `/p?skuId=X` and `/p`
1075
1075
  // get distinct keys and every variant-carrying PDP→PDP nav MISSes, even
1076
1076
  // though the resolved response is identical. Keeps PLP filter params intact.
1077
- if (
1078
- url.pathname.startsWith("/_serverFn/") ||
1079
- url.pathname.startsWith("/_server/")
1080
- ) {
1077
+ if (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/")) {
1081
1078
  const payload = url.searchParams.get("payload");
1082
1079
  if (payload) {
1083
1080
  url.searchParams.set("payload", canonicalizeServerFnPayloadForCacheKey(payload));
@@ -1122,8 +1119,8 @@ export function createDecoWorkerEntry(
1122
1119
  // diverge. `/_serverFn` (SPA-nav data) is excluded: it has its own keying
1123
1120
  // and stays eager via isClientNavigation, not this bucket.
1124
1121
  const secFetchDest = request.headers.get("sec-fetch-dest");
1125
- const isServerFnPath = url.pathname.startsWith("/_serverFn/") ||
1126
- url.pathname.startsWith("/_server/");
1122
+ const isServerFnPath =
1123
+ url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/");
1127
1124
  if (!isServerFnPath && secFetchDest === "empty") {
1128
1125
  url.searchParams.set("__fetch", "1");
1129
1126
  }
@@ -1135,7 +1132,9 @@ export function createDecoWorkerEntry(
1135
1132
  // keep sharing one entry. A freshly-assigned visitor sets the cookie on the
1136
1133
  // response, which is not a "safe" cookie, so that one response bypasses the
1137
1134
  // cache and never poisons the shared entry.
1138
- const flagToken = segmentCacheToken(parseSegmentCookie(readRequestCookie(request, SEGMENT_COOKIE)));
1135
+ const flagToken = segmentCacheToken(
1136
+ parseSegmentCookie(readRequestCookie(request, SEGMENT_COOKIE)),
1137
+ );
1139
1138
  if (flagToken) {
1140
1139
  url.searchParams.set("__abf", flagToken);
1141
1140
  }
@@ -2129,7 +2128,10 @@ export function createDecoWorkerEntry(
2129
2128
  ctx.waitUntil(
2130
2129
  Promise.resolve(serverEntry.fetch(request, env, ctx))
2131
2130
  .then((origin) => {
2132
- if (origin.status === 200) {
2131
+ // Never overwrite a good entry with a degraded 200 (a critical
2132
+ // section failed) — that would poison the cache. Leave the stale
2133
+ // entry in place until a healthy revalidation succeeds.
2134
+ if (origin.status === 200 && origin.headers.get("X-Deco-Degraded") !== "true") {
2133
2135
  // Only cache if response has no cookies or only safe cookies.
2134
2136
  // Strip safe cookies from the cached copy.
2135
2137
  if (hasOnlySafeCookies(origin, safeCookieSet)) {
@@ -2247,6 +2249,38 @@ export function createDecoWorkerEntry(
2247
2249
  return resp;
2248
2250
  }
2249
2251
 
2252
+ // Degraded origin — a critical section loader failed, so this 200 carries
2253
+ // raw/empty data (e.g. an empty product shelf during a VTEX outage). Treat
2254
+ // it like a 5xx for caching: prefer a warm stale entry (STALE-ERROR) and
2255
+ // NEVER store it. Without this, the broken page would be cached as healthy
2256
+ // for the full retention window and stale-if-error would never fire —
2257
+ // turning a seconds-long blip into hours of empty pages.
2258
+ if (origin.headers.get("X-Deco-Degraded") === "true") {
2259
+ if (cached && edgeConfig.sie > 0) {
2260
+ const storedAtStr = cached.headers.get("X-Deco-Stored-At");
2261
+ const storedAt = storedAtStr ? Number(storedAtStr) : 0;
2262
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
2263
+ if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
2264
+ console.warn(
2265
+ `[edge-cache] Origin degraded, serving stale (age=${Math.round(ageSec)}s, sie=${edgeConfig.sie}s)`,
2266
+ );
2267
+ recordCacheMetric(true, profile, "STALE-ERROR", "edge");
2268
+ return dressResponse(cached, "STALE-ERROR", {
2269
+ "X-Cache-Age": String(Math.round(ageSec)),
2270
+ "X-Cache-Reason": "degraded",
2271
+ });
2272
+ }
2273
+ }
2274
+ // Cold cache — serve the degraded page but never persist it.
2275
+ const resp = new Response(origin.body, origin);
2276
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
2277
+ resp.headers.delete("CDN-Cache-Control");
2278
+ resp.headers.set("X-Cache", "BYPASS");
2279
+ resp.headers.set("X-Cache-Reason", "degraded");
2280
+ appendResourceHints(resp);
2281
+ return resp;
2282
+ }
2283
+
2250
2284
  const profileConfig = getCacheProfile(profile);
2251
2285
 
2252
2286
  if (!profileConfig.isPublic || profileConfig.edge.fresh === 0) {