@decocms/start 1.3.6 → 1.3.7

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.6",
3
+ "version": "1.3.7",
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",
@@ -27,6 +27,7 @@ import {
27
27
  getRequest,
28
28
  getRequestHeader,
29
29
  getRequestUrl,
30
+ setResponseHeader,
30
31
  } from "@tanstack/react-start/server";
31
32
  import { createElement } from "react";
32
33
  import { preloadSectionComponents } from "../cms/registry";
@@ -227,6 +228,12 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
227
228
  headers: originRequest.headers,
228
229
  });
229
230
  const enriched = await runSingleSectionLoader(section, request);
231
+
232
+ // Signal to the worker entry that this response is safe to edge-cache.
233
+ // Without this header, POST _serverFn responses are passed through
234
+ // without caching (checkout actions, invoke mutations, etc.).
235
+ setResponseHeader("X-Deco-Cacheable", "true");
236
+
230
237
  return normalizeUrlsInObject(enriched);
231
238
  });
232
239
 
@@ -267,7 +267,6 @@ const builtinPatterns: CachePattern[] = [
267
267
  test: (p) =>
268
268
  p.startsWith("/api/") ||
269
269
  p.startsWith("/deco/") ||
270
- p.startsWith("/_server") ||
271
270
  p.startsWith("/_build"),
272
271
  profile: "none",
273
272
  },
@@ -179,7 +179,7 @@ export interface DecoWorkerEntryOptions {
179
179
  /**
180
180
  * Paths that should always bypass the edge cache, even if the
181
181
  * profile detector would otherwise cache them.
182
- * Defaults include `/_server`, `/_build`, `/assets`, `/deco/`.
182
+ * Defaults include `/_build`, `/deco/`, `/live/`, `/.decofile`.
183
183
  */
184
184
  bypassPaths?: string[];
185
185
 
@@ -295,6 +295,63 @@ export interface DecoWorkerEntryOptions {
295
295
  * @default true
296
296
  */
297
297
  autoInjectGeoCookies?: boolean;
298
+
299
+ /**
300
+ * Cookie names considered "safe" for caching — these are public/anonymous
301
+ * cookies that do not carry per-user session or auth data.
302
+ *
303
+ * When a response contains ONLY safe cookies, it is still eligible for
304
+ * Cache API storage. The safe cookies are stripped from the cached copy
305
+ * but kept on the response served to the current user.
306
+ *
307
+ * If the response contains ANY cookie NOT in this list, the response
308
+ * bypasses caching entirely (existing behavior).
309
+ *
310
+ * @default DEFAULT_SAFE_COOKIES (vtex_is_session, vtex_is_anonymous, vtex_segment, _deco_bucket)
311
+ *
312
+ * @example
313
+ * ```ts
314
+ * createDecoWorkerEntry(serverEntry, {
315
+ * safeCookies: [
316
+ * ...DEFAULT_SAFE_COOKIES,
317
+ * "my_custom_analytics_cookie",
318
+ * ],
319
+ * });
320
+ * ```
321
+ */
322
+ safeCookies?: string[];
323
+
324
+ /**
325
+ * Additional static paths (beyond fingerprinted assets) that should
326
+ * receive long-lived immutable cache headers.
327
+ *
328
+ * Useful for non-fingerprinted resources like fonts that live at
329
+ * stable URLs (e.g., `/fonts/Lato-Regular.woff2`).
330
+ *
331
+ * @default ["/fonts/"]
332
+ *
333
+ * @example
334
+ * ```ts
335
+ * createDecoWorkerEntry(serverEntry, {
336
+ * staticPaths: ["/fonts/", "/static/", "/images/icons/"],
337
+ * });
338
+ * ```
339
+ */
340
+ staticPaths?: string[];
341
+
342
+ /**
343
+ * CDN-Cache-Control header strategy.
344
+ *
345
+ * - `"no-store"` (default): CDN never caches; every request invokes the Worker.
346
+ * Correct when segment-based cache keys differ from the original URL.
347
+ * - `"match-profile"`: Set CDN-Cache-Control to a short TTL matching the
348
+ * profile's edge.fresh value. Only safe when you are NOT using segment-based
349
+ * cache keys (i.e., no `buildSegment` and `deviceSpecificKeys: false`).
350
+ * - A function: Return a CDN-Cache-Control value per profile, or `null` for no-store.
351
+ *
352
+ * @default "no-store"
353
+ */
354
+ cdnCacheControl?: "no-store" | "match-profile" | ((profile: CacheProfileName) => string | null);
298
355
  }
299
356
 
300
357
  // ---------------------------------------------------------------------------
@@ -379,7 +436,110 @@ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
379
436
  "Cross-Origin-Opener-Policy": "same-origin-allow-popups",
380
437
  };
381
438
 
382
- const DEFAULT_BYPASS_PATHS = ["/_server", "/_build", "/deco/", "/live/", "/.decofile"];
439
+ const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
440
+
441
+ /**
442
+ * Cookie names that are safe for caching — they carry anonymous/public
443
+ * segment data, not per-user auth tokens.
444
+ *
445
+ * VTEX Intelligent Search sets `vtex_is_session` and `vtex_is_anonymous`
446
+ * on every response. `vtex_segment` encodes the sales channel.
447
+ * `_deco_bucket` is the A/B test cohort cookie.
448
+ */
449
+ export const DEFAULT_SAFE_COOKIES: string[] = [
450
+ "vtex_is_session",
451
+ "vtex_is_anonymous",
452
+ "vtex_segment",
453
+ "_deco_bucket",
454
+ ];
455
+
456
+ const DEFAULT_STATIC_PATHS = ["/fonts/"];
457
+
458
+ /**
459
+ * Parse Set-Cookie header values and return cookie names.
460
+ */
461
+ function parseCookieNames(response: Response): string[] {
462
+ const names: string[] = [];
463
+ // getSetCookie() returns individual Set-Cookie values (available in Workers runtime)
464
+ const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
465
+ if (setCookies) {
466
+ for (const sc of setCookies) {
467
+ const eqIdx = sc.indexOf("=");
468
+ if (eqIdx > 0) names.push(sc.slice(0, eqIdx).trim());
469
+ }
470
+ } else {
471
+ // Fallback: parse from combined header (less reliable but covers edge cases)
472
+ const combined = response.headers.get("set-cookie") ?? "";
473
+ for (const part of combined.split(",")) {
474
+ const eqIdx = part.indexOf("=");
475
+ if (eqIdx > 0) {
476
+ const name = part.slice(0, eqIdx).trim();
477
+ // Skip attributes like "Expires=..." that appear after semicolons
478
+ if (!name.includes(";") && name.length > 0) names.push(name);
479
+ }
480
+ }
481
+ }
482
+ return names;
483
+ }
484
+
485
+ /**
486
+ * Check if ALL cookies in a response are in the safe list.
487
+ * Returns true if the response has no cookies or only safe cookies.
488
+ */
489
+ function hasOnlySafeCookies(response: Response, safeCookieSet: Set<string>): boolean {
490
+ if (!response.headers.has("set-cookie")) return true;
491
+ const names = parseCookieNames(response);
492
+ if (names.length === 0) return true;
493
+ return names.every((name) => safeCookieSet.has(name));
494
+ }
495
+
496
+ /**
497
+ * Clone a response, stripping Set-Cookie headers that match the safe list.
498
+ * Uses response.clone() to preserve the original body for the served response.
499
+ * The returned copy is intended for cache storage only.
500
+ */
501
+ function stripSafeCookiesForCache(response: Response, safeCookieSet: Set<string>): Response {
502
+ const clone = response.clone();
503
+ const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
504
+ if (!setCookies || setCookies.length === 0) return clone;
505
+
506
+ // Remove all Set-Cookie headers, then re-add only unsafe ones
507
+ clone.headers.delete("set-cookie");
508
+ for (const sc of setCookies) {
509
+ const eqIdx = sc.indexOf("=");
510
+ const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : "";
511
+ if (name && !safeCookieSet.has(name)) {
512
+ clone.headers.append("set-cookie", sc);
513
+ }
514
+ }
515
+ return clone;
516
+ }
517
+
518
+ /**
519
+ * Deduplicate Set-Cookie headers — keep only the LAST occurrence of
520
+ * each cookie name. Multiple layers (VTEX middleware, invoke handlers,
521
+ * etc.) may independently append the same cookie.
522
+ */
523
+ function deduplicateSetCookies(response: Response): void {
524
+ const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
525
+ if (!setCookies || setCookies.length <= 1) return;
526
+
527
+ // Build map: cookie name → last Set-Cookie value
528
+ const seen = new Map<string, string>();
529
+ for (const sc of setCookies) {
530
+ const eqIdx = sc.indexOf("=");
531
+ const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : sc;
532
+ seen.set(name, sc);
533
+ }
534
+
535
+ // If no duplicates, nothing to do
536
+ if (seen.size === setCookies.length) return;
537
+
538
+ response.headers.delete("set-cookie");
539
+ for (const sc of seen.values()) {
540
+ response.headers.append("set-cookie", sc);
541
+ }
542
+ }
383
543
 
384
544
  const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
385
545
 
@@ -388,6 +548,15 @@ const IMMUTABLE_HEADERS: Record<string, string> = {
388
548
  Vary: "Accept-Encoding",
389
549
  };
390
550
 
551
+ /** SHA-256 hex hash of a string — used for POST body cache keys. */
552
+ async function hashText(text: string): Promise<string> {
553
+ const data = new TextEncoder().encode(text);
554
+ const buf = await crypto.subtle.digest("SHA-256", data);
555
+ return Array.from(new Uint8Array(buf))
556
+ .map((b) => b.toString(16).padStart(2, "0"))
557
+ .join("");
558
+ }
559
+
391
560
  // ---------------------------------------------------------------------------
392
561
  // Factory
393
562
  // ---------------------------------------------------------------------------
@@ -421,8 +590,13 @@ export function createDecoWorkerEntry(
421
590
  securityHeaders: securityHeadersOpt,
422
591
  csp: cspOpt,
423
592
  autoInjectGeoCookies: geoOpt = true,
593
+ safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES,
594
+ staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS,
595
+ cdnCacheControl: cdnCacheControlOpt = "no-store",
424
596
  } = options;
425
597
 
598
+ const safeCookieSet = new Set(safeCookiesOpt);
599
+
426
600
  // Build the final security headers map (merged defaults + custom + CSP)
427
601
  const secHeaders: Record<string, string> | null = (() => {
428
602
  if (securityHeadersOpt === false) return null;
@@ -456,7 +630,9 @@ export function createDecoWorkerEntry(
456
630
  }
457
631
 
458
632
  function isStaticAsset(pathname: string): boolean {
459
- 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));
460
636
  }
461
637
 
462
638
  function isCacheable(request: Request, url: URL): boolean {
@@ -756,6 +932,10 @@ export function createDecoWorkerEntry(
756
932
  return handleRequest(request, env, ctx);
757
933
  });
758
934
 
935
+ // Deduplicate Set-Cookie headers — multiple layers (VTEX middleware,
936
+ // invoke handlers, etc.) may independently append the same cookie.
937
+ deduplicateSetCookies(response);
938
+
759
939
  return applySecurityHeaders(response);
760
940
  },
761
941
  };
@@ -835,6 +1015,168 @@ export function createDecoWorkerEntry(
835
1015
  return origin;
836
1016
  }
837
1017
 
1018
+ // -----------------------------------------------------------------
1019
+ // POST _serverFn — edge-cacheable using body-hash as cache key.
1020
+ // These carry public CMS section data (shelves, deferred sections)
1021
+ // that benefits from edge caching despite being POST requests.
1022
+ // -----------------------------------------------------------------
1023
+ if (
1024
+ request.method === "POST" &&
1025
+ (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
1026
+ ) {
1027
+ const serverFnCache =
1028
+ typeof caches !== "undefined"
1029
+ ? ((caches as unknown as { default?: Cache }).default ?? null)
1030
+ : null;
1031
+
1032
+ // Build segment once — used for logged-in check and cache key
1033
+ const sfnSegment = buildSegment ? buildSegment(request) : undefined;
1034
+
1035
+ // Logged-in users always bypass — personalized content must not leak
1036
+ if (sfnSegment?.loggedIn) {
1037
+ const origin = await serverEntry.fetch(request, env, ctx);
1038
+ const resp = new Response(origin.body, origin);
1039
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1040
+ resp.headers.set("X-Cache", "BYPASS");
1041
+ resp.headers.set("X-Cache-Reason", "logged-in");
1042
+ return resp;
1043
+ }
1044
+
1045
+ // Clone request before consuming body — the clone goes to origin
1046
+ // untouched so TanStack Start internals (cookie passthrough, etc.)
1047
+ // work correctly. We only read the body for the cache key hash.
1048
+ const originClone = request.clone();
1049
+ const body = await request.text();
1050
+ const bodyHash = await hashText(body);
1051
+
1052
+ // Build a synthetic GET cache key from the URL + body hash + segment
1053
+ // Includes device, salesChannel, regionId, flags — so users in
1054
+ // different regions or channels get separate cache entries.
1055
+ const cacheKeyUrl = new URL(request.url);
1056
+ cacheKeyUrl.searchParams.set("__body", bodyHash);
1057
+ if (cacheVersionEnv !== false) {
1058
+ const version = (env[cacheVersionEnv] as string) || "";
1059
+ if (version) cacheKeyUrl.searchParams.set("__v", version);
1060
+ }
1061
+ if (sfnSegment) {
1062
+ cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
1063
+ } else if (deviceSpecificKeys) {
1064
+ const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
1065
+ cacheKeyUrl.searchParams.set("__cf_device", device);
1066
+ }
1067
+ // Include CF geo data so location-based content doesn't leak across geos
1068
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
1069
+ if (cf) {
1070
+ const geoParts: string[] = [];
1071
+ if (cf.country) geoParts.push(cf.country);
1072
+ if (cf.region) geoParts.push(cf.region);
1073
+ if (cf.city) geoParts.push(cf.city);
1074
+ if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
1075
+ }
1076
+ const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
1077
+
1078
+ // Use "listing" profile for server function responses
1079
+ const sfnProfile: CacheProfileName = "listing";
1080
+ const sfnEdge = edgeCacheConfig(sfnProfile);
1081
+
1082
+ // Check edge cache
1083
+ let sfnCached: Response | undefined;
1084
+ if (serverFnCache) {
1085
+ try {
1086
+ sfnCached = await serverFnCache.match(sfnCacheKey) ?? undefined;
1087
+ } catch { /* Cache API unavailable */ }
1088
+ }
1089
+
1090
+ if (sfnCached && sfnEdge.fresh > 0) {
1091
+ const storedAt = Number(sfnCached.headers.get("X-Deco-Stored-At") || "0");
1092
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1093
+
1094
+ if (ageSec < sfnEdge.fresh) {
1095
+ const out = new Response(sfnCached.body, sfnCached);
1096
+ const hdrs = cacheHeaders(sfnProfile);
1097
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1098
+ out.headers.set("X-Cache", "HIT");
1099
+ out.headers.set("X-Cache-Profile", sfnProfile);
1100
+ return out;
1101
+ }
1102
+
1103
+ if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
1104
+ // Stale-while-revalidate: serve stale, refresh in background
1105
+ ctx.waitUntil(
1106
+ (async () => {
1107
+ try {
1108
+ const bgReq = new Request(request, { body, method: "POST" });
1109
+ const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
1110
+ if (
1111
+ bgOrigin.status === 200 &&
1112
+ bgOrigin.headers.get("X-Deco-Cacheable") === "true" &&
1113
+ !bgOrigin.headers.has("set-cookie") &&
1114
+ serverFnCache
1115
+ ) {
1116
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1117
+ const toStore = bgOrigin.clone();
1118
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1119
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1120
+ toStore.headers.delete("CDN-Cache-Control");
1121
+ toStore.headers.delete("X-Deco-Cacheable");
1122
+ await serverFnCache.put(sfnCacheKey, toStore);
1123
+ }
1124
+ } catch { /* background revalidation failed */ }
1125
+ })(),
1126
+ );
1127
+ const out = new Response(sfnCached.body, sfnCached);
1128
+ const hdrs = cacheHeaders(sfnProfile);
1129
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1130
+ out.headers.set("X-Cache", "STALE-HIT");
1131
+ out.headers.set("X-Cache-Profile", sfnProfile);
1132
+ out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
1133
+ return out;
1134
+ }
1135
+ }
1136
+
1137
+ // Cache MISS — fetch origin with the body we already read
1138
+ const origin = await serverEntry.fetch(originClone, env, ctx);
1139
+
1140
+ // Only cache responses explicitly marked as cacheable by the handler
1141
+ // (loadDeferredSection sets X-Deco-Cacheable: true). Checkout actions,
1142
+ // invoke mutations, and other server functions are passed through.
1143
+ const isCacheableResponse =
1144
+ origin.headers.get("X-Deco-Cacheable") === "true" &&
1145
+ !origin.headers.has("set-cookie") &&
1146
+ origin.status === 200;
1147
+
1148
+ if (!isCacheableResponse) {
1149
+ const resp = new Response(origin.body, origin);
1150
+ resp.headers.delete("X-Deco-Cacheable");
1151
+ resp.headers.set("X-Cache", "BYPASS");
1152
+ resp.headers.set("X-Cache-Reason", origin.headers.has("set-cookie")
1153
+ ? "set-cookie"
1154
+ : "not-cacheable");
1155
+ return resp;
1156
+ }
1157
+
1158
+ // Store in edge cache
1159
+ if (serverFnCache) {
1160
+ try {
1161
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1162
+ const toStore = origin.clone();
1163
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1164
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1165
+ toStore.headers.delete("CDN-Cache-Control");
1166
+ toStore.headers.delete("X-Deco-Cacheable");
1167
+ ctx.waitUntil(serverFnCache.put(sfnCacheKey, toStore));
1168
+ } catch { /* Cache API unavailable */ }
1169
+ }
1170
+
1171
+ const resp = new Response(origin.body, origin);
1172
+ resp.headers.delete("X-Deco-Cacheable");
1173
+ const hdrs = cacheHeaders(sfnProfile);
1174
+ for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
1175
+ resp.headers.set("X-Cache", "MISS");
1176
+ resp.headers.set("X-Cache-Profile", sfnProfile);
1177
+ return resp;
1178
+ }
1179
+
838
1180
  // Non-cacheable requests — pass through but protect against accidental caching
839
1181
  if (!isCacheable(request, url)) {
840
1182
  const origin = await serverEntry.fetch(request, env, ctx);
@@ -850,10 +1192,28 @@ export function createDecoWorkerEntry(
850
1192
  }
851
1193
 
852
1194
  const resp = new Response(origin.body, origin);
1195
+
1196
+ // Responses with private Set-Cookie headers carry per-user tokens —
1197
+ // never expose them with public cache headers.
1198
+ // Safe/public cookies (e.g., vtex_is_session) are allowed through.
1199
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1200
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1201
+ resp.headers.delete("CDN-Cache-Control");
1202
+ resp.headers.set("X-Cache", "BYPASS");
1203
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1204
+ return resp;
1205
+ }
1206
+
1207
+ // Set cache headers from the detected profile so the response
1208
+ // is explicit about cacheability (avoids ambiguous empty header).
1209
+ const hdrsNc = cacheHeaders(profile);
1210
+ for (const [k, v] of Object.entries(hdrsNc)) resp.headers.set(k, v);
1211
+
853
1212
  const reason = request.method !== "GET"
854
1213
  ? `method:${request.method}`
855
1214
  : "bypass-path";
856
1215
  resp.headers.set("X-Cache", "BYPASS");
1216
+ resp.headers.set("X-Cache-Profile", profile);
857
1217
  resp.headers.set("X-Cache-Reason", reason);
858
1218
  return resp;
859
1219
  }
@@ -885,7 +1245,22 @@ export function createDecoWorkerEntry(
885
1245
  const out = new Response(resp.body, resp);
886
1246
  const hdrs = cacheHeaders(profile);
887
1247
  for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
888
- out.headers.set("CDN-Cache-Control", "no-store");
1248
+
1249
+ // CDN-Cache-Control: controls Cloudflare's automatic CDN layer
1250
+ // (separate from Cache API which the worker manages directly).
1251
+ if (cdnCacheControlOpt === "no-store") {
1252
+ out.headers.set("CDN-Cache-Control", "no-store");
1253
+ } else if (cdnCacheControlOpt === "match-profile") {
1254
+ if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
1255
+ out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
1256
+ } else {
1257
+ out.headers.set("CDN-Cache-Control", "no-store");
1258
+ }
1259
+ } else if (typeof cdnCacheControlOpt === "function") {
1260
+ const val = cdnCacheControlOpt(profile);
1261
+ out.headers.set("CDN-Cache-Control", val ?? "no-store");
1262
+ }
1263
+
889
1264
  out.headers.set("X-Cache", xCache);
890
1265
  out.headers.set("X-Cache-Profile", profile);
891
1266
  if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
@@ -917,8 +1292,15 @@ export function createDecoWorkerEntry(
917
1292
  function revalidateInBackground() {
918
1293
  ctx.waitUntil(
919
1294
  Promise.resolve(serverEntry.fetch(request, env, ctx)).then((origin) => {
920
- if (origin.status === 200 && !origin.headers.has("set-cookie")) {
921
- storeInCache(origin);
1295
+ if (origin.status === 200) {
1296
+ // Only cache if response has no cookies or only safe cookies.
1297
+ // Strip safe cookies from the cached copy.
1298
+ if (hasOnlySafeCookies(origin, safeCookieSet)) {
1299
+ const cleanOrigin = origin.headers.has("set-cookie")
1300
+ ? stripSafeCookiesForCache(origin, safeCookieSet)
1301
+ : origin;
1302
+ storeInCache(cleanOrigin);
1303
+ }
922
1304
  }
923
1305
  }).catch(() => {
924
1306
  // Background revalidation failed — stale entry stays until SIE expires
@@ -998,14 +1380,16 @@ export function createDecoWorkerEntry(
998
1380
  return resp;
999
1381
  }
1000
1382
 
1001
- // Responses with Set-Cookie must never be cached — they carry
1002
- // per-user session/auth tokens that would leak to other users.
1003
- if (origin.headers.has("set-cookie")) {
1383
+ // Responses with private Set-Cookie headers must never be cached —
1384
+ // they carry per-user session/auth tokens that would leak to other users.
1385
+ // Safe/public cookies (IS session, segment, etc.) are stripped from the
1386
+ // cached copy but kept on the response served to the current user.
1387
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1004
1388
  const resp = new Response(origin.body, origin);
1005
1389
  resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1006
1390
  resp.headers.delete("CDN-Cache-Control");
1007
1391
  resp.headers.set("X-Cache", "BYPASS");
1008
- resp.headers.set("X-Cache-Reason", "set-cookie");
1392
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1009
1393
  appendResourceHints(resp);
1010
1394
  return resp;
1011
1395
  }
@@ -1025,7 +1409,12 @@ export function createDecoWorkerEntry(
1025
1409
  // dressResponse() calls new Response(resp.body, resp) which locks
1026
1410
  // the ReadableStream. Calling clone() on a locked body corrupts
1027
1411
  // the stream in Workers runtime, causing Error 1101.
1028
- storeInCache(origin);
1412
+ // Strip safe cookies from the cached copy so they don't leak
1413
+ // to other users, but the current user still gets them.
1414
+ const cacheOrigin = origin.headers.has("set-cookie")
1415
+ ? stripSafeCookiesForCache(origin, safeCookieSet)
1416
+ : origin;
1417
+ storeInCache(cacheOrigin);
1029
1418
  return dressResponse(origin, "MISS");
1030
1419
  }
1031
1420
  }