@decocms/start 1.3.1 → 1.3.2

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": "1.3.1",
3
+ "version": "1.3.2",
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",
@@ -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
  // ---------------------------------------------------------------------------
@@ -381,6 +438,109 @@ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
381
438
 
382
439
  const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
383
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
+ }
543
+
384
544
  const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
385
545
 
386
546
  const IMMUTABLE_HEADERS: Record<string, string> = {
@@ -430,8 +590,13 @@ export function createDecoWorkerEntry(
430
590
  securityHeaders: securityHeadersOpt,
431
591
  csp: cspOpt,
432
592
  autoInjectGeoCookies: geoOpt = true,
593
+ safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES,
594
+ staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
595
+ cdnCacheControl: cdnCacheControlOpt = "no-store",
433
596
  } = options;
434
597
 
598
+ const safeCookieSet = new Set(safeCookiesOpt);
599
+
435
600
  // Build the final security headers map (merged defaults + custom + CSP)
436
601
  const secHeaders: Record<string, string> | null = (() => {
437
602
  if (securityHeadersOpt === false) return null;
@@ -465,7 +630,9 @@ export function createDecoWorkerEntry(
465
630
  }
466
631
 
467
632
  function isStaticAsset(pathname: string): boolean {
468
- return fingerprintedAssetPattern.test(pathname);
633
+ if (fingerprintedAssetPattern.test(pathname)) return true;
634
+ // Non-fingerprinted static paths (e.g., /fonts/)
635
+ return staticPathsOpt.some((sp) => pathname.startsWith(sp));
469
636
  }
470
637
 
471
638
  function isCacheable(request: Request, url: URL): boolean {
@@ -765,6 +932,10 @@ export function createDecoWorkerEntry(
765
932
  return handleRequest(request, env, ctx);
766
933
  });
767
934
 
935
+ // Deduplicate Set-Cookie headers — multiple layers (VTEX middleware,
936
+ // invoke handlers, etc.) may independently append the same cookie.
937
+ deduplicateSetCookies(response);
938
+
768
939
  return applySecurityHeaders(response);
769
940
  },
770
941
  };
@@ -1006,13 +1177,14 @@ export function createDecoWorkerEntry(
1006
1177
 
1007
1178
  const resp = new Response(origin.body, origin);
1008
1179
 
1009
- // Responses with Set-Cookie carry per-user tokens — never expose
1010
- // them with public cache headers regardless of profile.
1011
- if (origin.headers.has("set-cookie")) {
1180
+ // Responses with private Set-Cookie headers carry per-user tokens —
1181
+ // never expose them with public cache headers.
1182
+ // Safe/public cookies (e.g., vtex_is_session) are allowed through.
1183
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1012
1184
  resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1013
1185
  resp.headers.delete("CDN-Cache-Control");
1014
1186
  resp.headers.set("X-Cache", "BYPASS");
1015
- resp.headers.set("X-Cache-Reason", "set-cookie");
1187
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1016
1188
  return resp;
1017
1189
  }
1018
1190
 
@@ -1057,7 +1229,22 @@ export function createDecoWorkerEntry(
1057
1229
  const out = new Response(resp.body, resp);
1058
1230
  const hdrs = cacheHeaders(profile);
1059
1231
  for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1060
- out.headers.set("CDN-Cache-Control", "no-store");
1232
+
1233
+ // CDN-Cache-Control: controls Cloudflare's automatic CDN layer
1234
+ // (separate from Cache API which the worker manages directly).
1235
+ if (cdnCacheControlOpt === "no-store") {
1236
+ out.headers.set("CDN-Cache-Control", "no-store");
1237
+ } else if (cdnCacheControlOpt === "match-profile") {
1238
+ if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
1239
+ out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
1240
+ } else {
1241
+ out.headers.set("CDN-Cache-Control", "no-store");
1242
+ }
1243
+ } else if (typeof cdnCacheControlOpt === "function") {
1244
+ const val = cdnCacheControlOpt(profile);
1245
+ out.headers.set("CDN-Cache-Control", val ?? "no-store");
1246
+ }
1247
+
1061
1248
  out.headers.set("X-Cache", xCache);
1062
1249
  out.headers.set("X-Cache-Profile", profile);
1063
1250
  if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
@@ -1089,8 +1276,15 @@ export function createDecoWorkerEntry(
1089
1276
  function revalidateInBackground() {
1090
1277
  ctx.waitUntil(
1091
1278
  Promise.resolve(serverEntry.fetch(request, env, ctx)).then((origin) => {
1092
- if (origin.status === 200 && !origin.headers.has("set-cookie")) {
1093
- storeInCache(origin);
1279
+ if (origin.status === 200) {
1280
+ // Only cache if response has no cookies or only safe cookies.
1281
+ // Strip safe cookies from the cached copy.
1282
+ if (hasOnlySafeCookies(origin, safeCookieSet)) {
1283
+ const cleanOrigin = origin.headers.has("set-cookie")
1284
+ ? stripSafeCookiesForCache(origin, safeCookieSet)
1285
+ : origin;
1286
+ storeInCache(cleanOrigin);
1287
+ }
1094
1288
  }
1095
1289
  }).catch(() => {
1096
1290
  // Background revalidation failed — stale entry stays until SIE expires
@@ -1170,14 +1364,16 @@ export function createDecoWorkerEntry(
1170
1364
  return resp;
1171
1365
  }
1172
1366
 
1173
- // Responses with Set-Cookie must never be cached — they carry
1174
- // per-user session/auth tokens that would leak to other users.
1175
- if (origin.headers.has("set-cookie")) {
1367
+ // Responses with private Set-Cookie headers must never be cached —
1368
+ // they carry per-user session/auth tokens that would leak to other users.
1369
+ // Safe/public cookies (IS session, segment, etc.) are stripped from the
1370
+ // cached copy but kept on the response served to the current user.
1371
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1176
1372
  const resp = new Response(origin.body, origin);
1177
1373
  resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1178
1374
  resp.headers.delete("CDN-Cache-Control");
1179
1375
  resp.headers.set("X-Cache", "BYPASS");
1180
- resp.headers.set("X-Cache-Reason", "set-cookie");
1376
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1181
1377
  appendResourceHints(resp);
1182
1378
  return resp;
1183
1379
  }
@@ -1197,7 +1393,12 @@ export function createDecoWorkerEntry(
1197
1393
  // dressResponse() calls new Response(resp.body, resp) which locks
1198
1394
  // the ReadableStream. Calling clone() on a locked body corrupts
1199
1395
  // the stream in Workers runtime, causing Error 1101.
1200
- storeInCache(origin);
1396
+ // Strip safe cookies from the cached copy so they don't leak
1397
+ // to other users, but the current user still gets them.
1398
+ const cacheOrigin = origin.headers.has("set-cookie")
1399
+ ? stripSafeCookiesForCache(origin, safeCookieSet)
1400
+ : origin;
1401
+ storeInCache(cacheOrigin);
1201
1402
  return dressResponse(origin, "MISS");
1202
1403
  }
1203
1404
  }