@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 +4 -4
- package/src/routes/cmsRoute.ts +129 -29
- package/src/sdk/workerEntry.test.ts +59 -46
- package/src/sdk/workerEntry.ts +50 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/tanstack",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
28
|
-
"@decocms/blocks-admin": "7.
|
|
29
|
-
"@decocms/blocks-cli": "7.
|
|
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"
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -21,20 +21,17 @@
|
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
379
|
-
??
|
|
380
|
-
|
|
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(
|
|
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
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
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
|
-
|
|
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: () =>
|
|
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
|
-
|
|
165
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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(
|
|
324
|
-
expect(
|
|
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
|
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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) {
|