@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "0.36.4",
3
+ "version": "0.37.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -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,")) &&
@@ -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, CacheableSectionConfig>): void {
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
 
@@ -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 CacheProfile,
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?: CacheProfile;
331
+ cacheProfile?: CacheProfileName;
332
332
  seo?: PageSeo;
333
333
  device?: Device;
334
334
  resolvedSections?: Array<{ component: string }>;
@@ -1,22 +1,27 @@
1
1
  /**
2
- * Cache-Control header generation for different page types.
2
+ * Unified cache profile system for Deco storefronts.
3
3
  *
4
- * Produces spec-compliant `Cache-Control` values suitable for CDNs
5
- * (Cloudflare, Vercel, Fastly) with `s-maxage` and `stale-while-revalidate`.
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
- * // In a TanStack Start route:
10
- * import { cacheHeaders, routeCacheDefaults } from "@decocms/start/sdk/cacheHeaders";
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
- export type CacheProfile =
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
- export interface CacheHeadersConfig {
29
- /** Browser max-age in seconds. */
30
- maxAge: number;
31
- /** CDN/edge max-age in seconds. */
32
- sMaxAge: number;
33
- /** Stale-while-revalidate window in seconds. */
34
- staleWhileRevalidate: number;
35
- /** Whether the response is public (cacheable by CDN). */
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
- const PROFILES: Record<CacheProfile, CacheHeadersConfig> = {
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
- maxAge: 120,
42
- sMaxAge: 86400,
43
- staleWhileRevalidate: 86400,
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
- maxAge: 60,
48
- sMaxAge: 300,
49
- staleWhileRevalidate: 3600,
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
- maxAge: 30,
54
- sMaxAge: 120,
55
- staleWhileRevalidate: 600,
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
- maxAge: 0,
60
- sMaxAge: 60,
61
- staleWhileRevalidate: 300,
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
- maxAge: 0,
66
- sMaxAge: 0,
67
- staleWhileRevalidate: 0,
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
- maxAge: 0,
72
- sMaxAge: 0,
73
- staleWhileRevalidate: 0,
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
- maxAge: 0,
78
- sMaxAge: 0,
79
- staleWhileRevalidate: 0,
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
- * Generate a `Cache-Control` header value from a named profile or custom config.
86
- * Returns a headers object ready to spread into route `headers()`.
138
+ * Override specific values of a cache profile. Only the fields you specify
139
+ * are merged; everything else keeps its default.
87
140
  *
88
- * Always includes `Vary: Accept-Encoding` for public responses.
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
- profileOrConfig: CacheProfile | CacheHeadersConfig,
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 (!config.isPublic || (config.sMaxAge === 0 && config.maxAge === 0)) {
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 (config.maxAge > 0) {
104
- parts.push(`max-age=${config.maxAge}`);
105
- } else {
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
- * Get the raw config for a named cache profile.
125
- * Useful when you need the numeric values (e.g. for custom logic).
126
- */
127
- export function getCacheProfileConfig(profile: CacheProfile): CacheHeadersConfig {
128
- return PROFILES[profile];
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
- * Override the default TTLs for a cache profile.
133
- * Useful when the built-in values don't match your site's
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 staleTime / gcTime)
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 during rapid
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: CacheProfile): RouteCacheDefaults {
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 ROUTE_CACHE[profile];
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
- * Override the client-side route cache defaults for a profile.
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 setRouteCacheDefaults(profile: CacheProfile, defaults: RouteCacheDefaults): void {
199
- ROUTE_CACHE[profile] = defaults;
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: CacheProfile;
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" (conservative public cache) for unmatched paths.
301
+ * Falls back to "listing" for unmatched paths.
270
302
  */
271
- export function detectCacheProfile(pathnameOrUrl: string | URL): CacheProfile {
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
  }
@@ -1,36 +1,30 @@
1
1
  /**
2
- * Server-side loader caching with stale-while-revalidate semantics.
2
+ * Server-side loader caching with stale-while-revalidate + stale-if-error.
3
3
  *
4
- * This provides a lightweight in-memory cache layer for commerce loaders
5
- * that runs on the server during SSR, without requiring TanStack Query
6
- * (which is optional and operates at the client/route level).
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
- * For client-side SWR, use TanStack Query's `staleTime` / `gcTime`.
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 and single-flight dedup.
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
- * const cachedProductList = createCachedLoader(
66
- * "vtex/loaders/productList",
67
- * vtexProductList,
68
- * { policy: "stale-while-revalidate", maxAge: 30_000 }
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
- options: CachedLoaderOptions,
88
+ optionsOrProfile: CachedLoaderOptions | CacheProfileName,
76
89
  ): (props: TProps) => Promise<TResult> {
77
- const { policy, maxAge = DEFAULT_MAX_AGE, keyFn = JSON.stringify } = options;
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, { policy, maxAge, keyFn });
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 CacheHeadersConfig,
11
- type CacheProfile,
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
- getCacheProfileConfig,
18
+ edgeCacheConfig,
19
+ getCacheProfile,
20
+ loaderCacheOptions,
15
21
  registerCachePattern,
16
22
  routeCacheDefaults,
17
- setCacheProfileConfig,
18
- setRouteCacheDefaults,
23
+ setCacheProfile,
19
24
  } from "./cacheHeaders";
20
25
  export { clx } from "./clx";
21
26
  export { decodeCookie, deleteCookie, getCookie, getServerSideCookie, setCookie } from "./cookie";
@@ -26,10 +26,11 @@
26
26
  */
27
27
 
28
28
  import {
29
- type CacheProfile,
29
+ type CacheProfileName,
30
30
  cacheHeaders,
31
31
  detectCacheProfile,
32
- getCacheProfileConfig,
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) => CacheProfile | null;
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): CacheProfile {
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
- if (cache) {
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 cached = await cache.match(cacheKey);
734
- if (cached) {
735
- const hit = new Response(cached.body, cached);
736
- hit.headers.set("X-Cache", "HIT");
737
- if (segment) hit.headers.set("X-Cache-Segment", hashSegment(segment));
738
- if (cacheVersionEnv !== false) {
739
- const v = (env[cacheVersionEnv] as string) || "";
740
- if (v) hit.headers.set("X-Cache-Version", v);
741
- }
742
- // Restore client-facing Cache-Control (the stored version uses sMaxAge
743
- // as max-age for Cache API TTL, which would leak to the CDN auto-cache
744
- // and cause stale HTML after deploys — the CDN caches under the raw URL,
745
- // bypassing BUILD_HASH versioned keys).
746
- const hitProfile = getProfile(url);
747
- const hitHeaders = cacheHeaders(hitProfile);
748
- for (const [k, v] of Object.entries(hitHeaders)) {
749
- hit.headers.set(k, v);
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
- // Prevent CDN from auto-caching this response under the raw URL.
752
- // The Worker manages edge caching via caches.default with versioned keys;
753
- // CDN auto-caching would bypass that versioning and serve stale HTML
754
- // after deploys (referencing old CSS/JS fingerprinted filenames).
755
- hit.headers.set("CDN-Cache-Control", "no-store");
756
- appendResourceHints(hit);
757
- return hit;
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 in this environment — proceed without cache
787
+ // Cache API unavailable
761
788
  }
762
789
  }
763
790
 
764
- // Cache MISS fetch from origin
765
- const origin = await serverEntry.fetch(request, env, ctx);
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
- const hasSetCookie = origin.headers.has("set-cookie");
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
- // Determine the right cache profile for this URL
791
- const profile = getProfile(url);
792
- const profileConfig = getCacheProfileConfig(profile);
865
+ const profileConfig = getCacheProfile(profile);
793
866
 
794
- if (!profileConfig.isPublic || profileConfig.sMaxAge === 0) {
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
- const headers = cacheHeaders(profile);
804
-
805
- const toReturn = new Response(origin.body, {
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
  };