@decocms/blocks 7.23.0 → 7.25.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 +1 -1
- package/src/cms/index.ts +30 -26
- package/src/cms/resolve.ts +6 -1
- package/src/cms/sectionLoaders.test.ts +76 -0
- package/src/cms/sectionLoaders.ts +55 -0
package/package.json
CHANGED
package/src/cms/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
|
|
2
|
+
export { applySectionConventions } from "./applySectionConventions";
|
|
1
3
|
export type { BlockSnapshot, BlockSource, KVNamespace } from "./blockSource";
|
|
2
4
|
export {
|
|
3
5
|
BUILD_HASH_ENV,
|
|
@@ -50,10 +52,11 @@ export type {
|
|
|
50
52
|
} from "./resolve";
|
|
51
53
|
export {
|
|
52
54
|
addSkipResolveType,
|
|
55
|
+
cacheDeferredRawProps,
|
|
56
|
+
clearCommerceLoaders,
|
|
53
57
|
evaluateMatcher,
|
|
54
58
|
extractSeoFromProps,
|
|
55
59
|
extractSeoFromSections,
|
|
56
|
-
cacheDeferredRawProps,
|
|
57
60
|
getAsyncRenderingConfig,
|
|
58
61
|
getDeferredRawProps,
|
|
59
62
|
isBot,
|
|
@@ -63,45 +66,24 @@ export {
|
|
|
63
66
|
registerBotPattern,
|
|
64
67
|
registerCommerceLoader,
|
|
65
68
|
registerCommerceLoaders,
|
|
66
|
-
unregisterCommerceLoader,
|
|
67
|
-
clearCommerceLoaders,
|
|
68
|
-
registerMatcher,
|
|
69
69
|
registerEagerSections,
|
|
70
|
+
registerMatcher,
|
|
70
71
|
registerNeverDeferSections,
|
|
71
72
|
registerSeoSections,
|
|
72
73
|
resolveDecoPage,
|
|
73
|
-
resolvePageSections,
|
|
74
|
-
resolvePageSeoBlock,
|
|
75
74
|
resolveDeferredSection,
|
|
76
75
|
resolveDeferredSectionFull,
|
|
76
|
+
resolvePageSections,
|
|
77
|
+
resolvePageSeoBlock,
|
|
77
78
|
resolveValue,
|
|
78
79
|
setAsyncRenderingConfig,
|
|
79
80
|
setDanglingReferenceHandler,
|
|
80
81
|
setResolveErrorHandler,
|
|
82
|
+
unregisterCommerceLoader,
|
|
81
83
|
WELL_KNOWN_TYPES,
|
|
82
84
|
} from "./resolve";
|
|
83
|
-
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
84
85
|
export type { SectionLoaderContext } from "./sectionLoaderContext";
|
|
85
86
|
export { buildSectionLoaderContext } from "./sectionLoaderContext";
|
|
86
|
-
export {
|
|
87
|
-
isLayoutSection,
|
|
88
|
-
registerCacheableSections,
|
|
89
|
-
registerLayoutSections,
|
|
90
|
-
registerSectionLoader,
|
|
91
|
-
registerSectionLoaders,
|
|
92
|
-
runSectionLoaders,
|
|
93
|
-
runSingleSectionLoader,
|
|
94
|
-
unregisterLayoutSections,
|
|
95
|
-
} from "./sectionLoaders";
|
|
96
|
-
export {
|
|
97
|
-
compose,
|
|
98
|
-
withDevice,
|
|
99
|
-
withMobile,
|
|
100
|
-
withSearchParam,
|
|
101
|
-
withSectionLoader,
|
|
102
|
-
} from "./sectionMixins";
|
|
103
|
-
export type { ApplySectionConventionsInput, SectionMetaEntry } from "./applySectionConventions";
|
|
104
|
-
export { applySectionConventions } from "./applySectionConventions";
|
|
105
87
|
export type {
|
|
106
88
|
ActionConfig,
|
|
107
89
|
AppSchemas,
|
|
@@ -123,3 +105,25 @@ export {
|
|
|
123
105
|
registerMatcherSchema,
|
|
124
106
|
registerMatcherSchemas,
|
|
125
107
|
} from "./schema";
|
|
108
|
+
export type { SectionLoaderFn } from "./sectionLoaders";
|
|
109
|
+
export {
|
|
110
|
+
getDegradedSections,
|
|
111
|
+
isCriticalSection,
|
|
112
|
+
isLayoutSection,
|
|
113
|
+
markSectionDegraded,
|
|
114
|
+
registerCacheableSections,
|
|
115
|
+
registerLayoutSections,
|
|
116
|
+
registerNonCriticalSections,
|
|
117
|
+
registerSectionLoader,
|
|
118
|
+
registerSectionLoaders,
|
|
119
|
+
runSectionLoaders,
|
|
120
|
+
runSingleSectionLoader,
|
|
121
|
+
unregisterLayoutSections,
|
|
122
|
+
} from "./sectionLoaders";
|
|
123
|
+
export {
|
|
124
|
+
compose,
|
|
125
|
+
withDevice,
|
|
126
|
+
withMobile,
|
|
127
|
+
withSearchParam,
|
|
128
|
+
withSectionLoader,
|
|
129
|
+
} from "./sectionMixins";
|
package/src/cms/resolve.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
|
13
13
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
14
14
|
import { findPageByPath, loadBlocks } from "./loader";
|
|
15
15
|
import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
|
|
16
|
-
import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
|
|
16
|
+
import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders";
|
|
17
17
|
|
|
18
18
|
// globalThis-backed: share state across Vite server function split modules
|
|
19
19
|
const G = globalThis as any;
|
|
@@ -911,6 +911,11 @@ async function internalResolve(value: unknown, rctx: ResolveContext): Promise<un
|
|
|
911
911
|
return await commerceLoader(resolvedProps);
|
|
912
912
|
} catch (error) {
|
|
913
913
|
onResolveError(error, resolveType, "Commerce loader");
|
|
914
|
+
// Commerce loaders (VTEX product/PLP/PDP data) are the primary source of
|
|
915
|
+
// page-body data — this is exactly the layer that fails during a VTEX
|
|
916
|
+
// outage. Flag the page degraded so the edge won't cache the empty
|
|
917
|
+
// result as a healthy 200 (see X-Deco-Degraded in cmsRoute/workerEntry).
|
|
918
|
+
markSectionDegraded(resolveType);
|
|
914
919
|
return null;
|
|
915
920
|
}
|
|
916
921
|
}
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { configureTracer, type Span } from "../sdk/observability";
|
|
3
|
+
import { RequestContext } from "../sdk/requestContext";
|
|
3
4
|
import type { ResolvedSection } from "./resolve";
|
|
4
5
|
import {
|
|
6
|
+
getDegradedSections,
|
|
5
7
|
isLayoutSection,
|
|
6
8
|
registerCacheableSections,
|
|
7
9
|
registerLayoutSections,
|
|
10
|
+
registerNonCriticalSections,
|
|
8
11
|
registerSectionLoader,
|
|
9
12
|
registerSectionLoaders,
|
|
10
13
|
runSectionLoaders,
|
|
@@ -19,6 +22,7 @@ beforeEach(() => {
|
|
|
19
22
|
G.__deco.sectionLoaderRegistry.clear();
|
|
20
23
|
G.__deco.layoutSections.clear();
|
|
21
24
|
G.__deco.cacheableSections.clear();
|
|
25
|
+
G.__deco.nonCriticalSections.clear();
|
|
22
26
|
});
|
|
23
27
|
|
|
24
28
|
const makeSection = (component: string, props: Record<string, unknown> = {}): ResolvedSection => ({
|
|
@@ -28,6 +32,78 @@ const makeSection = (component: string, props: Record<string, unknown> = {}): Re
|
|
|
28
32
|
index: 0,
|
|
29
33
|
});
|
|
30
34
|
|
|
35
|
+
describe("section degradation tracking (anti-cache-poisoning)", () => {
|
|
36
|
+
const failing = async () => {
|
|
37
|
+
throw new Error("VTEX down");
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
it("marks a critical section as degraded when its loader throws", async () => {
|
|
41
|
+
registerSectionLoader("site/sections/ProductShelf.tsx", failing);
|
|
42
|
+
const section = makeSection("site/sections/ProductShelf.tsx");
|
|
43
|
+
const request = new Request("https://store.com/p");
|
|
44
|
+
|
|
45
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
46
|
+
// Loader throws → section degrades to raw props (does not reject).
|
|
47
|
+
const result = await runSingleSectionLoader(section, request);
|
|
48
|
+
expect(result.component).toBe("site/sections/ProductShelf.tsx");
|
|
49
|
+
return getDegradedSections();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(degraded).toContain("site/sections/ProductShelf.tsx");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("does NOT mark a non-critical section as degraded", async () => {
|
|
56
|
+
registerSectionLoader("site/sections/Decorative.tsx", failing);
|
|
57
|
+
registerNonCriticalSections(["site/sections/Decorative.tsx"]);
|
|
58
|
+
const section = makeSection("site/sections/Decorative.tsx");
|
|
59
|
+
const request = new Request("https://store.com/p");
|
|
60
|
+
|
|
61
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
62
|
+
await runSingleSectionLoader(section, request);
|
|
63
|
+
return getDegradedSections();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(degraded).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does NOT mark a failing LAYOUT section as degraded (avoids site-wide poisoning)", async () => {
|
|
70
|
+
registerSectionLoader("site/sections/Header.tsx", failing);
|
|
71
|
+
registerLayoutSections(["site/sections/Header.tsx"]);
|
|
72
|
+
const section = makeSection("site/sections/Header.tsx");
|
|
73
|
+
const request = new Request("https://store.com/p");
|
|
74
|
+
|
|
75
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
76
|
+
await runSingleSectionLoader(section, request);
|
|
77
|
+
return getDegradedSections();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// A flaky Header/Footer must not flip every page to X-Deco-Degraded.
|
|
81
|
+
expect(degraded).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does not flag a page whose loaders all succeed", async () => {
|
|
85
|
+
registerSectionLoader("site/sections/Ok.tsx", async (p: Record<string, unknown>) => p);
|
|
86
|
+
const section = makeSection("site/sections/Ok.tsx");
|
|
87
|
+
const request = new Request("https://store.com/p");
|
|
88
|
+
|
|
89
|
+
const degraded = await RequestContext.run(request, async () => {
|
|
90
|
+
await runSingleSectionLoader(section, request);
|
|
91
|
+
return getDegradedSections();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(degraded).toEqual([]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("is a no-op outside a request scope (does not throw)", async () => {
|
|
98
|
+
registerSectionLoader("site/sections/NoScope.tsx", failing);
|
|
99
|
+
const section = makeSection("site/sections/NoScope.tsx");
|
|
100
|
+
const request = new Request("https://store.com/p");
|
|
101
|
+
// No RequestContext.run wrapper — must not throw.
|
|
102
|
+
await expect(runSingleSectionLoader(section, request)).resolves.toBeTruthy();
|
|
103
|
+
expect(getDegradedSections()).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
31
107
|
describe("runSingleSectionLoader — page context injection", () => {
|
|
32
108
|
it("injects __pageUrl and __pagePath into loader props", async () => {
|
|
33
109
|
// Two-arg signature so loader.mock.calls[0] types as [props, req].
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* inside the TanStack Start server function.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
12
13
|
import { getCacheProfile } from "../sdk/cacheHeaders";
|
|
13
14
|
import { djb2 } from "../sdk/djb2";
|
|
14
15
|
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
@@ -33,9 +34,56 @@ if (!G.__deco) G.__deco = {};
|
|
|
33
34
|
if (!G.__deco.sectionLoaderRegistry) G.__deco.sectionLoaderRegistry = new Map();
|
|
34
35
|
if (!G.__deco.layoutSections) G.__deco.layoutSections = new Set();
|
|
35
36
|
if (!G.__deco.cacheableSections) G.__deco.cacheableSections = new Map();
|
|
37
|
+
if (!G.__deco.nonCriticalSections) G.__deco.nonCriticalSections = new Set();
|
|
36
38
|
|
|
37
39
|
const loaderRegistry: Map<string, SectionLoaderFn> = G.__deco.sectionLoaderRegistry;
|
|
38
40
|
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Degradation tracking (anti-cache-poisoning)
|
|
43
|
+
//
|
|
44
|
+
// When a section loader throws, the section renders with raw (un-enriched)
|
|
45
|
+
// props — e.g. a product shelf with no products during a VTEX outage. Today
|
|
46
|
+
// that degraded page is emitted as a healthy 200 and the edge caches it for the
|
|
47
|
+
// full retention window, so stale-if-error never fires and users see an empty
|
|
48
|
+
// page for hours. We record each CRITICAL section degradation on a
|
|
49
|
+
// request-scoped collector; the CMS route reads it and emits `X-Deco-Degraded`
|
|
50
|
+
// so the worker refuses to cache the broken page and serves stale instead.
|
|
51
|
+
//
|
|
52
|
+
// Every section is critical by default (a registered loader failing means
|
|
53
|
+
// intended data is missing). Decorative sections whose empty state is harmless
|
|
54
|
+
// can opt out via `registerNonCriticalSections`.
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
const DEGRADED_BAG_KEY = "deco:degraded-sections";
|
|
58
|
+
const nonCriticalSections: Set<string> = G.__deco.nonCriticalSections;
|
|
59
|
+
|
|
60
|
+
/** Mark section keys whose loader failure should NOT make the page uncacheable. */
|
|
61
|
+
export function registerNonCriticalSections(keys: string[]): void {
|
|
62
|
+
for (const k of keys) nonCriticalSections.add(k);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A section is critical (its failure degrades the page) unless opted out. */
|
|
66
|
+
export function isCriticalSection(key: string): boolean {
|
|
67
|
+
return !nonCriticalSections.has(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Record that a critical section rendered with raw props because its loader
|
|
72
|
+
* threw. No-op outside a request scope or for non-critical sections.
|
|
73
|
+
*/
|
|
74
|
+
export function markSectionDegraded(component: string): void {
|
|
75
|
+
if (!isCriticalSection(component)) return;
|
|
76
|
+
const set = RequestContext.getBag<Set<string>>(DEGRADED_BAG_KEY) ?? new Set<string>();
|
|
77
|
+
set.add(component);
|
|
78
|
+
RequestContext.setBag(DEGRADED_BAG_KEY, set);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Component keys of critical sections that degraded during this request. */
|
|
82
|
+
export function getDegradedSections(): string[] {
|
|
83
|
+
const set = RequestContext.getBag<Set<string>>(DEGRADED_BAG_KEY);
|
|
84
|
+
return set ? [...set] : [];
|
|
85
|
+
}
|
|
86
|
+
|
|
39
87
|
// ---------------------------------------------------------------------------
|
|
40
88
|
// Cacheable section loaders — SWR cache for section loader results
|
|
41
89
|
// ---------------------------------------------------------------------------
|
|
@@ -432,6 +480,11 @@ async function runSingleSectionLoaderImpl(
|
|
|
432
480
|
result = await resolveLayoutSection(section, wrapped, request);
|
|
433
481
|
} catch (error) {
|
|
434
482
|
console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
|
|
483
|
+
// Deliberately NOT marked degraded: layout sections (Header/Footer/Theme)
|
|
484
|
+
// render on every page, so a flaky layout loader would flip the whole
|
|
485
|
+
// site to X-Deco-Degraded and defeat edge caching everywhere. A failed
|
|
486
|
+
// layout renders raw chrome, which is acceptable — page-body data
|
|
487
|
+
// integrity (product shelves/PLP/PDP) is what the degraded signal guards.
|
|
435
488
|
result = section;
|
|
436
489
|
}
|
|
437
490
|
} else {
|
|
@@ -441,6 +494,7 @@ async function runSingleSectionLoaderImpl(
|
|
|
441
494
|
result = await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
|
|
442
495
|
} catch (error) {
|
|
443
496
|
console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
|
|
497
|
+
markSectionDegraded(section.component);
|
|
444
498
|
result = section;
|
|
445
499
|
}
|
|
446
500
|
} else {
|
|
@@ -449,6 +503,7 @@ async function runSingleSectionLoaderImpl(
|
|
|
449
503
|
result = { ...section, props: enrichedProps };
|
|
450
504
|
} catch (error) {
|
|
451
505
|
console.error(`[SectionLoader] Error in "${section.component}":`, error);
|
|
506
|
+
markSectionDegraded(section.component);
|
|
452
507
|
result = section;
|
|
453
508
|
}
|
|
454
509
|
}
|