@decocms/blocks 7.0.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 +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section Loader Registry
|
|
3
|
+
*
|
|
4
|
+
* Section loaders enrich resolved CMS props with server-side data
|
|
5
|
+
* (e.g., price simulations, device detection) that can't be done
|
|
6
|
+
* at the CMS resolution level.
|
|
7
|
+
*
|
|
8
|
+
* This runs AFTER resolveDecoPage and BEFORE React rendering,
|
|
9
|
+
* inside the TanStack Start server function.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getCacheProfile } from "../sdk/cacheHeaders";
|
|
13
|
+
import { djb2 } from "../sdk/djb2";
|
|
14
|
+
import { withInflightTimeout } from "../sdk/inflightTimeout";
|
|
15
|
+
import { withTracing } from "../sdk/observability";
|
|
16
|
+
import type { ResolvedSection } from "./resolve";
|
|
17
|
+
|
|
18
|
+
export type SectionLoaderFn = (
|
|
19
|
+
props: Record<string, unknown>,
|
|
20
|
+
req: Request,
|
|
21
|
+
) => Promise<Record<string, unknown>> | Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
// globalThis-backed: server function split modules need access
|
|
24
|
+
const G = globalThis as any;
|
|
25
|
+
if (!G.__deco) G.__deco = {};
|
|
26
|
+
if (!G.__deco.sectionLoaderRegistry) G.__deco.sectionLoaderRegistry = new Map();
|
|
27
|
+
if (!G.__deco.layoutSections) G.__deco.layoutSections = new Set();
|
|
28
|
+
if (!G.__deco.cacheableSections) G.__deco.cacheableSections = new Map();
|
|
29
|
+
|
|
30
|
+
const loaderRegistry: Map<string, SectionLoaderFn> = G.__deco.sectionLoaderRegistry;
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Cacheable section loaders — SWR cache for section loader results
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
interface CacheableSectionConfig {
|
|
37
|
+
maxAge: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CacheableSectionInput =
|
|
41
|
+
| CacheableSectionConfig
|
|
42
|
+
| import("../sdk/cacheHeaders").CacheProfileName;
|
|
43
|
+
|
|
44
|
+
function resolveSectionCacheConfig(input: CacheableSectionInput): CacheableSectionConfig {
|
|
45
|
+
if (typeof input === "string") {
|
|
46
|
+
const profile = getCacheProfile(input);
|
|
47
|
+
return { maxAge: profile.loader.fresh };
|
|
48
|
+
}
|
|
49
|
+
return input;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const cacheableSections: Map<string, CacheableSectionConfig> = G.__deco.cacheableSections;
|
|
53
|
+
|
|
54
|
+
interface SectionCacheEntry {
|
|
55
|
+
section: ResolvedSection;
|
|
56
|
+
createdAt: number;
|
|
57
|
+
refreshing: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sectionLoaderCache = new Map<string, SectionCacheEntry>();
|
|
61
|
+
const sectionLoaderInflight = new Map<string, Promise<ResolvedSection>>();
|
|
62
|
+
const MAX_SECTION_CACHE_ENTRIES = 200;
|
|
63
|
+
|
|
64
|
+
function evictSectionCacheIfNeeded() {
|
|
65
|
+
if (sectionLoaderCache.size <= MAX_SECTION_CACHE_ENTRIES) return;
|
|
66
|
+
const oldest = [...sectionLoaderCache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
67
|
+
const toDelete = oldest.slice(0, sectionLoaderCache.size - MAX_SECTION_CACHE_ENTRIES);
|
|
68
|
+
for (const [key] of toDelete) sectionLoaderCache.delete(key);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sectionCacheKey(component: string, props: Record<string, unknown>): string {
|
|
72
|
+
return `${component}::${djb2(JSON.stringify(props))}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register section components whose loader results should be cached.
|
|
77
|
+
* Uses SWR (stale-while-revalidate) semantics: stale results are returned
|
|
78
|
+
* immediately while a background refresh runs.
|
|
79
|
+
*
|
|
80
|
+
* Works for both eager sections (speeds up SSR) and deferred sections
|
|
81
|
+
* (speeds up individual fetch on scroll).
|
|
82
|
+
*/
|
|
83
|
+
export function registerCacheableSections(configs: Record<string, CacheableSectionInput>): void {
|
|
84
|
+
for (const [key, config] of Object.entries(configs)) {
|
|
85
|
+
cacheableSections.set(key, resolveSectionCacheConfig(config));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function runCacheableSectionLoader(
|
|
90
|
+
section: ResolvedSection,
|
|
91
|
+
loader: SectionLoaderFn,
|
|
92
|
+
request: Request,
|
|
93
|
+
config: CacheableSectionConfig,
|
|
94
|
+
): Promise<ResolvedSection> {
|
|
95
|
+
const key = sectionCacheKey(section.component, section.props as Record<string, unknown>);
|
|
96
|
+
|
|
97
|
+
const existing = sectionLoaderInflight.get(key);
|
|
98
|
+
if (existing) return existing;
|
|
99
|
+
|
|
100
|
+
const entry = sectionLoaderCache.get(key);
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
const isStale = entry ? now - entry.createdAt > config.maxAge : true;
|
|
103
|
+
|
|
104
|
+
if (entry && !isStale) {
|
|
105
|
+
return Promise.resolve(entry.section);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (entry && isStale && !entry.refreshing) {
|
|
109
|
+
entry.refreshing = true;
|
|
110
|
+
void Promise.resolve(loader(section.props as Record<string, unknown>, request))
|
|
111
|
+
.then((enrichedProps) => {
|
|
112
|
+
const enriched = { ...section, props: enrichedProps };
|
|
113
|
+
sectionLoaderCache.set(key, {
|
|
114
|
+
section: enriched,
|
|
115
|
+
createdAt: Date.now(),
|
|
116
|
+
refreshing: false,
|
|
117
|
+
});
|
|
118
|
+
})
|
|
119
|
+
.catch(() => {
|
|
120
|
+
entry.refreshing = false;
|
|
121
|
+
});
|
|
122
|
+
return Promise.resolve(entry.section);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (entry) return Promise.resolve(entry.section);
|
|
126
|
+
|
|
127
|
+
const promise = withInflightTimeout(
|
|
128
|
+
(async () => {
|
|
129
|
+
const enrichedProps = await loader(section.props as Record<string, unknown>, request);
|
|
130
|
+
const enriched = { ...section, props: enrichedProps };
|
|
131
|
+
sectionLoaderCache.set(key, {
|
|
132
|
+
section: enriched,
|
|
133
|
+
createdAt: Date.now(),
|
|
134
|
+
refreshing: false,
|
|
135
|
+
});
|
|
136
|
+
evictSectionCacheIfNeeded();
|
|
137
|
+
return enriched;
|
|
138
|
+
})(),
|
|
139
|
+
`sectionLoader ${key}`,
|
|
140
|
+
).finally(() => sectionLoaderInflight.delete(key));
|
|
141
|
+
|
|
142
|
+
sectionLoaderInflight.set(key, promise);
|
|
143
|
+
return promise;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Register a server-side loader for a specific section.
|
|
148
|
+
* The loader receives the CMS-resolved props and a Request,
|
|
149
|
+
* and returns enriched props.
|
|
150
|
+
*/
|
|
151
|
+
export function registerSectionLoader(sectionKey: string, loader: SectionLoaderFn): void {
|
|
152
|
+
loaderRegistry.set(sectionKey, loader);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Register multiple section loaders at once.
|
|
157
|
+
*
|
|
158
|
+
* Dev-only diagnostic: when a request-dependent loader (one built from
|
|
159
|
+
* `withDevice`/`withMobile`/`withSearchParam`, possibly through `compose`)
|
|
160
|
+
* is registered for a section that's also in `layoutSections`, the layout
|
|
161
|
+
* cache will serve the first visitor's variant to every viewer for
|
|
162
|
+
* `LAYOUT_CACHE_TTL` (5 min). We log a loud warning explaining the
|
|
163
|
+
* remediation options. See #206.
|
|
164
|
+
*/
|
|
165
|
+
export function registerSectionLoaders(loaders: Record<string, SectionLoaderFn>): void {
|
|
166
|
+
for (const [key, loader] of Object.entries(loaders)) {
|
|
167
|
+
loaderRegistry.set(key, loader);
|
|
168
|
+
}
|
|
169
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
170
|
+
for (const [key, loader] of Object.entries(loaders)) {
|
|
171
|
+
const requestDependent =
|
|
172
|
+
(loader as SectionLoaderFn & { __requestDependent?: boolean }).__requestDependent === true;
|
|
173
|
+
if (requestDependent && layoutSections.has(key)) {
|
|
174
|
+
console.warn(
|
|
175
|
+
`[SectionLoaders] "${key}" is registered as a layout section ` +
|
|
176
|
+
`(cached for 5min by component path) but its loader is request-` +
|
|
177
|
+
`dependent (withDevice/withMobile/withSearchParam). The first ` +
|
|
178
|
+
`visitor's variant will be served to all users for 5min. Fix: ` +
|
|
179
|
+
`(1) remove "export const layout = true" from the section, ` +
|
|
180
|
+
`(2) call unregisterLayoutSections(["${key}"]) in setup.ts ` +
|
|
181
|
+
`after applySectionConventions, or (3) move the request-` +
|
|
182
|
+
`dependent logic out of the layout loader.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Layout section cache — sections whose output doesn't change per-page
|
|
191
|
+
// (Header, Footer, Theme, etc.) are cached server-side so they aren't
|
|
192
|
+
// re-resolved and re-enriched on every navigation.
|
|
193
|
+
//
|
|
194
|
+
// Uses in-flight deduplication: if two requests try to resolve the same
|
|
195
|
+
// layout section concurrently, the second shares the first's Promise.
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
const layoutSections: Set<string> = G.__deco.layoutSections;
|
|
199
|
+
|
|
200
|
+
const LAYOUT_CACHE_TTL = 5 * 60_000; // 5 minutes
|
|
201
|
+
|
|
202
|
+
interface CachedSection {
|
|
203
|
+
section: ResolvedSection;
|
|
204
|
+
expiresAt: number;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const layoutCache = new Map<string, CachedSection>();
|
|
208
|
+
const layoutInflight = new Map<string, Promise<ResolvedSection>>();
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Register section keys that should be cached as layout sections.
|
|
212
|
+
* Layout sections (Header, Footer, etc.) are cached server-side
|
|
213
|
+
* for LAYOUT_CACHE_TTL to avoid redundant enrichment on every navigation.
|
|
214
|
+
*
|
|
215
|
+
* The cache key is the component path only — it does NOT include UA,
|
|
216
|
+
* cookies, or geo. Sections whose loader depends on those signals must
|
|
217
|
+
* not be layout-cached: see {@link unregisterLayoutSections} to opt a
|
|
218
|
+
* section out of the auto-discovery done by `applySectionConventions`.
|
|
219
|
+
*/
|
|
220
|
+
export function registerLayoutSections(keys: string[]): void {
|
|
221
|
+
for (const key of keys) {
|
|
222
|
+
layoutSections.add(key);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Remove section keys from the layout cache set. Use this to opt a section
|
|
228
|
+
* out of caching when `applySectionConventions` auto-registered it via
|
|
229
|
+
* `export const layout = true` but the section's loader is in fact request-
|
|
230
|
+
* dependent (Header with `withDevice()`, geo-aware promo, etc.).
|
|
231
|
+
*
|
|
232
|
+
* Call after `applySectionConventions` and before the first request.
|
|
233
|
+
*
|
|
234
|
+
* See #206 for the contamination bug this prevents.
|
|
235
|
+
*/
|
|
236
|
+
export function unregisterLayoutSections(keys: string[]): void {
|
|
237
|
+
for (const key of keys) {
|
|
238
|
+
layoutSections.delete(key);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Check if a section key is registered as a layout section. */
|
|
243
|
+
export function isLayoutSection(key: string): boolean {
|
|
244
|
+
return layoutSections.has(key);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getCachedLayout(component: string): ResolvedSection | null {
|
|
248
|
+
const entry = layoutCache.get(component);
|
|
249
|
+
if (!entry) return null;
|
|
250
|
+
if (Date.now() > entry.expiresAt) {
|
|
251
|
+
layoutCache.delete(component);
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
return entry.section;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function setCachedLayout(component: string, section: ResolvedSection): void {
|
|
258
|
+
layoutCache.set(component, {
|
|
259
|
+
section,
|
|
260
|
+
expiresAt: Date.now() + LAYOUT_CACHE_TTL,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Run a layout section's loader with in-flight dedup + TTL cache.
|
|
266
|
+
*/
|
|
267
|
+
function resolveLayoutSection(
|
|
268
|
+
section: ResolvedSection,
|
|
269
|
+
loader: SectionLoaderFn,
|
|
270
|
+
request: Request,
|
|
271
|
+
): Promise<ResolvedSection> {
|
|
272
|
+
const key = section.component;
|
|
273
|
+
|
|
274
|
+
const cached = getCachedLayout(key);
|
|
275
|
+
if (cached) return Promise.resolve(cached);
|
|
276
|
+
|
|
277
|
+
const existing = layoutInflight.get(key);
|
|
278
|
+
if (existing) return existing;
|
|
279
|
+
|
|
280
|
+
const promise = withInflightTimeout(
|
|
281
|
+
(async () => {
|
|
282
|
+
const enrichedProps = await loader(section.props as Record<string, unknown>, request);
|
|
283
|
+
const enriched = { ...section, props: enrichedProps };
|
|
284
|
+
setCachedLayout(key, enriched);
|
|
285
|
+
return enriched;
|
|
286
|
+
})(),
|
|
287
|
+
`layoutSection ${key}`,
|
|
288
|
+
).finally(() => layoutInflight.delete(key));
|
|
289
|
+
|
|
290
|
+
layoutInflight.set(key, promise);
|
|
291
|
+
|
|
292
|
+
return promise;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Run registered section loaders against resolved sections.
|
|
297
|
+
* Sections without a registered loader pass through unchanged.
|
|
298
|
+
*
|
|
299
|
+
* Layout sections use in-flight dedup + TTL cache to avoid
|
|
300
|
+
* redundant enrichment across concurrent and sequential requests.
|
|
301
|
+
*
|
|
302
|
+
* Runs all loaders in parallel for performance.
|
|
303
|
+
*/
|
|
304
|
+
export async function runSectionLoaders(
|
|
305
|
+
sections: ResolvedSection[],
|
|
306
|
+
request: Request,
|
|
307
|
+
): Promise<ResolvedSection[]> {
|
|
308
|
+
// Dev warning: detect likely layout sections not registered via registerLayoutSections.
|
|
309
|
+
// Without registration, Header/Footer won't be cached across navigations.
|
|
310
|
+
if (typeof process !== "undefined" && process.env?.NODE_ENV !== "production") {
|
|
311
|
+
for (const s of sections) {
|
|
312
|
+
const key = s.component.toLowerCase();
|
|
313
|
+
if ((key.includes("header") || key.includes("footer")) && !layoutSections.has(s.component)) {
|
|
314
|
+
console.warn(
|
|
315
|
+
`[SectionLoaders] "${s.component}" looks like a layout section but is not in registerLayoutSections(). ` +
|
|
316
|
+
`Add it to registerLayoutSections() in setup.ts for consistent caching across navigations.`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return withTracing(
|
|
322
|
+
"deco.section.loaders.batch",
|
|
323
|
+
() => Promise.all(sections.map((section) => runSingleSectionLoader(section, request))),
|
|
324
|
+
{ "section.count": sections.length },
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Inject the active request's URL and path into section props so site
|
|
330
|
+
* loaders can read `props.__pageUrl` / `props.__pagePath` without having
|
|
331
|
+
* to derive them from `req.url` themselves.
|
|
332
|
+
*
|
|
333
|
+
* The framework already injects these for commerce loaders (resolve.ts),
|
|
334
|
+
* but section loaders (e.g. category SearchBanner, breadcrumb-aware FAQs)
|
|
335
|
+
* also need to know the active page. Without this, callers had to wrap
|
|
336
|
+
* `loader(...)` themselves in a custom `delegateAfter`-style helper —
|
|
337
|
+
* forgetting it produced silent rendering bugs (empty banners, default
|
|
338
|
+
* fallbacks).
|
|
339
|
+
*
|
|
340
|
+
* Existing values in `props` win — sites that already pre-populated
|
|
341
|
+
* `__pageUrl` (e.g. via a custom mixin) keep their value untouched.
|
|
342
|
+
*
|
|
343
|
+
* Note: this runs only at the point we hand props to the user's loader.
|
|
344
|
+
* The cacheable-section cache key hashes the *original* props (URL-agnostic),
|
|
345
|
+
* so sections registered via `registerCacheableSections` keep sharing a
|
|
346
|
+
* single cache entry across pages.
|
|
347
|
+
*/
|
|
348
|
+
function injectPageContext(
|
|
349
|
+
props: Record<string, unknown>,
|
|
350
|
+
request: Request,
|
|
351
|
+
): Record<string, unknown> {
|
|
352
|
+
let url: URL;
|
|
353
|
+
try {
|
|
354
|
+
url = new URL(request.url);
|
|
355
|
+
} catch {
|
|
356
|
+
return props;
|
|
357
|
+
}
|
|
358
|
+
const enriched = { ...props } as Record<string, unknown>;
|
|
359
|
+
if (enriched.__pageUrl === undefined) enriched.__pageUrl = request.url;
|
|
360
|
+
if (enriched.__pagePath === undefined) enriched.__pagePath = url.pathname;
|
|
361
|
+
return enriched;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Wrap a loader so it receives __pageUrl/__pagePath in its props. */
|
|
365
|
+
function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
|
|
366
|
+
return (props, req) => loader(injectPageContext(props, req), req);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Run a single section's registered loader.
|
|
371
|
+
* Used by both `runSectionLoaders` (batch) and `loadDeferredSection` (individual).
|
|
372
|
+
*
|
|
373
|
+
* Respects three cache tiers:
|
|
374
|
+
* 1. Layout sections (Header/Footer) — 5min TTL + in-flight dedup
|
|
375
|
+
* 2. Cacheable sections (ProductShelf, FAQ) — SWR with configurable maxAge
|
|
376
|
+
* 3. Regular sections — no cache, always fresh
|
|
377
|
+
*
|
|
378
|
+
* After the section's own loader runs, recursively runs loaders for any
|
|
379
|
+
* nested sections found in its resolved props (e.g. wrapper sections with
|
|
380
|
+
* a `sections: Section[]` prop). This eliminates the need for sites to
|
|
381
|
+
* manually walk + invoke `runSingleSectionLoader` on children.
|
|
382
|
+
*/
|
|
383
|
+
export async function runSingleSectionLoader(
|
|
384
|
+
section: ResolvedSection,
|
|
385
|
+
request: Request,
|
|
386
|
+
): Promise<ResolvedSection> {
|
|
387
|
+
return withTracing("deco.section.loader", () => runSingleSectionLoaderImpl(section, request), {
|
|
388
|
+
"deco.section": section.component,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function runSingleSectionLoaderImpl(
|
|
393
|
+
section: ResolvedSection,
|
|
394
|
+
request: Request,
|
|
395
|
+
): Promise<ResolvedSection> {
|
|
396
|
+
const loader = loaderRegistry.get(section.component);
|
|
397
|
+
|
|
398
|
+
let result: ResolvedSection;
|
|
399
|
+
|
|
400
|
+
if (!loader) {
|
|
401
|
+
// No own-loader, but the section may still contain nested sections in
|
|
402
|
+
// its props (CMS-resolved children) that need their loaders run.
|
|
403
|
+
result = section;
|
|
404
|
+
} else {
|
|
405
|
+
// Wrap the loader so __pageUrl/__pagePath are injected at the call site.
|
|
406
|
+
// Cache keys (component name for layout, component+propsHash for cacheable)
|
|
407
|
+
// are computed from the *original* section.props — keeping cache entries
|
|
408
|
+
// URL-agnostic and shared across pages.
|
|
409
|
+
const wrapped = withPageContext(loader);
|
|
410
|
+
|
|
411
|
+
if (layoutSections.has(section.component)) {
|
|
412
|
+
try {
|
|
413
|
+
result = await resolveLayoutSection(section, wrapped, request);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
|
|
416
|
+
result = section;
|
|
417
|
+
}
|
|
418
|
+
} else {
|
|
419
|
+
const cacheConfig = cacheableSections.get(section.component);
|
|
420
|
+
if (cacheConfig) {
|
|
421
|
+
try {
|
|
422
|
+
result = await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
|
|
425
|
+
result = section;
|
|
426
|
+
}
|
|
427
|
+
} else {
|
|
428
|
+
try {
|
|
429
|
+
const enrichedProps = await wrapped(section.props as Record<string, unknown>, request);
|
|
430
|
+
result = { ...section, props: enrichedProps };
|
|
431
|
+
} catch (error) {
|
|
432
|
+
console.error(`[SectionLoader] Error in "${section.component}":`, error);
|
|
433
|
+
result = section;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Recurse into nested sections AFTER the parent's loader/cache lookup so
|
|
440
|
+
// child sections keep their own cache TTL independent from the parent's.
|
|
441
|
+
// For layout/cacheable parents, this means a 5-min layout cache hit still
|
|
442
|
+
// re-evaluates child sections (whose own caches are usually shorter, e.g.
|
|
443
|
+
// ProductShelf 60s). For leaf sections, `enrichNestedSections` returns
|
|
444
|
+
// the same reference (no allocation, no extra work).
|
|
445
|
+
const props = result.props as Record<string, unknown> | undefined;
|
|
446
|
+
if (props && typeof props === "object") {
|
|
447
|
+
const enrichedProps = await enrichNestedSections(props, request);
|
|
448
|
+
if (enrichedProps !== props) {
|
|
449
|
+
return { ...result, props: enrichedProps };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Nested section loader support
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Type guard: matches the shape produced by `normalizeNestedSections` in
|
|
461
|
+
* resolve.ts — `{ Component: string, props: object }`. This is how the CMS
|
|
462
|
+
* resolver represents nested sections (children of wrapper sections).
|
|
463
|
+
*
|
|
464
|
+
* Note: the `Component` key uses capital C to match the runtime renderer's
|
|
465
|
+
* convention (mirrors deco-cx/deco's Fresh API). Not to be confused with
|
|
466
|
+
* the lowercase `component` on `ResolvedSection`.
|
|
467
|
+
*/
|
|
468
|
+
function isNestedSection(
|
|
469
|
+
value: unknown,
|
|
470
|
+
): value is { Component: string; props: Record<string, unknown> } {
|
|
471
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
472
|
+
const obj = value as Record<string, unknown>;
|
|
473
|
+
return (
|
|
474
|
+
typeof obj.Component === "string" &&
|
|
475
|
+
obj.props != null &&
|
|
476
|
+
typeof obj.props === "object" &&
|
|
477
|
+
!Array.isArray(obj.props)
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Walk a props object and run section loaders for any nested sections.
|
|
483
|
+
* Handles direct child sections AND arrays of sections (e.g.
|
|
484
|
+
* `sections: Section[]`, `slides: Slide[]`).
|
|
485
|
+
*
|
|
486
|
+
* Returns the same reference if nothing changed — so leaf sections (the
|
|
487
|
+
* vast majority) incur zero allocation overhead.
|
|
488
|
+
*
|
|
489
|
+
* Concurrency: all nested loader calls run in parallel via Promise.all.
|
|
490
|
+
*/
|
|
491
|
+
async function enrichNestedSections(
|
|
492
|
+
props: Record<string, unknown>,
|
|
493
|
+
request: Request,
|
|
494
|
+
): Promise<Record<string, unknown>> {
|
|
495
|
+
type Pending = {
|
|
496
|
+
key: string;
|
|
497
|
+
index?: number;
|
|
498
|
+
promise: Promise<ResolvedSection>;
|
|
499
|
+
};
|
|
500
|
+
const pending: Pending[] = [];
|
|
501
|
+
|
|
502
|
+
for (const [key, value] of Object.entries(props)) {
|
|
503
|
+
if (isNestedSection(value)) {
|
|
504
|
+
pending.push({
|
|
505
|
+
key,
|
|
506
|
+
promise: runSingleSectionLoader(
|
|
507
|
+
{
|
|
508
|
+
component: value.Component,
|
|
509
|
+
props: value.props,
|
|
510
|
+
key: value.Component,
|
|
511
|
+
} as ResolvedSection,
|
|
512
|
+
request,
|
|
513
|
+
),
|
|
514
|
+
});
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (Array.isArray(value)) {
|
|
519
|
+
for (let i = 0; i < value.length; i++) {
|
|
520
|
+
const item = value[i];
|
|
521
|
+
if (isNestedSection(item)) {
|
|
522
|
+
pending.push({
|
|
523
|
+
key,
|
|
524
|
+
index: i,
|
|
525
|
+
promise: runSingleSectionLoader(
|
|
526
|
+
{
|
|
527
|
+
component: item.Component,
|
|
528
|
+
props: item.props,
|
|
529
|
+
key: item.Component,
|
|
530
|
+
} as ResolvedSection,
|
|
531
|
+
request,
|
|
532
|
+
),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (pending.length === 0) return props;
|
|
540
|
+
|
|
541
|
+
const results = await Promise.all(pending.map((p) => p.promise));
|
|
542
|
+
const updated: Record<string, unknown> = { ...props };
|
|
543
|
+
|
|
544
|
+
for (let i = 0; i < pending.length; i++) {
|
|
545
|
+
const { key, index } = pending[i];
|
|
546
|
+
const enriched = results[i];
|
|
547
|
+
const nestedValue = { Component: enriched.component, props: enriched.props };
|
|
548
|
+
|
|
549
|
+
if (index != null) {
|
|
550
|
+
// Array item — clone the array on first mutation for this key
|
|
551
|
+
const current = updated[key];
|
|
552
|
+
if (current === props[key]) {
|
|
553
|
+
updated[key] = [...(current as unknown[])];
|
|
554
|
+
}
|
|
555
|
+
(updated[key] as unknown[])[index] = nestedValue;
|
|
556
|
+
} else {
|
|
557
|
+
updated[key] = nestedValue;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
return updated;
|
|
562
|
+
}
|