@decocms/start 1.3.6 → 1.4.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/applySectionConventions.ts +4 -0
- package/src/cms/index.ts +3 -0
- package/src/cms/resolve.test.ts +1 -0
- package/src/cms/resolve.ts +140 -6
- package/src/hooks/DecoPageRenderer.tsx +12 -9
- package/src/routes/cmsRoute.ts +24 -4
- package/src/sdk/cacheHeaders.ts +0 -1
- package/src/sdk/workerEntry.ts +400 -11
- package/src/vite/plugin.js +29 -1
package/package.json
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
registerLayoutSections,
|
|
16
16
|
} from "./sectionLoaders";
|
|
17
17
|
import {
|
|
18
|
+
registerEagerSections,
|
|
18
19
|
registerSeoSections,
|
|
19
20
|
setAsyncRenderingConfig,
|
|
20
21
|
getAsyncRenderingConfig,
|
|
@@ -77,6 +78,9 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
if (eagerSections.length > 0) {
|
|
81
|
+
// Permanent registry — survives subsequent setAsyncRenderingConfig() calls
|
|
82
|
+
registerEagerSections(eagerSections);
|
|
83
|
+
// Also add to alwaysEager for backward compat with code that reads the config
|
|
80
84
|
const existing = getAsyncRenderingConfig() ?? {};
|
|
81
85
|
setAsyncRenderingConfig({
|
|
82
86
|
...existing,
|
package/src/cms/index.ts
CHANGED
|
@@ -41,13 +41,16 @@ export {
|
|
|
41
41
|
evaluateMatcher,
|
|
42
42
|
extractSeoFromProps,
|
|
43
43
|
extractSeoFromSections,
|
|
44
|
+
cacheDeferredRawProps,
|
|
44
45
|
getAsyncRenderingConfig,
|
|
46
|
+
getDeferredRawProps,
|
|
45
47
|
isSeoSection,
|
|
46
48
|
onBeforeResolve,
|
|
47
49
|
registerBotPattern,
|
|
48
50
|
registerCommerceLoader,
|
|
49
51
|
registerCommerceLoaders,
|
|
50
52
|
registerMatcher,
|
|
53
|
+
registerEagerSections,
|
|
51
54
|
registerSeoSections,
|
|
52
55
|
resolveDecoPage,
|
|
53
56
|
resolvePageSections,
|
package/src/cms/resolve.test.ts
CHANGED
package/src/cms/resolve.ts
CHANGED
|
@@ -2,12 +2,14 @@ import { findPageByPath, loadBlocks } from "./loader";
|
|
|
2
2
|
import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry";
|
|
3
3
|
import { isLayoutSection, runSingleSectionLoader } from "./sectionLoaders";
|
|
4
4
|
import { normalizeUrlsInObject } from "../sdk/normalizeUrls";
|
|
5
|
+
import { djb2Hex } from "../sdk/djb2";
|
|
5
6
|
|
|
6
7
|
// globalThis-backed: share state across Vite server function split modules
|
|
7
8
|
const G = globalThis as any;
|
|
8
9
|
if (!G.__deco) G.__deco = {};
|
|
9
10
|
if (!G.__deco.commerceLoaders) G.__deco.commerceLoaders = {};
|
|
10
11
|
if (!G.__deco.customMatchers) G.__deco.customMatchers = {};
|
|
12
|
+
if (!G.__deco.eagerSectionKeys) G.__deco.eagerSectionKeys = new Set<string>();
|
|
11
13
|
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
15
|
// onBeforeResolveProps helper — eagerly loads the section module if needed
|
|
@@ -69,9 +71,19 @@ export interface DeferredSection {
|
|
|
69
71
|
key: string;
|
|
70
72
|
/** Position in the original page section list. */
|
|
71
73
|
index: number;
|
|
72
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Short hash of rawProps for client-side cache busting.
|
|
76
|
+
* Keeps the serialized payload small — full rawProps are resolved
|
|
77
|
+
* server-side from the deferred props cache or page re-resolution.
|
|
78
|
+
*/
|
|
79
|
+
propsHash: string;
|
|
80
|
+
/**
|
|
81
|
+
* CMS-resolved props without section-loader enrichment.
|
|
82
|
+
* @deprecated Stripped before serialization to reduce HTML payload.
|
|
83
|
+
* Only present server-side in the rawProps cache.
|
|
84
|
+
*/
|
|
73
85
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
74
|
-
rawProps
|
|
86
|
+
rawProps?: Record<string, any>;
|
|
75
87
|
}
|
|
76
88
|
|
|
77
89
|
// ---------------------------------------------------------------------------
|
|
@@ -120,10 +132,15 @@ export function setAsyncRenderingConfig(config?: {
|
|
|
120
132
|
alwaysEager?: string[];
|
|
121
133
|
respectCmsLazy?: boolean;
|
|
122
134
|
}): void {
|
|
135
|
+
const existing = getAsyncConfig();
|
|
136
|
+
const merged = new Set([
|
|
137
|
+
...(existing?.alwaysEager ?? []),
|
|
138
|
+
...(config?.alwaysEager ?? []),
|
|
139
|
+
]);
|
|
123
140
|
G.__deco.asyncConfig = {
|
|
124
|
-
respectCmsLazy: config?.respectCmsLazy ?? true,
|
|
125
|
-
foldThreshold: config?.foldThreshold ?? Infinity,
|
|
126
|
-
alwaysEager:
|
|
141
|
+
respectCmsLazy: config?.respectCmsLazy ?? existing?.respectCmsLazy ?? true,
|
|
142
|
+
foldThreshold: config?.foldThreshold ?? existing?.foldThreshold ?? Infinity,
|
|
143
|
+
alwaysEager: merged,
|
|
127
144
|
};
|
|
128
145
|
}
|
|
129
146
|
|
|
@@ -132,6 +149,64 @@ export function getAsyncRenderingConfig(): AsyncRenderingConfig | null {
|
|
|
132
149
|
return getAsyncConfig();
|
|
133
150
|
}
|
|
134
151
|
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Permanent eager section registry — survives setAsyncRenderingConfig() calls
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Register sections that declared `export const eager = true`.
|
|
158
|
+
* This is a permanent registry that cannot be overwritten by
|
|
159
|
+
* subsequent calls to `setAsyncRenderingConfig()`.
|
|
160
|
+
*/
|
|
161
|
+
export function registerEagerSections(keys: string[]): void {
|
|
162
|
+
const set: Set<string> = G.__deco.eagerSectionKeys;
|
|
163
|
+
for (const k of keys) set.add(k);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isEagerSection(key: string): boolean {
|
|
167
|
+
return (G.__deco.eagerSectionKeys as Set<string>).has(key);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Deferred rawProps cache — keeps rawProps server-side to trim HTML payload
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
const DEFERRED_PROPS_TTL = 120_000; // 2 minutes
|
|
175
|
+
const deferredRawPropsCache = new Map<string, { rawProps: Record<string, unknown>; ts: number }>();
|
|
176
|
+
|
|
177
|
+
function deferredPropsCacheKey(pagePath: string, component: string, index: number): string {
|
|
178
|
+
return `${pagePath}::${component}::${index}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function cacheDeferredRawProps(
|
|
182
|
+
pagePath: string,
|
|
183
|
+
component: string,
|
|
184
|
+
index: number,
|
|
185
|
+
rawProps: Record<string, unknown>,
|
|
186
|
+
): void {
|
|
187
|
+
const key = deferredPropsCacheKey(pagePath, component, index);
|
|
188
|
+
deferredRawPropsCache.set(key, { rawProps, ts: Date.now() });
|
|
189
|
+
|
|
190
|
+
// Lazy eviction: remove expired entries when cache grows
|
|
191
|
+
if (deferredRawPropsCache.size > 500) {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
for (const [k, v] of deferredRawPropsCache) {
|
|
194
|
+
if (now - v.ts > DEFERRED_PROPS_TTL) deferredRawPropsCache.delete(k);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function getDeferredRawProps(
|
|
200
|
+
pagePath: string,
|
|
201
|
+
component: string,
|
|
202
|
+
index: number,
|
|
203
|
+
): Record<string, unknown> | null {
|
|
204
|
+
const key = deferredPropsCacheKey(pagePath, component, index);
|
|
205
|
+
const entry = deferredRawPropsCache.get(key);
|
|
206
|
+
if (!entry || Date.now() - entry.ts > DEFERRED_PROPS_TTL) return null;
|
|
207
|
+
return entry.rawProps;
|
|
208
|
+
}
|
|
209
|
+
|
|
135
210
|
// ---------------------------------------------------------------------------
|
|
136
211
|
// Bot detection — bots always receive fully eager pages for SEO
|
|
137
212
|
// ---------------------------------------------------------------------------
|
|
@@ -932,6 +1007,8 @@ function shouldDeferSection(
|
|
|
932
1007
|
const finalKey = resolveFinalSectionKey(section, matcherCtx);
|
|
933
1008
|
if (!finalKey) return false;
|
|
934
1009
|
|
|
1010
|
+
// Permanent registry — `export const eager = true` cannot be clobbered
|
|
1011
|
+
if (isEagerSection(finalKey)) return false;
|
|
935
1012
|
if (cfg.alwaysEager.has(finalKey)) return false;
|
|
936
1013
|
if (isLayoutSection(finalKey)) return false;
|
|
937
1014
|
|
|
@@ -1024,6 +1101,7 @@ function resolveSectionShallow(
|
|
|
1024
1101
|
component: rt,
|
|
1025
1102
|
key: rt,
|
|
1026
1103
|
index: -1,
|
|
1104
|
+
propsHash: djb2Hex(JSON.stringify(rawProps)),
|
|
1027
1105
|
rawProps: rawProps as Record<string, unknown>,
|
|
1028
1106
|
};
|
|
1029
1107
|
}
|
|
@@ -1241,6 +1319,14 @@ export async function resolveDecoPage(
|
|
|
1241
1319
|
const deferred = resolveSectionShallow(section, ctx);
|
|
1242
1320
|
if (deferred) {
|
|
1243
1321
|
deferred.index = currentFlatIndex;
|
|
1322
|
+
|
|
1323
|
+
// Cache rawProps server-side and strip from the deferred object
|
|
1324
|
+
// so they are NOT serialized into the HTML payload.
|
|
1325
|
+
if (deferred.rawProps) {
|
|
1326
|
+
cacheDeferredRawProps(targetPath, deferred.component, currentFlatIndex, deferred.rawProps);
|
|
1327
|
+
delete deferred.rawProps;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1244
1330
|
deferredSections.push(deferred);
|
|
1245
1331
|
deferredOk = true;
|
|
1246
1332
|
}
|
|
@@ -1441,9 +1527,16 @@ export async function resolveDeferredSectionFull(
|
|
|
1441
1527
|
request: Request,
|
|
1442
1528
|
matcherCtx?: MatcherContext,
|
|
1443
1529
|
): Promise<ResolvedSection | null> {
|
|
1530
|
+
// rawProps may be stripped from the client payload — resolve from cache or page
|
|
1531
|
+
const rawProps = ds.rawProps
|
|
1532
|
+
?? getDeferredRawProps(pagePath, ds.component, ds.index)
|
|
1533
|
+
?? await reExtractRawProps(pagePath, ds.component, ds.index, matcherCtx);
|
|
1534
|
+
|
|
1535
|
+
if (!rawProps) return null;
|
|
1536
|
+
|
|
1444
1537
|
const section = await resolveDeferredSection(
|
|
1445
1538
|
ds.component,
|
|
1446
|
-
|
|
1539
|
+
rawProps,
|
|
1447
1540
|
pagePath,
|
|
1448
1541
|
matcherCtx,
|
|
1449
1542
|
);
|
|
@@ -1452,3 +1545,44 @@ export async function resolveDeferredSectionFull(
|
|
|
1452
1545
|
const enriched = await runSingleSectionLoader(section, request);
|
|
1453
1546
|
return normalizeUrlsInObject(enriched);
|
|
1454
1547
|
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Fallback for deferred rawProps cache miss: re-resolve the page and extract
|
|
1551
|
+
* rawProps for the section at the given index. Expensive but ensures correctness
|
|
1552
|
+
* when the in-memory cache has been evicted (different isolate, TTL expired).
|
|
1553
|
+
*/
|
|
1554
|
+
async function reExtractRawProps(
|
|
1555
|
+
pagePath: string,
|
|
1556
|
+
component: string,
|
|
1557
|
+
sectionIndex: number,
|
|
1558
|
+
matcherCtx?: MatcherContext,
|
|
1559
|
+
): Promise<Record<string, unknown> | null> {
|
|
1560
|
+
ensureInitialized();
|
|
1561
|
+
|
|
1562
|
+
const match = findPageByPath(pagePath);
|
|
1563
|
+
if (!match) return null;
|
|
1564
|
+
|
|
1565
|
+
const { page } = match;
|
|
1566
|
+
const ctx: MatcherContext = { ...matcherCtx, path: pagePath };
|
|
1567
|
+
|
|
1568
|
+
let rawSections: unknown[];
|
|
1569
|
+
if (Array.isArray(page.sections)) {
|
|
1570
|
+
rawSections = page.sections;
|
|
1571
|
+
} else {
|
|
1572
|
+
const rctx: ResolveContext = { matcherCtx: ctx, memo: new Map(), depth: 0 };
|
|
1573
|
+
rawSections = await resolveSectionsList(page.sections, rctx);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
if (sectionIndex < 0 || sectionIndex >= rawSections.length) return null;
|
|
1577
|
+
|
|
1578
|
+
const section = rawSections[sectionIndex];
|
|
1579
|
+
const shallow = resolveSectionShallow(section, ctx);
|
|
1580
|
+
if (!shallow || shallow.component !== component) return null;
|
|
1581
|
+
|
|
1582
|
+
// Cache for subsequent requests
|
|
1583
|
+
if (shallow.rawProps) {
|
|
1584
|
+
cacheDeferredRawProps(pagePath, component, sectionIndex, shallow.rawProps);
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
return shallow.rawProps ?? null;
|
|
1588
|
+
}
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
setResolvedComponent,
|
|
20
20
|
} from "../cms/registry";
|
|
21
21
|
import type { DeferredSection, ResolvedSection } from "../cms/resolve";
|
|
22
|
-
|
|
22
|
+
|
|
23
23
|
import { SectionErrorBoundary } from "./SectionErrorFallback";
|
|
24
24
|
|
|
25
25
|
type LazyComponent = ReturnType<typeof lazy>;
|
|
@@ -235,9 +235,10 @@ interface DeferredSectionWrapperProps {
|
|
|
235
235
|
errorFallback?: ReactNode;
|
|
236
236
|
loadFn: (data: {
|
|
237
237
|
component: string;
|
|
238
|
-
rawProps
|
|
238
|
+
rawProps?: Record<string, unknown>;
|
|
239
239
|
pagePath: string;
|
|
240
240
|
pageUrl?: string;
|
|
241
|
+
index?: number;
|
|
241
242
|
}) => Promise<ResolvedSection | null>;
|
|
242
243
|
}
|
|
243
244
|
|
|
@@ -249,8 +250,7 @@ function DeferredSectionWrapper({
|
|
|
249
250
|
errorFallback,
|
|
250
251
|
loadFn,
|
|
251
252
|
}: DeferredSectionWrapperProps) {
|
|
252
|
-
const
|
|
253
|
-
const stableKey = `${pagePath}::${deferred.component}::${deferred.index}::${propsHash}`;
|
|
253
|
+
const stableKey = `${pagePath}::${deferred.component}::${deferred.index}::${deferred.propsHash ?? ""}`;
|
|
254
254
|
const [section, setSection] = useState<ResolvedSection | null>(() =>
|
|
255
255
|
typeof document === "undefined" ? null : getCachedDeferredSection(stableKey),
|
|
256
256
|
);
|
|
@@ -308,9 +308,9 @@ function DeferredSectionWrapper({
|
|
|
308
308
|
const key0 = stableKey;
|
|
309
309
|
loadFn({
|
|
310
310
|
component: deferred.component,
|
|
311
|
-
rawProps: deferred.rawProps,
|
|
312
311
|
pagePath,
|
|
313
312
|
pageUrl,
|
|
313
|
+
index: deferred.index,
|
|
314
314
|
})
|
|
315
315
|
.then((result) => {
|
|
316
316
|
if (result) deferredSectionCache.set(key0, { section: result, ts: Date.now() });
|
|
@@ -328,9 +328,9 @@ function DeferredSectionWrapper({
|
|
|
328
328
|
const key1 = stableKey;
|
|
329
329
|
loadFn({
|
|
330
330
|
component: deferred.component,
|
|
331
|
-
rawProps: deferred.rawProps,
|
|
332
331
|
pagePath,
|
|
333
332
|
pageUrl,
|
|
333
|
+
index: deferred.index,
|
|
334
334
|
})
|
|
335
335
|
.then((result) => {
|
|
336
336
|
if (result) deferredSectionCache.set(key1, { section: result, ts: Date.now() });
|
|
@@ -344,7 +344,7 @@ function DeferredSectionWrapper({
|
|
|
344
344
|
|
|
345
345
|
observer.observe(el);
|
|
346
346
|
return () => observer.disconnect();
|
|
347
|
-
}, [deferred.component, deferred.
|
|
347
|
+
}, [deferred.component, deferred.index, deferred.propsHash, pagePath, pageUrl, section, loadFn]);
|
|
348
348
|
|
|
349
349
|
if (error) {
|
|
350
350
|
const errFallback = loadedOptions?.errorFallback
|
|
@@ -402,7 +402,9 @@ function DeferredSectionSkeleton({
|
|
|
402
402
|
}) {
|
|
403
403
|
const options = getSectionOptions(deferred.component);
|
|
404
404
|
if (options?.loadingFallback) {
|
|
405
|
-
|
|
405
|
+
// rawProps are no longer serialized to the client — pass empty object.
|
|
406
|
+
// LoadingFallback components should be pure layout skeletons.
|
|
407
|
+
return createElement(options.loadingFallback, deferred.rawProps ?? {});
|
|
406
408
|
}
|
|
407
409
|
if (fallback) return <>{fallback}</>;
|
|
408
410
|
if (isDev) return <DevMissingFallbackWarning component={deferred.component} />;
|
|
@@ -466,9 +468,10 @@ interface Props {
|
|
|
466
468
|
/** @deprecated Use deferredPromises instead (TanStack native streaming). */
|
|
467
469
|
loadDeferredSectionFn?: (data: {
|
|
468
470
|
component: string;
|
|
469
|
-
rawProps
|
|
471
|
+
rawProps?: Record<string, unknown>;
|
|
470
472
|
pagePath: string;
|
|
471
473
|
pageUrl?: string;
|
|
474
|
+
index?: number;
|
|
472
475
|
}) => Promise<ResolvedSection | null>;
|
|
473
476
|
}
|
|
474
477
|
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
getRequest,
|
|
28
28
|
getRequestHeader,
|
|
29
29
|
getRequestUrl,
|
|
30
|
+
setResponseHeader,
|
|
30
31
|
} from "@tanstack/react-start/server";
|
|
31
32
|
import { createElement } from "react";
|
|
32
33
|
import { preloadSectionComponents } from "../cms/registry";
|
|
@@ -34,6 +35,7 @@ import type { DeferredSection, MatcherContext, PageSeo, ResolvedSection } from "
|
|
|
34
35
|
import {
|
|
35
36
|
extractSeoFromProps,
|
|
36
37
|
extractSeoFromSections,
|
|
38
|
+
getDeferredRawProps,
|
|
37
39
|
resolveDecoPage,
|
|
38
40
|
resolveDeferredSection,
|
|
39
41
|
resolveDeferredSectionFull,
|
|
@@ -197,7 +199,8 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
197
199
|
(data: unknown) =>
|
|
198
200
|
data as {
|
|
199
201
|
component: string;
|
|
200
|
-
rawProps
|
|
202
|
+
/** @deprecated rawProps are now resolved server-side from the deferred props cache. */
|
|
203
|
+
rawProps?: Record<string, any>;
|
|
201
204
|
pagePath: string;
|
|
202
205
|
pageUrl?: string;
|
|
203
206
|
/** Original position in the page section list — preserved for correct SPA ordering. */
|
|
@@ -205,7 +208,7 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
205
208
|
},
|
|
206
209
|
)
|
|
207
210
|
.handler(async (ctx) => {
|
|
208
|
-
const { component, rawProps, pagePath, pageUrl, index } = ctx.data;
|
|
211
|
+
const { component, rawProps: clientRawProps, pagePath, pageUrl, index } = ctx.data;
|
|
209
212
|
|
|
210
213
|
const originRequest = getRequest();
|
|
211
214
|
const serverUrl = getRequestUrl().toString();
|
|
@@ -217,6 +220,15 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
217
220
|
request: originRequest,
|
|
218
221
|
};
|
|
219
222
|
|
|
223
|
+
// Resolve rawProps: prefer client-provided (backward compat), then server cache
|
|
224
|
+
const rawProps = clientRawProps
|
|
225
|
+
?? (index !== undefined ? getDeferredRawProps(pagePath, component, index) : null);
|
|
226
|
+
|
|
227
|
+
if (!rawProps) {
|
|
228
|
+
console.warn(`[CMS] Deferred section cache miss: ${component} at index ${index} on ${pagePath}`);
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
220
232
|
const section = await resolveDeferredSection(component, rawProps, pagePath, matcherCtx);
|
|
221
233
|
if (!section) return null;
|
|
222
234
|
|
|
@@ -227,6 +239,12 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
|
|
|
227
239
|
headers: originRequest.headers,
|
|
228
240
|
});
|
|
229
241
|
const enriched = await runSingleSectionLoader(section, request);
|
|
242
|
+
|
|
243
|
+
// Signal to the worker entry that this response is safe to edge-cache.
|
|
244
|
+
// Without this header, POST _serverFn responses are passed through
|
|
245
|
+
// without caching (checkout actions, invoke mutations, etc.).
|
|
246
|
+
setResponseHeader("X-Deco-Cacheable", "true");
|
|
247
|
+
|
|
230
248
|
return normalizeUrlsInObject(enriched);
|
|
231
249
|
});
|
|
232
250
|
|
|
@@ -246,14 +264,16 @@ export const deferredSectionLoader = async ({
|
|
|
246
264
|
rawProps,
|
|
247
265
|
pagePath,
|
|
248
266
|
pageUrl,
|
|
267
|
+
index,
|
|
249
268
|
}: {
|
|
250
269
|
component: string;
|
|
251
|
-
rawProps
|
|
270
|
+
rawProps?: Record<string, unknown>;
|
|
252
271
|
pagePath: string;
|
|
253
272
|
pageUrl?: string;
|
|
273
|
+
index?: number;
|
|
254
274
|
}): Promise<ResolvedSection | null> => {
|
|
255
275
|
return loadDeferredSection({
|
|
256
|
-
data: { component, rawProps, pagePath, pageUrl },
|
|
276
|
+
data: { component, rawProps, pagePath, pageUrl, index },
|
|
257
277
|
});
|
|
258
278
|
};
|
|
259
279
|
|
package/src/sdk/cacheHeaders.ts
CHANGED
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -179,7 +179,7 @@ export interface DecoWorkerEntryOptions {
|
|
|
179
179
|
/**
|
|
180
180
|
* Paths that should always bypass the edge cache, even if the
|
|
181
181
|
* profile detector would otherwise cache them.
|
|
182
|
-
* Defaults include `/
|
|
182
|
+
* Defaults include `/_build`, `/deco/`, `/live/`, `/.decofile`.
|
|
183
183
|
*/
|
|
184
184
|
bypassPaths?: string[];
|
|
185
185
|
|
|
@@ -295,6 +295,63 @@ export interface DecoWorkerEntryOptions {
|
|
|
295
295
|
* @default true
|
|
296
296
|
*/
|
|
297
297
|
autoInjectGeoCookies?: boolean;
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Cookie names considered "safe" for caching — these are public/anonymous
|
|
301
|
+
* cookies that do not carry per-user session or auth data.
|
|
302
|
+
*
|
|
303
|
+
* When a response contains ONLY safe cookies, it is still eligible for
|
|
304
|
+
* Cache API storage. The safe cookies are stripped from the cached copy
|
|
305
|
+
* but kept on the response served to the current user.
|
|
306
|
+
*
|
|
307
|
+
* If the response contains ANY cookie NOT in this list, the response
|
|
308
|
+
* bypasses caching entirely (existing behavior).
|
|
309
|
+
*
|
|
310
|
+
* @default DEFAULT_SAFE_COOKIES (vtex_is_session, vtex_is_anonymous, vtex_segment, _deco_bucket)
|
|
311
|
+
*
|
|
312
|
+
* @example
|
|
313
|
+
* ```ts
|
|
314
|
+
* createDecoWorkerEntry(serverEntry, {
|
|
315
|
+
* safeCookies: [
|
|
316
|
+
* ...DEFAULT_SAFE_COOKIES,
|
|
317
|
+
* "my_custom_analytics_cookie",
|
|
318
|
+
* ],
|
|
319
|
+
* });
|
|
320
|
+
* ```
|
|
321
|
+
*/
|
|
322
|
+
safeCookies?: string[];
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Additional static paths (beyond fingerprinted assets) that should
|
|
326
|
+
* receive long-lived immutable cache headers.
|
|
327
|
+
*
|
|
328
|
+
* Useful for non-fingerprinted resources like fonts that live at
|
|
329
|
+
* stable URLs (e.g., `/fonts/Lato-Regular.woff2`).
|
|
330
|
+
*
|
|
331
|
+
* @default ["/fonts/"]
|
|
332
|
+
*
|
|
333
|
+
* @example
|
|
334
|
+
* ```ts
|
|
335
|
+
* createDecoWorkerEntry(serverEntry, {
|
|
336
|
+
* staticPaths: ["/fonts/", "/static/", "/images/icons/"],
|
|
337
|
+
* });
|
|
338
|
+
* ```
|
|
339
|
+
*/
|
|
340
|
+
staticPaths?: string[];
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* CDN-Cache-Control header strategy.
|
|
344
|
+
*
|
|
345
|
+
* - `"no-store"` (default): CDN never caches; every request invokes the Worker.
|
|
346
|
+
* Correct when segment-based cache keys differ from the original URL.
|
|
347
|
+
* - `"match-profile"`: Set CDN-Cache-Control to a short TTL matching the
|
|
348
|
+
* profile's edge.fresh value. Only safe when you are NOT using segment-based
|
|
349
|
+
* cache keys (i.e., no `buildSegment` and `deviceSpecificKeys: false`).
|
|
350
|
+
* - A function: Return a CDN-Cache-Control value per profile, or `null` for no-store.
|
|
351
|
+
*
|
|
352
|
+
* @default "no-store"
|
|
353
|
+
*/
|
|
354
|
+
cdnCacheControl?: "no-store" | "match-profile" | ((profile: CacheProfileName) => string | null);
|
|
298
355
|
}
|
|
299
356
|
|
|
300
357
|
// ---------------------------------------------------------------------------
|
|
@@ -379,7 +436,110 @@ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
|
|
|
379
436
|
"Cross-Origin-Opener-Policy": "same-origin-allow-popups",
|
|
380
437
|
};
|
|
381
438
|
|
|
382
|
-
const DEFAULT_BYPASS_PATHS = ["/
|
|
439
|
+
const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Cookie names that are safe for caching — they carry anonymous/public
|
|
443
|
+
* segment data, not per-user auth tokens.
|
|
444
|
+
*
|
|
445
|
+
* VTEX Intelligent Search sets `vtex_is_session` and `vtex_is_anonymous`
|
|
446
|
+
* on every response. `vtex_segment` encodes the sales channel.
|
|
447
|
+
* `_deco_bucket` is the A/B test cohort cookie.
|
|
448
|
+
*/
|
|
449
|
+
export const DEFAULT_SAFE_COOKIES: string[] = [
|
|
450
|
+
"vtex_is_session",
|
|
451
|
+
"vtex_is_anonymous",
|
|
452
|
+
"vtex_segment",
|
|
453
|
+
"_deco_bucket",
|
|
454
|
+
];
|
|
455
|
+
|
|
456
|
+
const DEFAULT_STATIC_PATHS = ["/fonts/"];
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Parse Set-Cookie header values and return cookie names.
|
|
460
|
+
*/
|
|
461
|
+
function parseCookieNames(response: Response): string[] {
|
|
462
|
+
const names: string[] = [];
|
|
463
|
+
// getSetCookie() returns individual Set-Cookie values (available in Workers runtime)
|
|
464
|
+
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
|
|
465
|
+
if (setCookies) {
|
|
466
|
+
for (const sc of setCookies) {
|
|
467
|
+
const eqIdx = sc.indexOf("=");
|
|
468
|
+
if (eqIdx > 0) names.push(sc.slice(0, eqIdx).trim());
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
// Fallback: parse from combined header (less reliable but covers edge cases)
|
|
472
|
+
const combined = response.headers.get("set-cookie") ?? "";
|
|
473
|
+
for (const part of combined.split(",")) {
|
|
474
|
+
const eqIdx = part.indexOf("=");
|
|
475
|
+
if (eqIdx > 0) {
|
|
476
|
+
const name = part.slice(0, eqIdx).trim();
|
|
477
|
+
// Skip attributes like "Expires=..." that appear after semicolons
|
|
478
|
+
if (!name.includes(";") && name.length > 0) names.push(name);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return names;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Check if ALL cookies in a response are in the safe list.
|
|
487
|
+
* Returns true if the response has no cookies or only safe cookies.
|
|
488
|
+
*/
|
|
489
|
+
function hasOnlySafeCookies(response: Response, safeCookieSet: Set<string>): boolean {
|
|
490
|
+
if (!response.headers.has("set-cookie")) return true;
|
|
491
|
+
const names = parseCookieNames(response);
|
|
492
|
+
if (names.length === 0) return true;
|
|
493
|
+
return names.every((name) => safeCookieSet.has(name));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Clone a response, stripping Set-Cookie headers that match the safe list.
|
|
498
|
+
* Uses response.clone() to preserve the original body for the served response.
|
|
499
|
+
* The returned copy is intended for cache storage only.
|
|
500
|
+
*/
|
|
501
|
+
function stripSafeCookiesForCache(response: Response, safeCookieSet: Set<string>): Response {
|
|
502
|
+
const clone = response.clone();
|
|
503
|
+
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
|
|
504
|
+
if (!setCookies || setCookies.length === 0) return clone;
|
|
505
|
+
|
|
506
|
+
// Remove all Set-Cookie headers, then re-add only unsafe ones
|
|
507
|
+
clone.headers.delete("set-cookie");
|
|
508
|
+
for (const sc of setCookies) {
|
|
509
|
+
const eqIdx = sc.indexOf("=");
|
|
510
|
+
const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : "";
|
|
511
|
+
if (name && !safeCookieSet.has(name)) {
|
|
512
|
+
clone.headers.append("set-cookie", sc);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
return clone;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Deduplicate Set-Cookie headers — keep only the LAST occurrence of
|
|
520
|
+
* each cookie name. Multiple layers (VTEX middleware, invoke handlers,
|
|
521
|
+
* etc.) may independently append the same cookie.
|
|
522
|
+
*/
|
|
523
|
+
function deduplicateSetCookies(response: Response): void {
|
|
524
|
+
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
|
|
525
|
+
if (!setCookies || setCookies.length <= 1) return;
|
|
526
|
+
|
|
527
|
+
// Build map: cookie name → last Set-Cookie value
|
|
528
|
+
const seen = new Map<string, string>();
|
|
529
|
+
for (const sc of setCookies) {
|
|
530
|
+
const eqIdx = sc.indexOf("=");
|
|
531
|
+
const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : sc;
|
|
532
|
+
seen.set(name, sc);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// If no duplicates, nothing to do
|
|
536
|
+
if (seen.size === setCookies.length) return;
|
|
537
|
+
|
|
538
|
+
response.headers.delete("set-cookie");
|
|
539
|
+
for (const sc of seen.values()) {
|
|
540
|
+
response.headers.append("set-cookie", sc);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
383
543
|
|
|
384
544
|
const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
|
|
385
545
|
|
|
@@ -388,6 +548,15 @@ const IMMUTABLE_HEADERS: Record<string, string> = {
|
|
|
388
548
|
Vary: "Accept-Encoding",
|
|
389
549
|
};
|
|
390
550
|
|
|
551
|
+
/** SHA-256 hex hash of a string — used for POST body cache keys. */
|
|
552
|
+
async function hashText(text: string): Promise<string> {
|
|
553
|
+
const data = new TextEncoder().encode(text);
|
|
554
|
+
const buf = await crypto.subtle.digest("SHA-256", data);
|
|
555
|
+
return Array.from(new Uint8Array(buf))
|
|
556
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
557
|
+
.join("");
|
|
558
|
+
}
|
|
559
|
+
|
|
391
560
|
// ---------------------------------------------------------------------------
|
|
392
561
|
// Factory
|
|
393
562
|
// ---------------------------------------------------------------------------
|
|
@@ -421,8 +590,13 @@ export function createDecoWorkerEntry(
|
|
|
421
590
|
securityHeaders: securityHeadersOpt,
|
|
422
591
|
csp: cspOpt,
|
|
423
592
|
autoInjectGeoCookies: geoOpt = true,
|
|
593
|
+
safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES,
|
|
594
|
+
staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
|
|
595
|
+
cdnCacheControl: cdnCacheControlOpt = "no-store",
|
|
424
596
|
} = options;
|
|
425
597
|
|
|
598
|
+
const safeCookieSet = new Set(safeCookiesOpt);
|
|
599
|
+
|
|
426
600
|
// Build the final security headers map (merged defaults + custom + CSP)
|
|
427
601
|
const secHeaders: Record<string, string> | null = (() => {
|
|
428
602
|
if (securityHeadersOpt === false) return null;
|
|
@@ -456,7 +630,9 @@ export function createDecoWorkerEntry(
|
|
|
456
630
|
}
|
|
457
631
|
|
|
458
632
|
function isStaticAsset(pathname: string): boolean {
|
|
459
|
-
|
|
633
|
+
if (fingerprintedAssetPattern.test(pathname)) return true;
|
|
634
|
+
// Non-fingerprinted static paths (e.g., /fonts/)
|
|
635
|
+
return staticPathsOpt.some((sp) => pathname.startsWith(sp));
|
|
460
636
|
}
|
|
461
637
|
|
|
462
638
|
function isCacheable(request: Request, url: URL): boolean {
|
|
@@ -756,6 +932,10 @@ export function createDecoWorkerEntry(
|
|
|
756
932
|
return handleRequest(request, env, ctx);
|
|
757
933
|
});
|
|
758
934
|
|
|
935
|
+
// Deduplicate Set-Cookie headers — multiple layers (VTEX middleware,
|
|
936
|
+
// invoke handlers, etc.) may independently append the same cookie.
|
|
937
|
+
deduplicateSetCookies(response);
|
|
938
|
+
|
|
759
939
|
return applySecurityHeaders(response);
|
|
760
940
|
},
|
|
761
941
|
};
|
|
@@ -835,6 +1015,168 @@ export function createDecoWorkerEntry(
|
|
|
835
1015
|
return origin;
|
|
836
1016
|
}
|
|
837
1017
|
|
|
1018
|
+
// -----------------------------------------------------------------
|
|
1019
|
+
// POST _serverFn — edge-cacheable using body-hash as cache key.
|
|
1020
|
+
// These carry public CMS section data (shelves, deferred sections)
|
|
1021
|
+
// that benefits from edge caching despite being POST requests.
|
|
1022
|
+
// -----------------------------------------------------------------
|
|
1023
|
+
if (
|
|
1024
|
+
request.method === "POST" &&
|
|
1025
|
+
(url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
|
|
1026
|
+
) {
|
|
1027
|
+
const serverFnCache =
|
|
1028
|
+
typeof caches !== "undefined"
|
|
1029
|
+
? ((caches as unknown as { default?: Cache }).default ?? null)
|
|
1030
|
+
: null;
|
|
1031
|
+
|
|
1032
|
+
// Build segment once — used for logged-in check and cache key
|
|
1033
|
+
const sfnSegment = buildSegment ? buildSegment(request) : undefined;
|
|
1034
|
+
|
|
1035
|
+
// Logged-in users always bypass — personalized content must not leak
|
|
1036
|
+
if (sfnSegment?.loggedIn) {
|
|
1037
|
+
const origin = await serverEntry.fetch(request, env, ctx);
|
|
1038
|
+
const resp = new Response(origin.body, origin);
|
|
1039
|
+
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1040
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1041
|
+
resp.headers.set("X-Cache-Reason", "logged-in");
|
|
1042
|
+
return resp;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
// Clone request before consuming body — the clone goes to origin
|
|
1046
|
+
// untouched so TanStack Start internals (cookie passthrough, etc.)
|
|
1047
|
+
// work correctly. We only read the body for the cache key hash.
|
|
1048
|
+
const originClone = request.clone();
|
|
1049
|
+
const body = await request.text();
|
|
1050
|
+
const bodyHash = await hashText(body);
|
|
1051
|
+
|
|
1052
|
+
// Build a synthetic GET cache key from the URL + body hash + segment
|
|
1053
|
+
// Includes device, salesChannel, regionId, flags — so users in
|
|
1054
|
+
// different regions or channels get separate cache entries.
|
|
1055
|
+
const cacheKeyUrl = new URL(request.url);
|
|
1056
|
+
cacheKeyUrl.searchParams.set("__body", bodyHash);
|
|
1057
|
+
if (cacheVersionEnv !== false) {
|
|
1058
|
+
const version = (env[cacheVersionEnv] as string) || "";
|
|
1059
|
+
if (version) cacheKeyUrl.searchParams.set("__v", version);
|
|
1060
|
+
}
|
|
1061
|
+
if (sfnSegment) {
|
|
1062
|
+
cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
|
|
1063
|
+
} else if (deviceSpecificKeys) {
|
|
1064
|
+
const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
|
|
1065
|
+
cacheKeyUrl.searchParams.set("__cf_device", device);
|
|
1066
|
+
}
|
|
1067
|
+
// Include CF geo data so location-based content doesn't leak across geos
|
|
1068
|
+
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
|
|
1069
|
+
if (cf) {
|
|
1070
|
+
const geoParts: string[] = [];
|
|
1071
|
+
if (cf.country) geoParts.push(cf.country);
|
|
1072
|
+
if (cf.region) geoParts.push(cf.region);
|
|
1073
|
+
if (cf.city) geoParts.push(cf.city);
|
|
1074
|
+
if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
|
|
1075
|
+
}
|
|
1076
|
+
const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
|
|
1077
|
+
|
|
1078
|
+
// Use "listing" profile for server function responses
|
|
1079
|
+
const sfnProfile: CacheProfileName = "listing";
|
|
1080
|
+
const sfnEdge = edgeCacheConfig(sfnProfile);
|
|
1081
|
+
|
|
1082
|
+
// Check edge cache
|
|
1083
|
+
let sfnCached: Response | undefined;
|
|
1084
|
+
if (serverFnCache) {
|
|
1085
|
+
try {
|
|
1086
|
+
sfnCached = await serverFnCache.match(sfnCacheKey) ?? undefined;
|
|
1087
|
+
} catch { /* Cache API unavailable */ }
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (sfnCached && sfnEdge.fresh > 0) {
|
|
1091
|
+
const storedAt = Number(sfnCached.headers.get("X-Deco-Stored-At") || "0");
|
|
1092
|
+
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
1093
|
+
|
|
1094
|
+
if (ageSec < sfnEdge.fresh) {
|
|
1095
|
+
const out = new Response(sfnCached.body, sfnCached);
|
|
1096
|
+
const hdrs = cacheHeaders(sfnProfile);
|
|
1097
|
+
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
1098
|
+
out.headers.set("X-Cache", "HIT");
|
|
1099
|
+
out.headers.set("X-Cache-Profile", sfnProfile);
|
|
1100
|
+
return out;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
|
|
1104
|
+
// Stale-while-revalidate: serve stale, refresh in background
|
|
1105
|
+
ctx.waitUntil(
|
|
1106
|
+
(async () => {
|
|
1107
|
+
try {
|
|
1108
|
+
const bgReq = new Request(request, { body, method: "POST" });
|
|
1109
|
+
const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
|
|
1110
|
+
if (
|
|
1111
|
+
bgOrigin.status === 200 &&
|
|
1112
|
+
bgOrigin.headers.get("X-Deco-Cacheable") === "true" &&
|
|
1113
|
+
!bgOrigin.headers.has("set-cookie") &&
|
|
1114
|
+
serverFnCache
|
|
1115
|
+
) {
|
|
1116
|
+
const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
|
|
1117
|
+
const toStore = bgOrigin.clone();
|
|
1118
|
+
toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
|
1119
|
+
toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
|
|
1120
|
+
toStore.headers.delete("CDN-Cache-Control");
|
|
1121
|
+
toStore.headers.delete("X-Deco-Cacheable");
|
|
1122
|
+
await serverFnCache.put(sfnCacheKey, toStore);
|
|
1123
|
+
}
|
|
1124
|
+
} catch { /* background revalidation failed */ }
|
|
1125
|
+
})(),
|
|
1126
|
+
);
|
|
1127
|
+
const out = new Response(sfnCached.body, sfnCached);
|
|
1128
|
+
const hdrs = cacheHeaders(sfnProfile);
|
|
1129
|
+
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
1130
|
+
out.headers.set("X-Cache", "STALE-HIT");
|
|
1131
|
+
out.headers.set("X-Cache-Profile", sfnProfile);
|
|
1132
|
+
out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
|
|
1133
|
+
return out;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Cache MISS — fetch origin with the body we already read
|
|
1138
|
+
const origin = await serverEntry.fetch(originClone, env, ctx);
|
|
1139
|
+
|
|
1140
|
+
// Only cache responses explicitly marked as cacheable by the handler
|
|
1141
|
+
// (loadDeferredSection sets X-Deco-Cacheable: true). Checkout actions,
|
|
1142
|
+
// invoke mutations, and other server functions are passed through.
|
|
1143
|
+
const isCacheableResponse =
|
|
1144
|
+
origin.headers.get("X-Deco-Cacheable") === "true" &&
|
|
1145
|
+
!origin.headers.has("set-cookie") &&
|
|
1146
|
+
origin.status === 200;
|
|
1147
|
+
|
|
1148
|
+
if (!isCacheableResponse) {
|
|
1149
|
+
const resp = new Response(origin.body, origin);
|
|
1150
|
+
resp.headers.delete("X-Deco-Cacheable");
|
|
1151
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1152
|
+
resp.headers.set("X-Cache-Reason", origin.headers.has("set-cookie")
|
|
1153
|
+
? "set-cookie"
|
|
1154
|
+
: "not-cacheable");
|
|
1155
|
+
return resp;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
// Store in edge cache
|
|
1159
|
+
if (serverFnCache) {
|
|
1160
|
+
try {
|
|
1161
|
+
const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
|
|
1162
|
+
const toStore = origin.clone();
|
|
1163
|
+
toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
|
1164
|
+
toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
|
|
1165
|
+
toStore.headers.delete("CDN-Cache-Control");
|
|
1166
|
+
toStore.headers.delete("X-Deco-Cacheable");
|
|
1167
|
+
ctx.waitUntil(serverFnCache.put(sfnCacheKey, toStore));
|
|
1168
|
+
} catch { /* Cache API unavailable */ }
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const resp = new Response(origin.body, origin);
|
|
1172
|
+
resp.headers.delete("X-Deco-Cacheable");
|
|
1173
|
+
const hdrs = cacheHeaders(sfnProfile);
|
|
1174
|
+
for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
|
|
1175
|
+
resp.headers.set("X-Cache", "MISS");
|
|
1176
|
+
resp.headers.set("X-Cache-Profile", sfnProfile);
|
|
1177
|
+
return resp;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
838
1180
|
// Non-cacheable requests — pass through but protect against accidental caching
|
|
839
1181
|
if (!isCacheable(request, url)) {
|
|
840
1182
|
const origin = await serverEntry.fetch(request, env, ctx);
|
|
@@ -850,10 +1192,28 @@ export function createDecoWorkerEntry(
|
|
|
850
1192
|
}
|
|
851
1193
|
|
|
852
1194
|
const resp = new Response(origin.body, origin);
|
|
1195
|
+
|
|
1196
|
+
// Responses with private Set-Cookie headers carry per-user tokens —
|
|
1197
|
+
// never expose them with public cache headers.
|
|
1198
|
+
// Safe/public cookies (e.g., vtex_is_session) are allowed through.
|
|
1199
|
+
if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
|
|
1200
|
+
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1201
|
+
resp.headers.delete("CDN-Cache-Control");
|
|
1202
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1203
|
+
resp.headers.set("X-Cache-Reason", "private-set-cookie");
|
|
1204
|
+
return resp;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// Set cache headers from the detected profile so the response
|
|
1208
|
+
// is explicit about cacheability (avoids ambiguous empty header).
|
|
1209
|
+
const hdrsNc = cacheHeaders(profile);
|
|
1210
|
+
for (const [k, v] of Object.entries(hdrsNc)) resp.headers.set(k, v);
|
|
1211
|
+
|
|
853
1212
|
const reason = request.method !== "GET"
|
|
854
1213
|
? `method:${request.method}`
|
|
855
1214
|
: "bypass-path";
|
|
856
1215
|
resp.headers.set("X-Cache", "BYPASS");
|
|
1216
|
+
resp.headers.set("X-Cache-Profile", profile);
|
|
857
1217
|
resp.headers.set("X-Cache-Reason", reason);
|
|
858
1218
|
return resp;
|
|
859
1219
|
}
|
|
@@ -885,7 +1245,22 @@ export function createDecoWorkerEntry(
|
|
|
885
1245
|
const out = new Response(resp.body, resp);
|
|
886
1246
|
const hdrs = cacheHeaders(profile);
|
|
887
1247
|
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
888
|
-
|
|
1248
|
+
|
|
1249
|
+
// CDN-Cache-Control: controls Cloudflare's automatic CDN layer
|
|
1250
|
+
// (separate from Cache API which the worker manages directly).
|
|
1251
|
+
if (cdnCacheControlOpt === "no-store") {
|
|
1252
|
+
out.headers.set("CDN-Cache-Control", "no-store");
|
|
1253
|
+
} else if (cdnCacheControlOpt === "match-profile") {
|
|
1254
|
+
if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
|
|
1255
|
+
out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
|
|
1256
|
+
} else {
|
|
1257
|
+
out.headers.set("CDN-Cache-Control", "no-store");
|
|
1258
|
+
}
|
|
1259
|
+
} else if (typeof cdnCacheControlOpt === "function") {
|
|
1260
|
+
const val = cdnCacheControlOpt(profile);
|
|
1261
|
+
out.headers.set("CDN-Cache-Control", val ?? "no-store");
|
|
1262
|
+
}
|
|
1263
|
+
|
|
889
1264
|
out.headers.set("X-Cache", xCache);
|
|
890
1265
|
out.headers.set("X-Cache-Profile", profile);
|
|
891
1266
|
if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
|
|
@@ -917,8 +1292,15 @@ export function createDecoWorkerEntry(
|
|
|
917
1292
|
function revalidateInBackground() {
|
|
918
1293
|
ctx.waitUntil(
|
|
919
1294
|
Promise.resolve(serverEntry.fetch(request, env, ctx)).then((origin) => {
|
|
920
|
-
if (origin.status === 200
|
|
921
|
-
|
|
1295
|
+
if (origin.status === 200) {
|
|
1296
|
+
// Only cache if response has no cookies or only safe cookies.
|
|
1297
|
+
// Strip safe cookies from the cached copy.
|
|
1298
|
+
if (hasOnlySafeCookies(origin, safeCookieSet)) {
|
|
1299
|
+
const cleanOrigin = origin.headers.has("set-cookie")
|
|
1300
|
+
? stripSafeCookiesForCache(origin, safeCookieSet)
|
|
1301
|
+
: origin;
|
|
1302
|
+
storeInCache(cleanOrigin);
|
|
1303
|
+
}
|
|
922
1304
|
}
|
|
923
1305
|
}).catch(() => {
|
|
924
1306
|
// Background revalidation failed — stale entry stays until SIE expires
|
|
@@ -998,14 +1380,16 @@ export function createDecoWorkerEntry(
|
|
|
998
1380
|
return resp;
|
|
999
1381
|
}
|
|
1000
1382
|
|
|
1001
|
-
// Responses with Set-Cookie must never be cached —
|
|
1002
|
-
// per-user session/auth tokens that would leak to other users.
|
|
1003
|
-
|
|
1383
|
+
// Responses with private Set-Cookie headers must never be cached —
|
|
1384
|
+
// they carry per-user session/auth tokens that would leak to other users.
|
|
1385
|
+
// Safe/public cookies (IS session, segment, etc.) are stripped from the
|
|
1386
|
+
// cached copy but kept on the response served to the current user.
|
|
1387
|
+
if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
|
|
1004
1388
|
const resp = new Response(origin.body, origin);
|
|
1005
1389
|
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1006
1390
|
resp.headers.delete("CDN-Cache-Control");
|
|
1007
1391
|
resp.headers.set("X-Cache", "BYPASS");
|
|
1008
|
-
resp.headers.set("X-Cache-Reason", "set-cookie");
|
|
1392
|
+
resp.headers.set("X-Cache-Reason", "private-set-cookie");
|
|
1009
1393
|
appendResourceHints(resp);
|
|
1010
1394
|
return resp;
|
|
1011
1395
|
}
|
|
@@ -1025,7 +1409,12 @@ export function createDecoWorkerEntry(
|
|
|
1025
1409
|
// dressResponse() calls new Response(resp.body, resp) which locks
|
|
1026
1410
|
// the ReadableStream. Calling clone() on a locked body corrupts
|
|
1027
1411
|
// the stream in Workers runtime, causing Error 1101.
|
|
1028
|
-
|
|
1412
|
+
// Strip safe cookies from the cached copy so they don't leak
|
|
1413
|
+
// to other users, but the current user still gets them.
|
|
1414
|
+
const cacheOrigin = origin.headers.has("set-cookie")
|
|
1415
|
+
? stripSafeCookiesForCache(origin, safeCookieSet)
|
|
1416
|
+
: origin;
|
|
1417
|
+
storeInCache(cacheOrigin);
|
|
1029
1418
|
return dressResponse(origin, "MISS");
|
|
1030
1419
|
}
|
|
1031
1420
|
}
|
package/src/vite/plugin.js
CHANGED
|
@@ -131,12 +131,40 @@ export function decoVitePlugin() {
|
|
|
131
131
|
) {
|
|
132
132
|
return "vendor-react";
|
|
133
133
|
}
|
|
134
|
+
|
|
135
|
+
// TanStack Router — client-side router (always needed)
|
|
134
136
|
if (
|
|
135
137
|
id.includes("@tanstack/react-router") ||
|
|
136
|
-
id.includes("@tanstack/
|
|
138
|
+
id.includes("@tanstack/router-core")
|
|
139
|
+
) {
|
|
140
|
+
return "vendor-router";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// TanStack Start — specific checks before broad catch-all
|
|
144
|
+
// (react-start-client includes "react-start" so must come first)
|
|
145
|
+
if (
|
|
146
|
+
id.includes("@tanstack/react-start-client") ||
|
|
147
|
+
id.includes("@tanstack/start-client-core")
|
|
148
|
+
) {
|
|
149
|
+
return "vendor-router";
|
|
150
|
+
}
|
|
151
|
+
// Server-only TanStack packages — let Rollup tree-shake
|
|
152
|
+
if (
|
|
153
|
+
id.includes("@tanstack/react-start-server") ||
|
|
154
|
+
id.includes("@tanstack/start-server-core")
|
|
137
155
|
) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
// Remaining @tanstack/start (storage-context, plugin-core, etc.)
|
|
159
|
+
if (id.includes("@tanstack/start")) {
|
|
138
160
|
return "vendor-router";
|
|
139
161
|
}
|
|
162
|
+
|
|
163
|
+
// isbot — server-only (bot detection in resolve.ts)
|
|
164
|
+
if (id.includes("node_modules/isbot")) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
140
168
|
if (id.includes("@tanstack/react-query")) {
|
|
141
169
|
return "vendor-query";
|
|
142
170
|
}
|