@decocms/start 0.36.4 → 0.37.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/scripts/migrate/transforms/jsx.ts +49 -0
- package/src/apps/autoconfig.ts +2 -2
- package/src/cms/sectionLoaders.ts +13 -2
- package/src/routes/cmsRoute.ts +2 -2
- package/src/sdk/cacheHeaders.ts +168 -137
- package/src/sdk/cachedLoader.ts +66 -40
- package/src/sdk/index.ts +10 -5
- package/src/sdk/workerEntry.ts +117 -83
package/package.json
CHANGED
|
@@ -171,6 +171,55 @@ export function transformJsx(content: string): TransformResult {
|
|
|
171
171
|
notes.push("Replaced 'class' in interface definitions with 'className'");
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
// Remove `alt` prop from non-img elements (<a>, <iframe>, <div>, etc.)
|
|
175
|
+
// In React, `alt` is only valid on <img>, <input type="image">, <area>
|
|
176
|
+
const altOnNonImgRegex = /(<(?:a|iframe|div|span|button|section)\s[^>]*?)\s+alt=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g;
|
|
177
|
+
if (altOnNonImgRegex.test(result)) {
|
|
178
|
+
result = result.replace(
|
|
179
|
+
/(<(?:a|iframe|div|span|button|section)\s[^>]*?)\s+alt=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g,
|
|
180
|
+
"$1",
|
|
181
|
+
);
|
|
182
|
+
changed = true;
|
|
183
|
+
notes.push("Removed invalid alt prop from non-img elements");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Remove `type` prop from non-form elements (<span>, <div>, etc.)
|
|
187
|
+
// In React, `type` is only valid on <input>, <button>, <select>, <textarea>, <script>, <style>, <link>
|
|
188
|
+
const typeOnInvalidRegex = /(<(?:span|div|p|section|header|footer|nav|main|article|aside)\s[^>]*?)\s+type=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g;
|
|
189
|
+
if (typeOnInvalidRegex.test(result)) {
|
|
190
|
+
result = result.replace(
|
|
191
|
+
/(<(?:span|div|p|section|header|footer|nav|main|article|aside)\s[^>]*?)\s+type=(?:\{[^}]*\}|"[^"]*"|'[^']*')/g,
|
|
192
|
+
"$1",
|
|
193
|
+
);
|
|
194
|
+
changed = true;
|
|
195
|
+
notes.push("Removed invalid type prop from non-form elements");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// setTimeout/setInterval return type: use window.setTimeout for correct typing
|
|
199
|
+
// In Node/CF Workers, setTimeout returns Timeout object, not number
|
|
200
|
+
// window.setTimeout always returns number
|
|
201
|
+
if (/\bsetTimeout\b/.test(result) && /:\s*number/.test(result)) {
|
|
202
|
+
// Only replace bare setTimeout when it's assigned to a typed variable
|
|
203
|
+
result = result.replace(
|
|
204
|
+
/\b(?<!window\.)setTimeout\(/g,
|
|
205
|
+
"window.setTimeout(",
|
|
206
|
+
);
|
|
207
|
+
result = result.replace(
|
|
208
|
+
/\b(?<!window\.)setInterval\(/g,
|
|
209
|
+
"window.setInterval(",
|
|
210
|
+
);
|
|
211
|
+
result = result.replace(
|
|
212
|
+
/\b(?<!window\.)clearTimeout\(/g,
|
|
213
|
+
"window.clearTimeout(",
|
|
214
|
+
);
|
|
215
|
+
result = result.replace(
|
|
216
|
+
/\b(?<!window\.)clearInterval\(/g,
|
|
217
|
+
"window.clearInterval(",
|
|
218
|
+
);
|
|
219
|
+
changed = true;
|
|
220
|
+
notes.push("Prefixed setTimeout/setInterval with window. for correct typing");
|
|
221
|
+
}
|
|
222
|
+
|
|
174
223
|
// Ensure React import exists if we introduced React.* references
|
|
175
224
|
if (
|
|
176
225
|
(result.includes("React.") || result.includes("React,")) &&
|
package/src/apps/autoconfig.ts
CHANGED
|
@@ -25,7 +25,7 @@ interface AppAutoconfigurator {
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const KNOWN_APPS: Record<string, AppAutoconfigurator> = {
|
|
28
|
-
"deco-resend": async (block: any) => {
|
|
28
|
+
"deco-resend": async (block: any): Promise<Record<string, InvokeAction>> => {
|
|
29
29
|
try {
|
|
30
30
|
const [resendClient, resendActions] = await Promise.all([
|
|
31
31
|
import("@decocms/apps/resend/client" as string),
|
|
@@ -40,7 +40,7 @@ const KNOWN_APPS: Record<string, AppAutoconfigurator> = {
|
|
|
40
40
|
"[autoconfig] deco-resend: no API key found." +
|
|
41
41
|
" Set DECO_CRYPTO_KEY to decrypt CMS secrets, or set RESEND_API_KEY as fallback.",
|
|
42
42
|
);
|
|
43
|
-
return {}
|
|
43
|
+
return {} as Record<string, InvokeAction>;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
configureResend({
|
|
@@ -33,6 +33,17 @@ interface CacheableSectionConfig {
|
|
|
33
33
|
maxAge: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
type CacheableSectionInput = CacheableSectionConfig | import("../sdk/cacheHeaders").CacheProfileName;
|
|
37
|
+
|
|
38
|
+
function resolveSectionCacheConfig(input: CacheableSectionInput): CacheableSectionConfig {
|
|
39
|
+
if (typeof input === "string") {
|
|
40
|
+
const { getCacheProfile } = require("../sdk/cacheHeaders") as typeof import("../sdk/cacheHeaders");
|
|
41
|
+
const profile = getCacheProfile(input);
|
|
42
|
+
return { maxAge: profile.loader.fresh };
|
|
43
|
+
}
|
|
44
|
+
return input;
|
|
45
|
+
}
|
|
46
|
+
|
|
36
47
|
const cacheableSections: Map<string, CacheableSectionConfig> = G.__deco.cacheableSections;
|
|
37
48
|
|
|
38
49
|
interface SectionCacheEntry {
|
|
@@ -64,9 +75,9 @@ function sectionCacheKey(component: string, props: Record<string, unknown>): str
|
|
|
64
75
|
* Works for both eager sections (speeds up SSR) and deferred sections
|
|
65
76
|
* (speeds up individual fetch on scroll).
|
|
66
77
|
*/
|
|
67
|
-
export function registerCacheableSections(configs: Record<string,
|
|
78
|
+
export function registerCacheableSections(configs: Record<string, CacheableSectionInput>): void {
|
|
68
79
|
for (const [key, config] of Object.entries(configs)) {
|
|
69
|
-
cacheableSections.set(key, config);
|
|
80
|
+
cacheableSections.set(key, resolveSectionCacheConfig(config));
|
|
70
81
|
}
|
|
71
82
|
}
|
|
72
83
|
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -41,7 +41,7 @@ import {
|
|
|
41
41
|
import { getSiteSeo } from "../cms/loader";
|
|
42
42
|
import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
|
|
43
43
|
import {
|
|
44
|
-
type
|
|
44
|
+
type CacheProfileName,
|
|
45
45
|
cacheHeaders,
|
|
46
46
|
detectCacheProfile,
|
|
47
47
|
routeCacheDefaults,
|
|
@@ -328,7 +328,7 @@ export interface CmsRouteOptions {
|
|
|
328
328
|
|
|
329
329
|
type CmsPageLoaderData = {
|
|
330
330
|
name?: string;
|
|
331
|
-
cacheProfile?:
|
|
331
|
+
cacheProfile?: CacheProfileName;
|
|
332
332
|
seo?: PageSeo;
|
|
333
333
|
device?: Device;
|
|
334
334
|
resolvedSections?: Array<{ component: string }>;
|
package/src/sdk/cacheHeaders.ts
CHANGED
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Unified cache profile system for Deco storefronts.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Each named profile (product, listing, search, static, etc.) defines cache
|
|
5
|
+
* timing across ALL layers — edge, browser, loader, and client — in a single
|
|
6
|
+
* object. This is the single source of truth for cache configuration.
|
|
7
|
+
*
|
|
8
|
+
* Sites override specific values via `setCacheProfile()` without touching
|
|
9
|
+
* framework code. Derivation functions (`cacheHeaders`, `routeCacheDefaults`,
|
|
10
|
+
* `loaderCacheOptions`, `edgeCacheConfig`) read from the profiles.
|
|
6
11
|
*
|
|
7
12
|
* @example
|
|
8
13
|
* ```ts
|
|
9
|
-
* //
|
|
10
|
-
* import {
|
|
11
|
-
*
|
|
12
|
-
* export const Route = createFileRoute('/products/$slug')({
|
|
13
|
-
* ...routeCacheDefaults("product"),
|
|
14
|
-
* headers: () => cacheHeaders("product"),
|
|
15
|
-
* });
|
|
14
|
+
* // Site-level override (src/cache-config.ts):
|
|
15
|
+
* import { setCacheProfile } from "@decocms/start/sdk/cacheHeaders";
|
|
16
|
+
* setCacheProfile("product", { edge: { fresh: 600 } }); // 10min instead of 5min
|
|
16
17
|
* ```
|
|
17
18
|
*/
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export type CacheProfileName =
|
|
20
25
|
| "static"
|
|
21
26
|
| "product"
|
|
22
27
|
| "listing"
|
|
@@ -25,94 +30,156 @@ export type CacheProfile =
|
|
|
25
30
|
| "private"
|
|
26
31
|
| "none";
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
/** Time windows for a single caching layer (in seconds). */
|
|
34
|
+
export interface CacheTimingWindow {
|
|
35
|
+
/** How long content is considered fresh — served without origin contact. */
|
|
36
|
+
fresh: number;
|
|
37
|
+
/** After fresh expires, serve stale while refreshing in background. */
|
|
38
|
+
swr: number;
|
|
39
|
+
/** After fresh expires and origin is erroring, serve stale for this long. */
|
|
40
|
+
sie: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Unified cache profile covering all layers. */
|
|
44
|
+
export interface CacheProfileConfig {
|
|
45
|
+
/** Edge / CDN layer (Cloudflare Cache API). Times in seconds. */
|
|
46
|
+
edge: CacheTimingWindow;
|
|
47
|
+
/** Browser layer (Cache-Control header). Times in seconds. */
|
|
48
|
+
browser: CacheTimingWindow;
|
|
49
|
+
/** In-memory loader layer. Times in milliseconds. */
|
|
50
|
+
loader: {
|
|
51
|
+
fresh: number;
|
|
52
|
+
sie: number;
|
|
53
|
+
};
|
|
54
|
+
/** Client-side TanStack Router. Times in milliseconds. */
|
|
55
|
+
client: {
|
|
56
|
+
staleTime: number;
|
|
57
|
+
gcTime: number;
|
|
58
|
+
};
|
|
59
|
+
/** Whether CDN can cache this profile. False = private, never cached. */
|
|
36
60
|
isPublic: boolean;
|
|
37
61
|
}
|
|
38
62
|
|
|
39
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Deep partial of CacheProfileConfig for site-level overrides.
|
|
65
|
+
* Only the fields you specify are merged; everything else keeps its default.
|
|
66
|
+
*/
|
|
67
|
+
export type CacheProfileOverrides = {
|
|
68
|
+
[K in keyof CacheProfileConfig]?: CacheProfileConfig[K] extends object
|
|
69
|
+
? Partial<CacheProfileConfig[K]>
|
|
70
|
+
: CacheProfileConfig[K];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// Default profiles
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const PROFILES: Record<CacheProfileName, CacheProfileConfig> = {
|
|
40
78
|
static: {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
79
|
+
edge: { fresh: 900, swr: 7200, sie: 21600 },
|
|
80
|
+
browser: { fresh: 120, swr: 1800, sie: 7200 },
|
|
81
|
+
loader: { fresh: 300_000, sie: 1_800_000 },
|
|
82
|
+
client: { staleTime: 300_000, gcTime: 1_800_000 },
|
|
44
83
|
isPublic: true,
|
|
45
84
|
},
|
|
46
85
|
product: {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
86
|
+
edge: { fresh: 300, swr: 1800, sie: 7200 },
|
|
87
|
+
browser: { fresh: 60, swr: 600, sie: 3600 },
|
|
88
|
+
loader: { fresh: 30_000, sie: 600_000 },
|
|
89
|
+
client: { staleTime: 60_000, gcTime: 300_000 },
|
|
50
90
|
isPublic: true,
|
|
51
91
|
},
|
|
52
92
|
listing: {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
93
|
+
edge: { fresh: 120, swr: 900, sie: 3600 },
|
|
94
|
+
browser: { fresh: 30, swr: 300, sie: 1800 },
|
|
95
|
+
loader: { fresh: 60_000, sie: 300_000 },
|
|
96
|
+
client: { staleTime: 60_000, gcTime: 300_000 },
|
|
56
97
|
isPublic: true,
|
|
57
98
|
},
|
|
58
99
|
search: {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
100
|
+
edge: { fresh: 60, swr: 300, sie: 1800 },
|
|
101
|
+
browser: { fresh: 0, swr: 120, sie: 600 },
|
|
102
|
+
loader: { fresh: 60_000, sie: 180_000 },
|
|
103
|
+
client: { staleTime: 30_000, gcTime: 120_000 },
|
|
62
104
|
isPublic: true,
|
|
63
105
|
},
|
|
64
106
|
cart: {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
edge: { fresh: 0, swr: 0, sie: 0 },
|
|
108
|
+
browser: { fresh: 0, swr: 0, sie: 0 },
|
|
109
|
+
loader: { fresh: 0, sie: 0 },
|
|
110
|
+
client: { staleTime: 0, gcTime: 0 },
|
|
68
111
|
isPublic: false,
|
|
69
112
|
},
|
|
70
113
|
private: {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
114
|
+
edge: { fresh: 0, swr: 0, sie: 0 },
|
|
115
|
+
browser: { fresh: 0, swr: 0, sie: 0 },
|
|
116
|
+
loader: { fresh: 0, sie: 0 },
|
|
117
|
+
client: { staleTime: 0, gcTime: 0 },
|
|
74
118
|
isPublic: false,
|
|
75
119
|
},
|
|
76
120
|
none: {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
121
|
+
edge: { fresh: 0, swr: 0, sie: 0 },
|
|
122
|
+
browser: { fresh: 0, swr: 0, sie: 0 },
|
|
123
|
+
loader: { fresh: 0, sie: 0 },
|
|
124
|
+
client: { staleTime: 0, gcTime: 0 },
|
|
80
125
|
isPublic: false,
|
|
81
126
|
},
|
|
82
127
|
};
|
|
83
128
|
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Profile accessors
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
export function getCacheProfile(profile: CacheProfileName): CacheProfileConfig {
|
|
134
|
+
return PROFILES[profile];
|
|
135
|
+
}
|
|
136
|
+
|
|
84
137
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
138
|
+
* Override specific values of a cache profile. Only the fields you specify
|
|
139
|
+
* are merged; everything else keeps its default.
|
|
87
140
|
*
|
|
88
|
-
*
|
|
141
|
+
* @example
|
|
142
|
+
* ```ts
|
|
143
|
+
* setCacheProfile("product", { edge: { fresh: 600 } });
|
|
144
|
+
* setCacheProfile("static", { loader: { sie: 3_600_000 } });
|
|
145
|
+
* ```
|
|
146
|
+
*/
|
|
147
|
+
export function setCacheProfile(
|
|
148
|
+
profile: CacheProfileName,
|
|
149
|
+
overrides: CacheProfileOverrides,
|
|
150
|
+
): void {
|
|
151
|
+
const current = PROFILES[profile];
|
|
152
|
+
PROFILES[profile] = {
|
|
153
|
+
edge: { ...current.edge, ...overrides.edge },
|
|
154
|
+
browser: { ...current.browser, ...overrides.browser },
|
|
155
|
+
loader: { ...current.loader, ...overrides.loader },
|
|
156
|
+
client: { ...current.client, ...overrides.client },
|
|
157
|
+
isPublic: overrides.isPublic ?? current.isPublic,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Derivation: Cache-Control headers (browser layer)
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Generate a `Cache-Control` header from a named profile.
|
|
167
|
+
* Returns a headers object ready to spread into route `headers()`.
|
|
89
168
|
*/
|
|
90
|
-
export function cacheHeaders(
|
|
91
|
-
|
|
92
|
-
): Record<string, string> {
|
|
93
|
-
const config = typeof profileOrConfig === "string" ? PROFILES[profileOrConfig] : profileOrConfig;
|
|
169
|
+
export function cacheHeaders(profile: CacheProfileName): Record<string, string> {
|
|
170
|
+
const p = PROFILES[profile];
|
|
94
171
|
|
|
95
|
-
if (!
|
|
172
|
+
if (!p.isPublic || (p.edge.fresh === 0 && p.browser.fresh === 0)) {
|
|
96
173
|
return {
|
|
97
174
|
"Cache-Control": "private, no-cache, no-store, must-revalidate",
|
|
98
175
|
};
|
|
99
176
|
}
|
|
100
177
|
|
|
101
178
|
const parts: string[] = ["public"];
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
parts.push("max-age=0");
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
if (config.sMaxAge > 0) {
|
|
110
|
-
parts.push(`s-maxage=${config.sMaxAge}`);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
if (config.staleWhileRevalidate > 0) {
|
|
114
|
-
parts.push(`stale-while-revalidate=${config.staleWhileRevalidate}`);
|
|
115
|
-
}
|
|
179
|
+
parts.push(p.browser.fresh > 0 ? `max-age=${p.browser.fresh}` : "max-age=0");
|
|
180
|
+
if (p.edge.fresh > 0) parts.push(`s-maxage=${p.edge.fresh}`);
|
|
181
|
+
if (p.browser.swr > 0) parts.push(`stale-while-revalidate=${p.browser.swr}`);
|
|
182
|
+
if (p.browser.sie > 0) parts.push(`stale-if-error=${p.browser.sie}`);
|
|
116
183
|
|
|
117
184
|
return {
|
|
118
185
|
"Cache-Control": parts.join(", "),
|
|
@@ -120,83 +187,61 @@ export function cacheHeaders(
|
|
|
120
187
|
};
|
|
121
188
|
}
|
|
122
189
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
export
|
|
128
|
-
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Derivation: Edge cache config (for workerEntry SWR/SIE logic)
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export interface EdgeCacheConfig {
|
|
195
|
+
fresh: number;
|
|
196
|
+
swr: number;
|
|
197
|
+
sie: number;
|
|
198
|
+
isPublic: boolean;
|
|
129
199
|
}
|
|
130
200
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
* freshness requirements (e.g., longer product TTL for low-traffic stores).
|
|
135
|
-
*
|
|
136
|
-
* @example
|
|
137
|
-
* ```ts
|
|
138
|
-
* setCacheProfileConfig("product", { maxAge: 120, sMaxAge: 600, staleWhileRevalidate: 7200, isPublic: true });
|
|
139
|
-
* ```
|
|
140
|
-
*/
|
|
141
|
-
export function setCacheProfileConfig(profile: CacheProfile, config: CacheHeadersConfig): void {
|
|
142
|
-
PROFILES[profile] = config;
|
|
201
|
+
export function edgeCacheConfig(profile: CacheProfileName): EdgeCacheConfig {
|
|
202
|
+
const p = PROFILES[profile];
|
|
203
|
+
return { ...p.edge, isPublic: p.isPublic };
|
|
143
204
|
}
|
|
144
205
|
|
|
145
206
|
// ---------------------------------------------------------------------------
|
|
146
|
-
// Client-side route cache defaults (TanStack Router
|
|
207
|
+
// Derivation: Client-side route cache defaults (TanStack Router)
|
|
147
208
|
// ---------------------------------------------------------------------------
|
|
148
209
|
|
|
149
|
-
interface RouteCacheDefaults {
|
|
150
|
-
/** How long route data is considered fresh on the client (ms). */
|
|
151
|
-
staleTime: number;
|
|
152
|
-
/** How long stale data is kept in memory before garbage collection (ms). */
|
|
153
|
-
gcTime: number;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const ROUTE_CACHE: Record<CacheProfile, RouteCacheDefaults> = {
|
|
157
|
-
static: { staleTime: 5 * 60_000, gcTime: 30 * 60_000 },
|
|
158
|
-
product: { staleTime: 60_000, gcTime: 5 * 60_000 },
|
|
159
|
-
listing: { staleTime: 60_000, gcTime: 5 * 60_000 },
|
|
160
|
-
search: { staleTime: 30_000, gcTime: 2 * 60_000 },
|
|
161
|
-
cart: { staleTime: 0, gcTime: 0 },
|
|
162
|
-
private: { staleTime: 0, gcTime: 0 },
|
|
163
|
-
none: { staleTime: 0, gcTime: 0 },
|
|
164
|
-
};
|
|
165
|
-
|
|
166
210
|
/**
|
|
167
211
|
* Returns `{ staleTime, gcTime }` for a cache profile, ready to spread
|
|
168
212
|
* into a TanStack Router route definition.
|
|
169
213
|
*
|
|
170
214
|
* In dev mode, uses short staleTime (5s) to keep data fresh enough for
|
|
171
|
-
* development while avoiding redundant re-fetches
|
|
172
|
-
* interactions (e.g. variant switching on a PDP).
|
|
173
|
-
*
|
|
174
|
-
* @example
|
|
175
|
-
* ```ts
|
|
176
|
-
* export const Route = createFileRoute("/$")({
|
|
177
|
-
* ...routeCacheDefaults("listing"),
|
|
178
|
-
* loader: ...,
|
|
179
|
-
* headers: () => cacheHeaders("listing"),
|
|
180
|
-
* });
|
|
181
|
-
* ```
|
|
215
|
+
* development while avoiding redundant re-fetches.
|
|
182
216
|
*/
|
|
183
|
-
export function routeCacheDefaults(profile:
|
|
217
|
+
export function routeCacheDefaults(profile: CacheProfileName): { staleTime: number; gcTime: number } {
|
|
184
218
|
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
185
219
|
const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
|
|
186
220
|
if (isDev) return { staleTime: 5_000, gcTime: 30_000 };
|
|
187
|
-
return
|
|
221
|
+
return { ...PROFILES[profile].client };
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Derivation: Loader cache options (for createCachedLoader)
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
export interface LoaderCacheOptions {
|
|
229
|
+
policy: "stale-while-revalidate";
|
|
230
|
+
maxAge: number;
|
|
231
|
+
staleIfError: number;
|
|
188
232
|
}
|
|
189
233
|
|
|
190
234
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* @example
|
|
194
|
-
* ```ts
|
|
195
|
-
* setRouteCacheDefaults("product", { staleTime: 120_000, gcTime: 10 * 60_000 });
|
|
196
|
-
* ```
|
|
235
|
+
* Get loader-layer cache options for a profile.
|
|
236
|
+
* Pass directly to `createCachedLoader` as the options argument.
|
|
197
237
|
*/
|
|
198
|
-
export function
|
|
199
|
-
|
|
238
|
+
export function loaderCacheOptions(profile: CacheProfileName): LoaderCacheOptions {
|
|
239
|
+
const p = PROFILES[profile];
|
|
240
|
+
return {
|
|
241
|
+
policy: "stale-while-revalidate",
|
|
242
|
+
maxAge: p.loader.fresh,
|
|
243
|
+
staleIfError: p.loader.sie,
|
|
244
|
+
};
|
|
200
245
|
}
|
|
201
246
|
|
|
202
247
|
// ---------------------------------------------------------------------------
|
|
@@ -205,11 +250,10 @@ export function setRouteCacheDefaults(profile: CacheProfile, defaults: RouteCach
|
|
|
205
250
|
|
|
206
251
|
interface CachePattern {
|
|
207
252
|
test: (pathname: string, searchParams: URLSearchParams) => boolean;
|
|
208
|
-
profile:
|
|
253
|
+
profile: CacheProfileName;
|
|
209
254
|
}
|
|
210
255
|
|
|
211
256
|
const builtinPatterns: CachePattern[] = [
|
|
212
|
-
// Private routes — must be first (highest priority)
|
|
213
257
|
{
|
|
214
258
|
test: (p) =>
|
|
215
259
|
p.startsWith("/cart") ||
|
|
@@ -219,7 +263,6 @@ const builtinPatterns: CachePattern[] = [
|
|
|
219
263
|
p.startsWith("/my-account"),
|
|
220
264
|
profile: "private",
|
|
221
265
|
},
|
|
222
|
-
// Internal / API routes
|
|
223
266
|
{
|
|
224
267
|
test: (p) =>
|
|
225
268
|
p.startsWith("/api/") ||
|
|
@@ -228,17 +271,14 @@ const builtinPatterns: CachePattern[] = [
|
|
|
228
271
|
p.startsWith("/_build"),
|
|
229
272
|
profile: "none",
|
|
230
273
|
},
|
|
231
|
-
// Search pages
|
|
232
274
|
{
|
|
233
275
|
test: (p, sp) => p === "/s" || p.startsWith("/s/") || sp.has("q"),
|
|
234
276
|
profile: "search",
|
|
235
277
|
},
|
|
236
|
-
// PDP — VTEX convention: URL ends with /p
|
|
237
278
|
{
|
|
238
279
|
test: (p) => p.endsWith("/p") || /\/p[?#]/.test(p),
|
|
239
280
|
profile: "product",
|
|
240
281
|
},
|
|
241
|
-
// Home page
|
|
242
282
|
{
|
|
243
283
|
test: (p) => p === "/" || p === "",
|
|
244
284
|
profile: "static",
|
|
@@ -250,14 +290,6 @@ const customPatterns: CachePattern[] = [];
|
|
|
250
290
|
/**
|
|
251
291
|
* Register additional URL-to-profile patterns. Custom patterns are evaluated
|
|
252
292
|
* before built-in ones, so they can override defaults.
|
|
253
|
-
*
|
|
254
|
-
* @example
|
|
255
|
-
* ```ts
|
|
256
|
-
* registerCachePattern({
|
|
257
|
-
* test: (p) => p.startsWith("/institucional"),
|
|
258
|
-
* profile: "static",
|
|
259
|
-
* });
|
|
260
|
-
* ```
|
|
261
293
|
*/
|
|
262
294
|
export function registerCachePattern(pattern: CachePattern): void {
|
|
263
295
|
customPatterns.push(pattern);
|
|
@@ -266,9 +298,9 @@ export function registerCachePattern(pattern: CachePattern): void {
|
|
|
266
298
|
/**
|
|
267
299
|
* Detect the appropriate cache profile based on a URL.
|
|
268
300
|
* Evaluates custom patterns first, then built-in patterns.
|
|
269
|
-
* Falls back to "listing"
|
|
301
|
+
* Falls back to "listing" for unmatched paths.
|
|
270
302
|
*/
|
|
271
|
-
export function detectCacheProfile(pathnameOrUrl: string | URL):
|
|
303
|
+
export function detectCacheProfile(pathnameOrUrl: string | URL): CacheProfileName {
|
|
272
304
|
let pathname: string;
|
|
273
305
|
let searchParams: URLSearchParams;
|
|
274
306
|
|
|
@@ -289,6 +321,5 @@ export function detectCacheProfile(pathnameOrUrl: string | URL): CacheProfile {
|
|
|
289
321
|
if (pattern.test(pathname, searchParams)) return pattern.profile;
|
|
290
322
|
}
|
|
291
323
|
|
|
292
|
-
// Default: listing (conservative, short edge TTL)
|
|
293
324
|
return "listing";
|
|
294
325
|
}
|
package/src/sdk/cachedLoader.ts
CHANGED
|
@@ -1,36 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Server-side loader caching with stale-while-revalidate
|
|
2
|
+
* Server-side loader caching with stale-while-revalidate + stale-if-error.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Provides an in-memory cache layer for commerce loaders during SSR.
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Single-flight dedup (identical concurrent requests share one fetch)
|
|
7
|
+
* - SWR: serve stale immediately, refresh in background
|
|
8
|
+
* - SIE: on origin error, fall back to stale entry within a configurable window
|
|
7
9
|
*
|
|
8
|
-
*
|
|
10
|
+
* Can be configured with explicit options or by passing a cache profile name
|
|
11
|
+
* (e.g. "product") which derives timing from the unified profile system.
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
import type { CacheProfileName } from "./cacheHeaders";
|
|
15
|
+
|
|
11
16
|
export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
|
|
12
17
|
|
|
13
18
|
export interface CachedLoaderOptions {
|
|
14
19
|
policy: CachePolicy;
|
|
15
20
|
/** Max age in milliseconds before an entry is considered stale. Default: 60_000 (1 min). */
|
|
16
21
|
maxAge?: number;
|
|
22
|
+
/** How long to serve stale on origin error, in ms. Default: 0 (no error fallback). */
|
|
23
|
+
staleIfError?: number;
|
|
17
24
|
/** Key function to generate a cache key from loader props. Default: JSON.stringify. */
|
|
18
25
|
keyFn?: (props: unknown) => string;
|
|
19
26
|
}
|
|
20
27
|
|
|
21
|
-
/**
|
|
22
|
-
* Loader module interface for modules that export cache configuration alongside
|
|
23
|
-
* their default loader function. Mirrors deco-cx/apps pattern where loaders
|
|
24
|
-
* can declare their own caching policy.
|
|
25
|
-
*
|
|
26
|
-
* @example
|
|
27
|
-
* ```ts
|
|
28
|
-
* // In a loader file:
|
|
29
|
-
* export const cache = "stale-while-revalidate";
|
|
30
|
-
* export const cacheKey = (props: any) => `myLoader:${props.slug}`;
|
|
31
|
-
* export default async function myLoader(props: Props) { ... }
|
|
32
|
-
* ```
|
|
33
|
-
*/
|
|
34
28
|
export interface LoaderModule<TProps = any, TResult = any> {
|
|
35
29
|
default: (props: TProps) => Promise<TResult>;
|
|
36
30
|
cache?: CachePolicy | { maxAge: number };
|
|
@@ -57,24 +51,49 @@ function evictIfNeeded() {
|
|
|
57
51
|
|
|
58
52
|
const inflightRequests = new Map<string, Promise<unknown>>();
|
|
59
53
|
|
|
54
|
+
function resolveOptions(
|
|
55
|
+
optionsOrProfile: CachedLoaderOptions | CacheProfileName,
|
|
56
|
+
): CachedLoaderOptions {
|
|
57
|
+
if (typeof optionsOrProfile === "string") {
|
|
58
|
+
// Lazy import to avoid circular dependency at module load time.
|
|
59
|
+
// loaderCacheOptions() reads from the PROFILES map in cacheHeaders.ts.
|
|
60
|
+
const { loaderCacheOptions } = require("./cacheHeaders") as typeof import("./cacheHeaders");
|
|
61
|
+
return loaderCacheOptions(optionsOrProfile);
|
|
62
|
+
}
|
|
63
|
+
return optionsOrProfile;
|
|
64
|
+
}
|
|
65
|
+
|
|
60
66
|
/**
|
|
61
|
-
* Wraps a loader function with server-side caching
|
|
67
|
+
* Wraps a loader function with server-side caching, single-flight dedup,
|
|
68
|
+
* and stale-if-error resilience.
|
|
69
|
+
*
|
|
70
|
+
* Accepts either explicit options or a cache profile name:
|
|
62
71
|
*
|
|
63
72
|
* @example
|
|
64
73
|
* ```ts
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
74
|
+
* // Profile-driven (recommended):
|
|
75
|
+
* const cachedPDP = createCachedLoader("vtex/productDetailsPage", pdpLoader, "product");
|
|
76
|
+
*
|
|
77
|
+
* // Explicit options (when loader needs different timing than its profile):
|
|
78
|
+
* const cachedSuggestions = createCachedLoader("vtex/suggestions", suggestionsLoader, {
|
|
79
|
+
* policy: "stale-while-revalidate",
|
|
80
|
+
* maxAge: 120_000,
|
|
81
|
+
* staleIfError: 300_000,
|
|
82
|
+
* });
|
|
70
83
|
* ```
|
|
71
84
|
*/
|
|
72
85
|
export function createCachedLoader<TProps, TResult>(
|
|
73
86
|
name: string,
|
|
74
87
|
loaderFn: (props: TProps) => Promise<TResult>,
|
|
75
|
-
|
|
88
|
+
optionsOrProfile: CachedLoaderOptions | CacheProfileName,
|
|
76
89
|
): (props: TProps) => Promise<TResult> {
|
|
77
|
-
const
|
|
90
|
+
const resolved = resolveOptions(optionsOrProfile);
|
|
91
|
+
const {
|
|
92
|
+
policy,
|
|
93
|
+
maxAge = DEFAULT_MAX_AGE,
|
|
94
|
+
staleIfError = 0,
|
|
95
|
+
keyFn = JSON.stringify,
|
|
96
|
+
} = resolved;
|
|
78
97
|
|
|
79
98
|
const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
|
|
80
99
|
const isDev = env?.DECO_CACHE_DISABLE === "true" || env?.NODE_ENV === "development";
|
|
@@ -84,11 +103,9 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
84
103
|
return async (props: TProps): Promise<TResult> => {
|
|
85
104
|
const cacheKey = `${name}::${keyFn(props)}`;
|
|
86
105
|
|
|
87
|
-
// Single-flight dedup: if an identical request is already in-flight, reuse it
|
|
88
106
|
const inflight = inflightRequests.get(cacheKey);
|
|
89
107
|
if (inflight) return inflight as Promise<TResult>;
|
|
90
108
|
|
|
91
|
-
// In dev mode, skip SWR cache but keep inflight dedup
|
|
92
109
|
if (isDev) {
|
|
93
110
|
const promise = loaderFn(props).finally(() => inflightRequests.delete(cacheKey));
|
|
94
111
|
inflightRequests.set(cacheKey, promise);
|
|
@@ -101,7 +118,6 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
101
118
|
|
|
102
119
|
if (policy === "no-cache") {
|
|
103
120
|
if (entry && !isStale) return entry.value;
|
|
104
|
-
// Stale or missing — fetch fresh
|
|
105
121
|
}
|
|
106
122
|
|
|
107
123
|
if (policy === "stale-while-revalidate") {
|
|
@@ -109,7 +125,6 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
109
125
|
|
|
110
126
|
if (entry && isStale && !entry.refreshing) {
|
|
111
127
|
entry.refreshing = true;
|
|
112
|
-
// Fire-and-forget background refresh
|
|
113
128
|
loaderFn(props)
|
|
114
129
|
.then((result) => {
|
|
115
130
|
cache.set(cacheKey, {
|
|
@@ -119,7 +134,12 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
119
134
|
});
|
|
120
135
|
})
|
|
121
136
|
.catch(() => {
|
|
137
|
+
// Background refresh failed — entry stays stale.
|
|
138
|
+
// If past the SIE window, evict so we don't serve indefinitely stale data.
|
|
122
139
|
entry.refreshing = false;
|
|
140
|
+
if (staleIfError > 0 && now - entry.createdAt > maxAge + staleIfError) {
|
|
141
|
+
cache.delete(cacheKey);
|
|
142
|
+
}
|
|
123
143
|
});
|
|
124
144
|
return entry.value;
|
|
125
145
|
}
|
|
@@ -140,6 +160,16 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
140
160
|
})
|
|
141
161
|
.catch((err) => {
|
|
142
162
|
inflightRequests.delete(cacheKey);
|
|
163
|
+
// SIE fallback: if we have a stale entry within the error window, return it
|
|
164
|
+
if (staleIfError > 0 && entry) {
|
|
165
|
+
const age = now - entry.createdAt;
|
|
166
|
+
if (age < maxAge + staleIfError) {
|
|
167
|
+
console.warn(
|
|
168
|
+
`[cachedLoader] ${name}: origin error, serving stale entry (age=${Math.round(age / 1000)}s, sie=${Math.round(staleIfError / 1000)}s)`,
|
|
169
|
+
);
|
|
170
|
+
return entry.value;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
143
173
|
throw err;
|
|
144
174
|
});
|
|
145
175
|
|
|
@@ -151,15 +181,6 @@ export function createCachedLoader<TProps, TResult>(
|
|
|
151
181
|
/**
|
|
152
182
|
* Create a cached loader from a module that exports `cache` and/or `cacheKey`.
|
|
153
183
|
* Falls back to the provided defaults if the module doesn't declare them.
|
|
154
|
-
*
|
|
155
|
-
* @example
|
|
156
|
-
* ```ts
|
|
157
|
-
* import * as myLoaderModule from "./loaders/myLoader";
|
|
158
|
-
* const cached = createCachedLoaderFromModule("myLoader", myLoaderModule, {
|
|
159
|
-
* policy: "stale-while-revalidate",
|
|
160
|
-
* maxAge: 60_000,
|
|
161
|
-
* });
|
|
162
|
-
* ```
|
|
163
184
|
*/
|
|
164
185
|
export function createCachedLoaderFromModule<TProps, TResult>(
|
|
165
186
|
name: string,
|
|
@@ -188,7 +209,12 @@ export function createCachedLoaderFromModule<TProps, TResult>(
|
|
|
188
209
|
}
|
|
189
210
|
: defaults?.keyFn;
|
|
190
211
|
|
|
191
|
-
return createCachedLoader(name, mod.default, {
|
|
212
|
+
return createCachedLoader(name, mod.default, {
|
|
213
|
+
policy,
|
|
214
|
+
maxAge,
|
|
215
|
+
staleIfError: defaults?.staleIfError,
|
|
216
|
+
keyFn,
|
|
217
|
+
});
|
|
192
218
|
}
|
|
193
219
|
|
|
194
220
|
/** Clear all cached entries. Useful for decofile hot-reload. */
|
package/src/sdk/index.ts
CHANGED
|
@@ -7,15 +7,20 @@ export {
|
|
|
7
7
|
getLoaderCacheStats,
|
|
8
8
|
} from "./cachedLoader";
|
|
9
9
|
export {
|
|
10
|
-
type
|
|
11
|
-
type
|
|
10
|
+
type CacheProfileConfig,
|
|
11
|
+
type CacheProfileName,
|
|
12
|
+
type CacheProfileOverrides,
|
|
13
|
+
type CacheTimingWindow,
|
|
14
|
+
type EdgeCacheConfig,
|
|
15
|
+
type LoaderCacheOptions,
|
|
12
16
|
cacheHeaders,
|
|
13
17
|
detectCacheProfile,
|
|
14
|
-
|
|
18
|
+
edgeCacheConfig,
|
|
19
|
+
getCacheProfile,
|
|
20
|
+
loaderCacheOptions,
|
|
15
21
|
registerCachePattern,
|
|
16
22
|
routeCacheDefaults,
|
|
17
|
-
|
|
18
|
-
setRouteCacheDefaults,
|
|
23
|
+
setCacheProfile,
|
|
19
24
|
} from "./cacheHeaders";
|
|
20
25
|
export { clx } from "./clx";
|
|
21
26
|
export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } from "./cookie";
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
28
|
import {
|
|
29
|
-
type
|
|
29
|
+
type CacheProfileName,
|
|
30
30
|
cacheHeaders,
|
|
31
31
|
detectCacheProfile,
|
|
32
|
-
|
|
32
|
+
edgeCacheConfig,
|
|
33
|
+
getCacheProfile,
|
|
33
34
|
} from "./cacheHeaders";
|
|
34
35
|
import { buildHtmlShell } from "./htmlShell";
|
|
35
36
|
import { cleanPathForCacheKey } from "./urlUtils";
|
|
@@ -122,7 +123,7 @@ export interface DecoWorkerEntryOptions {
|
|
|
122
123
|
* Override the default cache profile detection.
|
|
123
124
|
* Return `null` to fall through to the built-in detector.
|
|
124
125
|
*/
|
|
125
|
-
detectProfile?: (url: URL) =>
|
|
126
|
+
detectProfile?: (url: URL) => CacheProfileName | null;
|
|
126
127
|
|
|
127
128
|
/**
|
|
128
129
|
* Whether to create device-specific cache keys (mobile vs desktop).
|
|
@@ -382,7 +383,7 @@ export function createDecoWorkerEntry(
|
|
|
382
383
|
return true;
|
|
383
384
|
}
|
|
384
385
|
|
|
385
|
-
function getProfile(url: URL):
|
|
386
|
+
function getProfile(url: URL): CacheProfileName {
|
|
386
387
|
if (customDetect) {
|
|
387
388
|
const custom = customDetect(url);
|
|
388
389
|
if (custom !== null) return custom;
|
|
@@ -728,43 +729,120 @@ export function createDecoWorkerEntry(
|
|
|
728
729
|
? ((caches as unknown as { default?: Cache }).default ?? null)
|
|
729
730
|
: null;
|
|
730
731
|
|
|
731
|
-
|
|
732
|
+
const profile = getProfile(url);
|
|
733
|
+
const edgeConfig = edgeCacheConfig(profile);
|
|
734
|
+
|
|
735
|
+
// Helper: dress a response with proper client-facing headers
|
|
736
|
+
function dressResponse(resp: Response, xCache: string, extra?: Record<string, string>): Response {
|
|
737
|
+
const out = new Response(resp.body, resp);
|
|
738
|
+
const hdrs = cacheHeaders(profile);
|
|
739
|
+
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
740
|
+
out.headers.set("CDN-Cache-Control", "no-store");
|
|
741
|
+
out.headers.set("X-Cache", xCache);
|
|
742
|
+
out.headers.set("X-Cache-Profile", profile);
|
|
743
|
+
if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
|
|
744
|
+
if (cacheVersionEnv !== false) {
|
|
745
|
+
const v = (env[cacheVersionEnv] as string) || "";
|
|
746
|
+
if (v) out.headers.set("X-Cache-Version", v);
|
|
747
|
+
}
|
|
748
|
+
if (extra) for (const [k, v] of Object.entries(extra)) out.headers.set(k, v);
|
|
749
|
+
appendResourceHints(out);
|
|
750
|
+
return out;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Helper: store a response in Cache API with the full retention window
|
|
754
|
+
function storeInCache(resp: Response) {
|
|
755
|
+
if (!cache) return;
|
|
732
756
|
try {
|
|
733
|
-
const
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
757
|
+
const storageTtl = edgeConfig.fresh + Math.max(edgeConfig.swr, edgeConfig.sie);
|
|
758
|
+
const toStore = resp.clone();
|
|
759
|
+
toStore.headers.set("Cache-Control", `public, max-age=${storageTtl}`);
|
|
760
|
+
toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
|
|
761
|
+
toStore.headers.delete("CDN-Cache-Control");
|
|
762
|
+
ctx.waitUntil(cache.put(cacheKey, toStore));
|
|
763
|
+
} catch {
|
|
764
|
+
// Cache API unavailable
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Helper: background revalidation (fetch origin, store result)
|
|
769
|
+
function revalidateInBackground() {
|
|
770
|
+
ctx.waitUntil(
|
|
771
|
+
Promise.resolve(serverEntry.fetch(request, env, ctx)).then((origin) => {
|
|
772
|
+
if (origin.status === 200 && !origin.headers.has("set-cookie")) {
|
|
773
|
+
storeInCache(origin);
|
|
750
774
|
}
|
|
751
|
-
|
|
752
|
-
//
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
775
|
+
}).catch(() => {
|
|
776
|
+
// Background revalidation failed — stale entry stays until SIE expires
|
|
777
|
+
}),
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// --- Edge cache check with SWR + SIE ---
|
|
782
|
+
let cached: Response | undefined;
|
|
783
|
+
if (cache) {
|
|
784
|
+
try {
|
|
785
|
+
cached = await cache.match(cacheKey) ?? undefined;
|
|
759
786
|
} catch {
|
|
760
|
-
// Cache API unavailable
|
|
787
|
+
// Cache API unavailable
|
|
761
788
|
}
|
|
762
789
|
}
|
|
763
790
|
|
|
764
|
-
|
|
765
|
-
|
|
791
|
+
if (cached && edgeConfig.isPublic && edgeConfig.fresh > 0) {
|
|
792
|
+
const storedAtStr = cached.headers.get("X-Deco-Stored-At");
|
|
793
|
+
const storedAt = storedAtStr ? Number(storedAtStr) : 0;
|
|
794
|
+
const ageMs = storedAt > 0 ? Date.now() - storedAt : Infinity;
|
|
795
|
+
const ageSec = ageMs / 1000;
|
|
796
|
+
|
|
797
|
+
if (ageSec < edgeConfig.fresh) {
|
|
798
|
+
// FRESH HIT — serve immediately
|
|
799
|
+
return dressResponse(cached, "HIT");
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
if (ageSec < edgeConfig.fresh + edgeConfig.swr) {
|
|
803
|
+
// STALE-HIT within SWR window — serve stale, revalidate in background
|
|
804
|
+
revalidateInBackground();
|
|
805
|
+
return dressResponse(cached, "STALE-HIT", { "X-Cache-Age": String(Math.round(ageSec)) });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Past SWR window but still in cache (within SIE window) — keep reference
|
|
809
|
+
// for potential error fallback below
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Cache MISS or past SWR window — fetch from origin
|
|
813
|
+
let origin: Response;
|
|
814
|
+
try {
|
|
815
|
+
origin = await serverEntry.fetch(request, env, ctx);
|
|
816
|
+
} catch (err) {
|
|
817
|
+
// Origin fetch threw — SIE fallback if we have a stale entry
|
|
818
|
+
if (cached && edgeConfig.sie > 0) {
|
|
819
|
+
const storedAtStr = cached.headers.get("X-Deco-Stored-At");
|
|
820
|
+
const storedAt = storedAtStr ? Number(storedAtStr) : 0;
|
|
821
|
+
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
822
|
+
if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
|
|
823
|
+
console.warn(`[edge-cache] Origin threw, serving stale (age=${Math.round(ageSec)}s, sie=${edgeConfig.sie}s)`);
|
|
824
|
+
return dressResponse(cached, "STALE-ERROR", { "X-Cache-Age": String(Math.round(ageSec)) });
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
throw err;
|
|
828
|
+
}
|
|
766
829
|
|
|
767
830
|
if (origin.status !== 200) {
|
|
831
|
+
// Non-200 origin — SIE fallback on 5xx/429
|
|
832
|
+
if (origin.status >= 500 || origin.status === 429) {
|
|
833
|
+
if (cached && edgeConfig.sie > 0) {
|
|
834
|
+
const storedAtStr = cached.headers.get("X-Deco-Stored-At");
|
|
835
|
+
const storedAt = storedAtStr ? Number(storedAtStr) : 0;
|
|
836
|
+
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
837
|
+
if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
|
|
838
|
+
console.warn(`[edge-cache] Origin ${origin.status}, serving stale (age=${Math.round(ageSec)}s)`);
|
|
839
|
+
return dressResponse(cached, "STALE-ERROR", {
|
|
840
|
+
"X-Cache-Age": String(Math.round(ageSec)),
|
|
841
|
+
"X-Cache-Origin-Status": String(origin.status),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
768
846
|
const resp = new Response(origin.body, origin);
|
|
769
847
|
resp.headers.set("X-Cache", "BYPASS");
|
|
770
848
|
resp.headers.set("X-Cache-Reason", `status:${origin.status}`);
|
|
@@ -774,12 +852,9 @@ export function createDecoWorkerEntry(
|
|
|
774
852
|
|
|
775
853
|
// Responses with Set-Cookie must never be cached — they carry
|
|
776
854
|
// per-user session/auth tokens that would leak to other users.
|
|
777
|
-
|
|
778
|
-
if (hasSetCookie) {
|
|
855
|
+
if (origin.headers.has("set-cookie")) {
|
|
779
856
|
const resp = new Response(origin.body, origin);
|
|
780
857
|
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
781
|
-
// CDN-Cache-Control takes precedence over Cache-Control on Cloudflare.
|
|
782
|
-
// If the origin set it, Cloudflare would ignore our private directive.
|
|
783
858
|
resp.headers.delete("CDN-Cache-Control");
|
|
784
859
|
resp.headers.set("X-Cache", "BYPASS");
|
|
785
860
|
resp.headers.set("X-Cache-Reason", "set-cookie");
|
|
@@ -787,11 +862,9 @@ export function createDecoWorkerEntry(
|
|
|
787
862
|
return resp;
|
|
788
863
|
}
|
|
789
864
|
|
|
790
|
-
|
|
791
|
-
const profile = getProfile(url);
|
|
792
|
-
const profileConfig = getCacheProfileConfig(profile);
|
|
865
|
+
const profileConfig = getCacheProfile(profile);
|
|
793
866
|
|
|
794
|
-
if (!profileConfig.isPublic || profileConfig.
|
|
867
|
+
if (!profileConfig.isPublic || profileConfig.edge.fresh === 0) {
|
|
795
868
|
const resp = new Response(origin.body, origin);
|
|
796
869
|
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
797
870
|
resp.headers.set("X-Cache", "BYPASS");
|
|
@@ -800,48 +873,9 @@ export function createDecoWorkerEntry(
|
|
|
800
873
|
return resp;
|
|
801
874
|
}
|
|
802
875
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
status: origin.status,
|
|
807
|
-
statusText: origin.statusText,
|
|
808
|
-
headers: new Headers(origin.headers),
|
|
809
|
-
});
|
|
810
|
-
|
|
811
|
-
// Apply profile-specific cache headers for the client response
|
|
812
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
813
|
-
toReturn.headers.set(k, v);
|
|
814
|
-
}
|
|
815
|
-
toReturn.headers.set("X-Cache", "MISS");
|
|
816
|
-
toReturn.headers.set("X-Cache-Profile", profile);
|
|
817
|
-
if (segment) toReturn.headers.set("X-Cache-Segment", hashSegment(segment));
|
|
818
|
-
if (cacheVersionEnv !== false) {
|
|
819
|
-
const v = (env[cacheVersionEnv] as string) || "";
|
|
820
|
-
if (v) toReturn.headers.set("X-Cache-Version", v);
|
|
821
|
-
}
|
|
822
|
-
|
|
823
|
-
// Prevent CDN from auto-caching this response under the raw URL.
|
|
824
|
-
// Edge caching is managed by the Worker via caches.default with
|
|
825
|
-
// BUILD_HASH-versioned keys; CDN auto-caching would bypass versioning
|
|
826
|
-
// and serve stale HTML after deploys.
|
|
827
|
-
toReturn.headers.set("CDN-Cache-Control", "no-store");
|
|
828
|
-
appendResourceHints(toReturn);
|
|
829
|
-
|
|
830
|
-
// For Cache API storage, use sMaxAge as max-age since the Cache API
|
|
831
|
-
// ignores s-maxage and only respects max-age for TTL decisions.
|
|
832
|
-
if (cache) {
|
|
833
|
-
try {
|
|
834
|
-
const toStore = toReturn.clone();
|
|
835
|
-
toStore.headers.set("Cache-Control", `public, max-age=${profileConfig.sMaxAge}`);
|
|
836
|
-
// Remove CDN-Cache-Control from stored version — it's only needed
|
|
837
|
-
// on the response to the client to prevent CDN auto-caching.
|
|
838
|
-
toStore.headers.delete("CDN-Cache-Control");
|
|
839
|
-
ctx.waitUntil(cache.put(cacheKey, toStore));
|
|
840
|
-
} catch {
|
|
841
|
-
// Cache API unavailable — skip storing
|
|
842
|
-
}
|
|
843
|
-
}
|
|
844
|
-
|
|
876
|
+
// Store in Cache API and return
|
|
877
|
+
const toReturn = dressResponse(origin, "MISS");
|
|
878
|
+
storeInCache(origin);
|
|
845
879
|
return toReturn;
|
|
846
880
|
},
|
|
847
881
|
};
|