@decocms/start 2.28.1 → 2.29.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.
@@ -25,6 +25,12 @@
25
25
  * ```
26
26
  */
27
27
 
28
+ import { getRenderShellConfig } from "../admin/setup";
29
+ import { loadBlocks } from "../cms/loader";
30
+ import type { MatcherContext } from "../cms/resolve";
31
+ import { resolveDecoPage } from "../cms/resolve";
32
+ import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
33
+ import { logRequest, recordRequestMetric, withTracing } from "../middleware/observability";
28
34
  import {
29
35
  type CacheProfileName,
30
36
  cacheHeaders,
@@ -33,15 +39,11 @@ import {
33
39
  getCacheProfile,
34
40
  } from "./cacheHeaders";
35
41
  import { buildHtmlShell } from "./htmlShell";
36
- import { cleanPathForCacheKey } from "./urlUtils";
37
- import { type Device, isMobileUA } from "./useDevice";
38
- import { getRenderShellConfig } from "../admin/setup";
42
+ import { setRuntimeEnv } from "./otelAdapters";
39
43
  import { RequestContext } from "./requestContext";
40
44
  import { getAppMiddleware } from "./setupApps";
41
- import type { MatcherContext, ResolvedSection } from "../cms/resolve";
42
- import { resolveDecoPage } from "../cms/resolve";
43
- import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
44
- import { loadBlocks } from "../cms/loader";
45
+ import { cleanPathForCacheKey } from "./urlUtils";
46
+ import { type Device, isMobileUA } from "./useDevice";
45
47
 
46
48
  /**
47
49
  * Append Link preload headers for CSS and fonts so the browser starts
@@ -671,7 +673,10 @@ export function createDecoWorkerEntry(
671
673
  return parts.join("|");
672
674
  }
673
675
 
674
- function buildCacheKey(request: Request, env: Record<string, unknown>): { key: Request; segment?: SegmentKey } {
676
+ function buildCacheKey(
677
+ request: Request,
678
+ env: Record<string, unknown>,
679
+ ): { key: Request; segment?: SegmentKey } {
675
680
  const url = new URL(request.url);
676
681
 
677
682
  if (shouldStripTracking) {
@@ -795,7 +800,9 @@ export function createDecoWorkerEntry(
795
800
  const key = new Request(url.toString(), { method: "GET" });
796
801
  try {
797
802
  if (await cache.delete(key)) {
798
- const label = cc ? `${p} (${hashSegment(seg)}, ${cc})` : `${p} (${hashSegment(seg)})`;
803
+ const label = cc
804
+ ? `${p} (${hashSegment(seg)}, ${cc})`
805
+ : `${p} (${hashSegment(seg)})`;
799
806
  purged.push(label);
800
807
  }
801
808
  } catch {
@@ -924,6 +931,10 @@ export function createDecoWorkerEntry(
924
931
  env: Record<string, unknown>,
925
932
  ctx: WorkerExecutionContext,
926
933
  ): Promise<Response> {
934
+ const startedAt = performance.now();
935
+ const reqUrl = new URL(request.url);
936
+ const method = request.method;
937
+
927
938
  // Inject CF geo data as cookies for location matchers (before anything reads cookies)
928
939
  if (geoOpt) {
929
940
  request = injectGeoCookies(request);
@@ -933,20 +944,55 @@ export function createDecoWorkerEntry(
933
944
  // in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
934
945
  // can access the request and write response headers.
935
946
  const response = await RequestContext.run(request, async () => {
936
- // Run app middleware (injects app state into RequestContext.bag,
937
- // runs registered middleware like VTEX cookie forwarding).
938
- const appMw = getAppMiddleware();
939
- if (appMw) {
940
- return appMw(request, () => handleRequest(request, env, ctx));
941
- }
942
- return handleRequest(request, env, ctx);
947
+ // Stash env so request-scoped adapters (Workers Analytics Engine,
948
+ // future binding-driven destinations) can resolve their bindings
949
+ // via getRuntimeEnv() in sdk/otelAdapters.ts.
950
+ setRuntimeEnv(env);
951
+
952
+ // Wrap inner handler in a single root span. `@microlabs/otel-cf-workers`
953
+ // already creates an outer span via its `instrument()` wrapper; this
954
+ // adds a nested span carrying our normalized path/status attributes
955
+ // that microlabs doesn't capture (it uses url.path verbatim).
956
+ return withTracing(
957
+ "deco.http.request",
958
+ async () => {
959
+ // Run app middleware (injects app state into RequestContext.bag,
960
+ // runs registered middleware like VTEX cookie forwarding).
961
+ const appMw = getAppMiddleware();
962
+ if (appMw) {
963
+ return appMw(request, () => handleRequest(request, env, ctx));
964
+ }
965
+ return handleRequest(request, env, ctx);
966
+ },
967
+ {
968
+ "http.method": method,
969
+ "url.path": reqUrl.pathname,
970
+ },
971
+ );
943
972
  });
944
973
 
945
974
  // Deduplicate Set-Cookie headers — multiple layers (VTEX middleware,
946
975
  // invoke handlers, etc.) may independently append the same cookie.
947
976
  deduplicateSetCookies(response);
948
977
 
949
- return applySecurityHeaders(response);
978
+ const finalResponse = applySecurityHeaders(response);
979
+
980
+ // Metrics + structured request log. Done after security headers so
981
+ // the recorded status reflects what the client actually receives.
982
+ // Both calls are no-ops when no meter / logger is configured.
983
+ const durationMs = performance.now() - startedAt;
984
+ try {
985
+ recordRequestMetric(method, reqUrl.pathname, finalResponse.status, durationMs);
986
+ } catch {
987
+ /* swallow — observability must never fail the request */
988
+ }
989
+ try {
990
+ logRequest(request, finalResponse.status, durationMs);
991
+ } catch {
992
+ /* swallow */
993
+ }
994
+
995
+ return finalResponse;
950
996
  },
951
997
  };
952
998
 
@@ -957,425 +1003,438 @@ export function createDecoWorkerEntry(
957
1003
  env: Record<string, unknown>,
958
1004
  ctx: WorkerExecutionContext,
959
1005
  ): Promise<Response> {
960
- const url = new URL(request.url);
1006
+ const url = new URL(request.url);
1007
+
1008
+ // Admin routes (/_meta, /.decofile, /live/previews) — always handled first
1009
+ const adminResponse = await tryAdminRoute(request);
1010
+ if (adminResponse) return adminResponse;
961
1011
 
962
- // Admin routes (/_meta, /.decofile, /live/previews) — always handled first
963
- const adminResponse = await tryAdminRoute(request);
964
- if (adminResponse) return adminResponse;
1012
+ // Purge endpoint
1013
+ if (url.pathname === "/_cache/purge" && request.method === "POST") {
1014
+ return handlePurge(request, env);
1015
+ }
965
1016
 
966
- // Purge endpoint
967
- if (url.pathname === "/_cache/purge" && request.method === "POST") {
968
- return handlePurge(request, env);
1017
+ // ?asJson — return resolved page data as JSON (legacy deco compat)
1018
+ if (url.searchParams.has("asJson") && request.method === "GET") {
1019
+ const basePath = url.pathname;
1020
+ const cookies: Record<string, string> = {};
1021
+ for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
1022
+ const [k, ...v] = pair.split("=");
1023
+ if (k?.trim()) cookies[k.trim()] = v.join("=").trim();
969
1024
  }
1025
+ const matcherCtx: MatcherContext = {
1026
+ userAgent: request.headers.get("user-agent") ?? "",
1027
+ url: url.toString(),
1028
+ path: basePath,
1029
+ cookies,
1030
+ request,
1031
+ };
1032
+ const page = await resolveDecoPage(basePath, matcherCtx);
1033
+ if (!page) {
1034
+ return Response.json(null, {
1035
+ status: 404,
1036
+ headers: { "Access-Control-Allow-Origin": "*" },
1037
+ });
1038
+ }
1039
+ const enrichedSections = await runSectionLoaders(page.resolvedSections, request);
970
1040
 
971
- // ?asJson return resolved page data as JSON (legacy deco compat)
972
- if (url.searchParams.has("asJson") && request.method === "GET") {
973
- const basePath = url.pathname;
974
- const cookies: Record<string, string> = {};
975
- for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
976
- const [k, ...v] = pair.split("=");
977
- if (k?.trim()) cookies[k.trim()] = v.join("=").trim();
978
- }
979
- const matcherCtx: MatcherContext = {
980
- userAgent: request.headers.get("user-agent") ?? "",
981
- url: url.toString(),
982
- path: basePath,
983
- cookies,
984
- request,
985
- };
986
- const page = await resolveDecoPage(basePath, matcherCtx);
987
- if (!page) {
988
- return Response.json(null, { status: 404, headers: { "Access-Control-Allow-Origin": "*" } });
989
- }
990
- const enrichedSections = await runSectionLoaders(page.resolvedSections, request);
991
-
992
- // Run SEO section loader if registered
993
- let seoResult = page.seoSection;
994
- if (seoResult) {
995
- try {
996
- seoResult = await runSingleSectionLoader(seoResult, request);
997
- } catch {
998
- // use unloaded seoSection
999
- }
1041
+ // Run SEO section loader if registered
1042
+ let seoResult = page.seoSection;
1043
+ if (seoResult) {
1044
+ try {
1045
+ seoResult = await runSingleSectionLoader(seoResult, request);
1046
+ } catch {
1047
+ // use unloaded seoSection
1000
1048
  }
1049
+ }
1001
1050
 
1002
- // Merge site-wide SEO defaults into seo props
1003
- const blocks = loadBlocks();
1004
- const site = blocks["Site"] as Record<string, unknown> | undefined;
1005
- const fullSiteSeo = (site?.seo as Record<string, unknown>) ?? {};
1006
-
1007
- // When SeoV2 loader ran, use its output as base (preserves key order)
1008
- // and only fill in missing fields from the site-wide SEO config.
1009
- const loaderProps = seoResult?.props ?? {};
1010
- const seoProps: Record<string, unknown> = { ...loaderProps };
1011
- for (const [k, v] of Object.entries(fullSiteSeo)) {
1012
- if (!(k in seoProps)) seoProps[k] = v;
1013
- }
1014
- // Strip internal template fields
1015
- delete seoProps.titleTemplate;
1016
- delete seoProps.descriptionTemplate;
1017
-
1018
- // Build resolveChain statically to match legacy deco-cx/deco format.
1019
- type FieldResolver = { type: string; value: string | number };
1020
- const rawKey = page.blockKey ?? `pages-${page.name}`;
1021
- const encodedKey = rawKey.replace(
1022
- /^(pages-)(.+)$/,
1023
- (_m, prefix, rest) => prefix + encodeURIComponent(rest),
1024
- );
1025
- const pageChain: FieldResolver[] = [
1026
- { type: "resolver", value: "website/handlers/fresh.ts" },
1027
- { type: "prop", value: "page" },
1028
- { type: "resolver", value: "resolved" },
1029
- { type: "resolvable", value: encodedKey },
1030
- { type: "resolver", value: "website/pages/Page.tsx" },
1031
- ];
1032
-
1033
- const seoChain: FieldResolver[] = [
1034
- ...pageChain,
1035
- { type: "prop", value: "seo" },
1036
- { type: "resolver", value: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx" },
1037
- ];
1038
-
1039
- const result = {
1040
- props: {
1041
- name: page.name,
1042
- path: page.path,
1043
- seo: {
1044
- props: seoProps,
1045
- metadata: {
1046
- resolveChain: seoChain,
1047
- component: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx",
1048
- },
1051
+ // Merge site-wide SEO defaults into seo props
1052
+ const blocks = loadBlocks();
1053
+ const site = blocks["Site"] as Record<string, unknown> | undefined;
1054
+ const fullSiteSeo = (site?.seo as Record<string, unknown>) ?? {};
1055
+
1056
+ // When SeoV2 loader ran, use its output as base (preserves key order)
1057
+ // and only fill in missing fields from the site-wide SEO config.
1058
+ const loaderProps = seoResult?.props ?? {};
1059
+ const seoProps: Record<string, unknown> = { ...loaderProps };
1060
+ for (const [k, v] of Object.entries(fullSiteSeo)) {
1061
+ if (!(k in seoProps)) seoProps[k] = v;
1062
+ }
1063
+ // Strip internal template fields
1064
+ delete seoProps.titleTemplate;
1065
+ delete seoProps.descriptionTemplate;
1066
+
1067
+ // Build resolveChain statically to match legacy deco-cx/deco format.
1068
+ type FieldResolver = { type: string; value: string | number };
1069
+ const rawKey = page.blockKey ?? `pages-${page.name}`;
1070
+ const encodedKey = rawKey.replace(
1071
+ /^(pages-)(.+)$/,
1072
+ (_m, prefix, rest) => prefix + encodeURIComponent(rest),
1073
+ );
1074
+ const pageChain: FieldResolver[] = [
1075
+ { type: "resolver", value: "website/handlers/fresh.ts" },
1076
+ { type: "prop", value: "page" },
1077
+ { type: "resolver", value: "resolved" },
1078
+ { type: "resolvable", value: encodedKey },
1079
+ { type: "resolver", value: "website/pages/Page.tsx" },
1080
+ ];
1081
+
1082
+ const seoChain: FieldResolver[] = [
1083
+ ...pageChain,
1084
+ { type: "prop", value: "seo" },
1085
+ { type: "resolver", value: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx" },
1086
+ ];
1087
+
1088
+ const result = {
1089
+ props: {
1090
+ name: page.name,
1091
+ path: page.path,
1092
+ seo: {
1093
+ props: seoProps,
1094
+ metadata: {
1095
+ resolveChain: seoChain,
1096
+ component: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx",
1049
1097
  },
1050
- sections: enrichedSections.map((s, i) => ({
1051
- props: s.props,
1052
- metadata: {
1053
- resolveChain: [
1054
- ...pageChain,
1055
- { type: "prop", value: "sections" },
1056
- { type: "prop", value: String(i) },
1057
- { type: "resolver", value: s.component },
1058
- ],
1059
- component: s.component,
1060
- },
1061
- })),
1062
- devMode: false,
1063
- unindexedDomain: false,
1064
1098
  },
1065
- metadata: {
1066
- resolveChain: pageChain,
1067
- component: "website/pages/Page.tsx",
1068
- },
1069
- };
1070
- return Response.json(result, {
1071
- headers: {
1072
- "Access-Control-Allow-Origin": "*",
1073
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
1074
- "Access-Control-Allow-Methods": "GET, OPTIONS",
1075
- },
1076
- });
1077
- }
1099
+ sections: enrichedSections.map((s, i) => ({
1100
+ props: s.props,
1101
+ metadata: {
1102
+ resolveChain: [
1103
+ ...pageChain,
1104
+ { type: "prop", value: "sections" },
1105
+ { type: "prop", value: String(i) },
1106
+ { type: "resolver", value: s.component },
1107
+ ],
1108
+ component: s.component,
1109
+ },
1110
+ })),
1111
+ devMode: false,
1112
+ unindexedDomain: false,
1113
+ },
1114
+ metadata: {
1115
+ resolveChain: pageChain,
1116
+ component: "website/pages/Page.tsx",
1117
+ },
1118
+ };
1119
+ return Response.json(result, {
1120
+ headers: {
1121
+ "Access-Control-Allow-Origin": "*",
1122
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
1123
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
1124
+ },
1125
+ });
1126
+ }
1078
1127
 
1079
- // Commerce proxy (checkout, account, API, etc.)
1080
- if (options.proxyHandler) {
1081
- const proxyResponse = await options.proxyHandler(request, url);
1082
- if (proxyResponse) return proxyResponse;
1083
- }
1128
+ // Commerce proxy (checkout, account, API, etc.)
1129
+ if (options.proxyHandler) {
1130
+ const proxyResponse = await options.proxyHandler(request, url);
1131
+ if (proxyResponse) return proxyResponse;
1132
+ }
1084
1133
 
1085
- // Static fingerprinted assets — serve from origin with immutable headers
1086
- if (isStaticAsset(url.pathname)) {
1087
- const origin = await serverEntry.fetch(request, env, ctx);
1088
- if (origin.status === 200) {
1089
- const ct = origin.headers.get("content-type") ?? "";
1090
- if (ct.includes("text/html")) {
1091
- return new Response("Not Found", { status: 404 });
1092
- }
1093
- const resp = new Response(origin.body, origin);
1094
- for (const [k, v] of Object.entries(IMMUTABLE_HEADERS)) {
1095
- resp.headers.set(k, v);
1096
- }
1097
- return resp;
1134
+ // Static fingerprinted assets — serve from origin with immutable headers
1135
+ if (isStaticAsset(url.pathname)) {
1136
+ const origin = await serverEntry.fetch(request, env, ctx);
1137
+ if (origin.status === 200) {
1138
+ const ct = origin.headers.get("content-type") ?? "";
1139
+ if (ct.includes("text/html")) {
1140
+ return new Response("Not Found", { status: 404 });
1098
1141
  }
1099
- return origin;
1142
+ const resp = new Response(origin.body, origin);
1143
+ for (const [k, v] of Object.entries(IMMUTABLE_HEADERS)) {
1144
+ resp.headers.set(k, v);
1145
+ }
1146
+ return resp;
1100
1147
  }
1148
+ return origin;
1149
+ }
1101
1150
 
1102
- // -----------------------------------------------------------------
1103
- // POST _serverFn — edge-cacheable using body-hash as cache key.
1104
- // These carry public CMS section data (shelves, deferred sections)
1105
- // that benefits from edge caching despite being POST requests.
1106
- // -----------------------------------------------------------------
1107
- if (
1108
- request.method === "POST" &&
1109
- (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
1110
- ) {
1111
- const serverFnCache =
1112
- typeof caches !== "undefined"
1113
- ? ((caches as unknown as { default?: Cache }).default ?? null)
1114
- : null;
1115
-
1116
- // Build segment once — used for logged-in check and cache key
1117
- const sfnSegment = buildSegment ? buildSegment(request) : undefined;
1118
-
1119
- // Logged-in users always bypass — personalized content must not leak
1120
- if (sfnSegment?.loggedIn) {
1121
- const origin = await serverEntry.fetch(request, env, ctx);
1122
- const resp = new Response(origin.body, origin);
1123
- resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1124
- resp.headers.set("X-Cache", "BYPASS");
1125
- resp.headers.set("X-Cache-Reason", "logged-in");
1126
- return resp;
1127
- }
1151
+ // -----------------------------------------------------------------
1152
+ // POST _serverFn — edge-cacheable using body-hash as cache key.
1153
+ // These carry public CMS section data (shelves, deferred sections)
1154
+ // that benefits from edge caching despite being POST requests.
1155
+ // -----------------------------------------------------------------
1156
+ if (
1157
+ request.method === "POST" &&
1158
+ (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
1159
+ ) {
1160
+ const serverFnCache =
1161
+ typeof caches !== "undefined"
1162
+ ? ((caches as unknown as { default?: Cache }).default ?? null)
1163
+ : null;
1128
1164
 
1129
- // Clone request before consuming body the clone goes to origin
1130
- // untouched so TanStack Start internals (cookie passthrough, etc.)
1131
- // work correctly. We only read the body for the cache key hash.
1132
- const originClone = request.clone();
1133
- const body = await request.text();
1134
- const bodyHash = await hashText(body);
1135
-
1136
- // Build a synthetic GET cache key from the URL + body hash + segment
1137
- // Includes device, salesChannel, regionId, flags — so users in
1138
- // different regions or channels get separate cache entries.
1139
- const cacheKeyUrl = new URL(request.url);
1140
- cacheKeyUrl.searchParams.set("__body", bodyHash);
1141
- if (cacheVersionEnv !== false) {
1142
- const version = (env[cacheVersionEnv] as string) || "";
1143
- if (version) cacheKeyUrl.searchParams.set("__v", version);
1144
- }
1145
- if (sfnSegment) {
1146
- cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
1147
- } else if (deviceSpecificKeys) {
1148
- const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
1149
- cacheKeyUrl.searchParams.set("__cf_device", device);
1150
- }
1151
- // Include CF geo data so location-based content doesn't leak across geos
1152
- const cf = (request as unknown as { cf?: Record<string, string> }).cf;
1153
- if (cf) {
1154
- const geoParts: string[] = [];
1155
- if (cf.country) geoParts.push(cf.country);
1156
- if (cf.region) geoParts.push(cf.region);
1157
- if (cf.city) geoParts.push(cf.city);
1158
- if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
1159
- }
1160
- const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
1161
-
1162
- // Use "listing" profile for server function responses
1163
- const sfnProfile: CacheProfileName = "listing";
1164
- const sfnEdge = edgeCacheConfig(sfnProfile);
1165
-
1166
- // Check edge cache
1167
- let sfnCached: Response | undefined;
1168
- if (serverFnCache) {
1169
- try {
1170
- sfnCached = await serverFnCache.match(sfnCacheKey) ?? undefined;
1171
- } catch { /* Cache API unavailable */ }
1172
- }
1165
+ // Build segment once used for logged-in check and cache key
1166
+ const sfnSegment = buildSegment ? buildSegment(request) : undefined;
1173
1167
 
1174
- if (sfnCached && sfnEdge.fresh > 0) {
1175
- const storedAt = Number(sfnCached.headers.get("X-Deco-Stored-At") || "0");
1176
- const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1168
+ // Logged-in users always bypass personalized content must not leak
1169
+ if (sfnSegment?.loggedIn) {
1170
+ const origin = await serverEntry.fetch(request, env, ctx);
1171
+ const resp = new Response(origin.body, origin);
1172
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1173
+ resp.headers.set("X-Cache", "BYPASS");
1174
+ resp.headers.set("X-Cache-Reason", "logged-in");
1175
+ return resp;
1176
+ }
1177
1177
 
1178
- if (ageSec < sfnEdge.fresh) {
1179
- const out = new Response(sfnCached.body, sfnCached);
1180
- const hdrs = cacheHeaders(sfnProfile);
1181
- for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1182
- out.headers.set("X-Cache", "HIT");
1183
- out.headers.set("X-Cache-Profile", sfnProfile);
1184
- return out;
1185
- }
1178
+ // Clone request before consuming body — the clone goes to origin
1179
+ // untouched so TanStack Start internals (cookie passthrough, etc.)
1180
+ // work correctly. We only read the body for the cache key hash.
1181
+ const originClone = request.clone();
1182
+ const body = await request.text();
1183
+ const bodyHash = await hashText(body);
1184
+
1185
+ // Build a synthetic GET cache key from the URL + body hash + segment
1186
+ // Includes device, salesChannel, regionId, flags — so users in
1187
+ // different regions or channels get separate cache entries.
1188
+ const cacheKeyUrl = new URL(request.url);
1189
+ cacheKeyUrl.searchParams.set("__body", bodyHash);
1190
+ if (cacheVersionEnv !== false) {
1191
+ const version = (env[cacheVersionEnv] as string) || "";
1192
+ if (version) cacheKeyUrl.searchParams.set("__v", version);
1193
+ }
1194
+ if (sfnSegment) {
1195
+ cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
1196
+ } else if (deviceSpecificKeys) {
1197
+ const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
1198
+ cacheKeyUrl.searchParams.set("__cf_device", device);
1199
+ }
1200
+ // Include CF geo data so location-based content doesn't leak across geos
1201
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
1202
+ if (cf) {
1203
+ const geoParts: string[] = [];
1204
+ if (cf.country) geoParts.push(cf.country);
1205
+ if (cf.region) geoParts.push(cf.region);
1206
+ if (cf.city) geoParts.push(cf.city);
1207
+ if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
1208
+ }
1209
+ const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
1186
1210
 
1187
- if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
1188
- // Stale-while-revalidate: serve stale, refresh in background
1189
- ctx.waitUntil(
1190
- (async () => {
1191
- try {
1192
- const bgReq = new Request(request, { body, method: "POST" });
1193
- const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
1194
- if (
1195
- bgOrigin.status === 200 &&
1196
- bgOrigin.headers.get("X-Deco-Cacheable") === "true" &&
1197
- !bgOrigin.headers.has("set-cookie") &&
1198
- serverFnCache
1199
- ) {
1200
- const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1201
- const toStore = bgOrigin.clone();
1202
- toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1203
- toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1204
- toStore.headers.delete("CDN-Cache-Control");
1205
- toStore.headers.delete("X-Deco-Cacheable");
1206
- await serverFnCache.put(sfnCacheKey, toStore);
1207
- }
1208
- } catch { /* background revalidation failed */ }
1209
- })(),
1210
- );
1211
- const out = new Response(sfnCached.body, sfnCached);
1212
- const hdrs = cacheHeaders(sfnProfile);
1213
- for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1214
- out.headers.set("X-Cache", "STALE-HIT");
1215
- out.headers.set("X-Cache-Profile", sfnProfile);
1216
- out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
1217
- return out;
1218
- }
1211
+ // Use "listing" profile for server function responses
1212
+ const sfnProfile: CacheProfileName = "listing";
1213
+ const sfnEdge = edgeCacheConfig(sfnProfile);
1214
+
1215
+ // Check edge cache
1216
+ let sfnCached: Response | undefined;
1217
+ if (serverFnCache) {
1218
+ try {
1219
+ sfnCached = (await serverFnCache.match(sfnCacheKey)) ?? undefined;
1220
+ } catch {
1221
+ /* Cache API unavailable */
1219
1222
  }
1223
+ }
1220
1224
 
1221
- // Cache MISS fetch origin with the body we already read
1222
- const origin = await serverEntry.fetch(originClone, env, ctx);
1223
-
1224
- // Only cache responses explicitly marked as cacheable by the handler
1225
- // (loadDeferredSection sets X-Deco-Cacheable: true). Checkout actions,
1226
- // invoke mutations, and other server functions are passed through.
1227
- const isCacheableResponse =
1228
- origin.headers.get("X-Deco-Cacheable") === "true" &&
1229
- !origin.headers.has("set-cookie") &&
1230
- origin.status === 200;
1231
-
1232
- if (!isCacheableResponse) {
1233
- const resp = new Response(origin.body, origin);
1234
- resp.headers.delete("X-Deco-Cacheable");
1235
- resp.headers.set("X-Cache", "BYPASS");
1236
- resp.headers.set("X-Cache-Reason", origin.headers.has("set-cookie")
1237
- ? "set-cookie"
1238
- : "not-cacheable");
1239
- return resp;
1225
+ if (sfnCached && sfnEdge.fresh > 0) {
1226
+ const storedAt = Number(sfnCached.headers.get("X-Deco-Stored-At") || "0");
1227
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1228
+
1229
+ if (ageSec < sfnEdge.fresh) {
1230
+ const out = new Response(sfnCached.body, sfnCached);
1231
+ const hdrs = cacheHeaders(sfnProfile);
1232
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1233
+ out.headers.set("X-Cache", "HIT");
1234
+ out.headers.set("X-Cache-Profile", sfnProfile);
1235
+ return out;
1240
1236
  }
1241
1237
 
1242
- // Store in edge cache
1243
- if (serverFnCache) {
1244
- try {
1245
- const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1246
- const toStore = origin.clone();
1247
- toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1248
- toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1249
- toStore.headers.delete("CDN-Cache-Control");
1250
- toStore.headers.delete("X-Deco-Cacheable");
1251
- ctx.waitUntil(serverFnCache.put(sfnCacheKey, toStore));
1252
- } catch { /* Cache API unavailable */ }
1238
+ if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
1239
+ // Stale-while-revalidate: serve stale, refresh in background
1240
+ ctx.waitUntil(
1241
+ (async () => {
1242
+ try {
1243
+ const bgReq = new Request(request, { body, method: "POST" });
1244
+ const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
1245
+ if (
1246
+ bgOrigin.status === 200 &&
1247
+ bgOrigin.headers.get("X-Deco-Cacheable") === "true" &&
1248
+ !bgOrigin.headers.has("set-cookie") &&
1249
+ serverFnCache
1250
+ ) {
1251
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1252
+ const toStore = bgOrigin.clone();
1253
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1254
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1255
+ toStore.headers.delete("CDN-Cache-Control");
1256
+ toStore.headers.delete("X-Deco-Cacheable");
1257
+ await serverFnCache.put(sfnCacheKey, toStore);
1258
+ }
1259
+ } catch {
1260
+ /* background revalidation failed */
1261
+ }
1262
+ })(),
1263
+ );
1264
+ const out = new Response(sfnCached.body, sfnCached);
1265
+ const hdrs = cacheHeaders(sfnProfile);
1266
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1267
+ out.headers.set("X-Cache", "STALE-HIT");
1268
+ out.headers.set("X-Cache-Profile", sfnProfile);
1269
+ out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
1270
+ return out;
1253
1271
  }
1272
+ }
1273
+
1274
+ // Cache MISS — fetch origin with the body we already read
1275
+ const origin = await serverEntry.fetch(originClone, env, ctx);
1276
+
1277
+ // Only cache responses explicitly marked as cacheable by the handler
1278
+ // (loadDeferredSection sets X-Deco-Cacheable: true). Checkout actions,
1279
+ // invoke mutations, and other server functions are passed through.
1280
+ const isCacheableResponse =
1281
+ origin.headers.get("X-Deco-Cacheable") === "true" &&
1282
+ !origin.headers.has("set-cookie") &&
1283
+ origin.status === 200;
1254
1284
 
1285
+ if (!isCacheableResponse) {
1255
1286
  const resp = new Response(origin.body, origin);
1256
1287
  resp.headers.delete("X-Deco-Cacheable");
1257
- const hdrs = cacheHeaders(sfnProfile);
1258
- for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
1259
- resp.headers.set("X-Cache", "MISS");
1260
- resp.headers.set("X-Cache-Profile", sfnProfile);
1288
+ resp.headers.set("X-Cache", "BYPASS");
1289
+ resp.headers.set(
1290
+ "X-Cache-Reason",
1291
+ origin.headers.has("set-cookie") ? "set-cookie" : "not-cacheable",
1292
+ );
1261
1293
  return resp;
1262
1294
  }
1263
1295
 
1264
- // Non-cacheable requests pass through but protect against accidental caching
1265
- if (!isCacheable(request, url)) {
1266
- const origin = await serverEntry.fetch(request, env, ctx);
1267
- const profile = getProfile(url);
1268
-
1269
- if (profile === "private" || profile === "none" || profile === "cart") {
1270
- const resp = new Response(origin.body, origin);
1271
- resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1272
- resp.headers.delete("CDN-Cache-Control");
1273
- resp.headers.set("X-Cache", "BYPASS");
1274
- resp.headers.set("X-Cache-Reason", `non-cacheable:${profile}`);
1275
- return resp;
1296
+ // Store in edge cache
1297
+ if (serverFnCache) {
1298
+ try {
1299
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
1300
+ const toStore = origin.clone();
1301
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
1302
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1303
+ toStore.headers.delete("CDN-Cache-Control");
1304
+ toStore.headers.delete("X-Deco-Cacheable");
1305
+ ctx.waitUntil(serverFnCache.put(sfnCacheKey, toStore));
1306
+ } catch {
1307
+ /* Cache API unavailable */
1276
1308
  }
1309
+ }
1277
1310
 
1278
- const resp = new Response(origin.body, origin);
1279
-
1280
- // Responses with private Set-Cookie headers carry per-user tokens —
1281
- // never expose them with public cache headers.
1282
- // Safe/public cookies (e.g., vtex_is_session) are allowed through.
1283
- if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1284
- resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1285
- resp.headers.delete("CDN-Cache-Control");
1286
- resp.headers.set("X-Cache", "BYPASS");
1287
- resp.headers.set("X-Cache-Reason", "private-set-cookie");
1288
- return resp;
1289
- }
1311
+ const resp = new Response(origin.body, origin);
1312
+ resp.headers.delete("X-Deco-Cacheable");
1313
+ const hdrs = cacheHeaders(sfnProfile);
1314
+ for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
1315
+ resp.headers.set("X-Cache", "MISS");
1316
+ resp.headers.set("X-Cache-Profile", sfnProfile);
1317
+ return resp;
1318
+ }
1290
1319
 
1291
- // Set cache headers from the detected profile so the response
1292
- // is explicit about cacheability (avoids ambiguous empty header).
1293
- const hdrsNc = cacheHeaders(profile);
1294
- for (const [k, v] of Object.entries(hdrsNc)) resp.headers.set(k, v);
1320
+ // Non-cacheable requests pass through but protect against accidental caching
1321
+ if (!isCacheable(request, url)) {
1322
+ const origin = await serverEntry.fetch(request, env, ctx);
1323
+ const profile = getProfile(url);
1295
1324
 
1296
- const reason = request.method !== "GET"
1297
- ? `method:${request.method}`
1298
- : "bypass-path";
1325
+ if (profile === "private" || profile === "none" || profile === "cart") {
1326
+ const resp = new Response(origin.body, origin);
1327
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1328
+ resp.headers.delete("CDN-Cache-Control");
1299
1329
  resp.headers.set("X-Cache", "BYPASS");
1300
- resp.headers.set("X-Cache-Profile", profile);
1301
- resp.headers.set("X-Cache-Reason", reason);
1330
+ resp.headers.set("X-Cache-Reason", `non-cacheable:${profile}`);
1302
1331
  return resp;
1303
1332
  }
1304
1333
 
1305
- // Cacheable request build segment-aware cache key
1306
- const { key: cacheKey, segment } = buildCacheKey(request, env);
1334
+ const resp = new Response(origin.body, origin);
1307
1335
 
1308
- // Logged-in users always bypass the cache (personalized content)
1309
- if (segment?.loggedIn) {
1310
- const origin = await serverEntry.fetch(request, env, ctx);
1311
- const resp = new Response(origin.body, origin);
1336
+ // Responses with private Set-Cookie headers carry per-user tokens —
1337
+ // never expose them with public cache headers.
1338
+ // Safe/public cookies (e.g., vtex_is_session) are allowed through.
1339
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1312
1340
  resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1341
+ resp.headers.delete("CDN-Cache-Control");
1313
1342
  resp.headers.set("X-Cache", "BYPASS");
1314
- resp.headers.set("X-Cache-Reason", "logged-in");
1343
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1315
1344
  return resp;
1316
1345
  }
1317
1346
 
1318
- // Check Cache API (may not be available in local dev / miniflare)
1319
- const cache =
1320
- typeof caches !== "undefined"
1321
- ? ((caches as unknown as { default?: Cache }).default ?? null)
1322
- : null;
1347
+ // Set cache headers from the detected profile so the response
1348
+ // is explicit about cacheability (avoids ambiguous empty header).
1349
+ const hdrsNc = cacheHeaders(profile);
1350
+ for (const [k, v] of Object.entries(hdrsNc)) resp.headers.set(k, v);
1323
1351
 
1324
- const profile = getProfile(url);
1325
- const edgeConfig = edgeCacheConfig(profile);
1352
+ const reason = request.method !== "GET" ? `method:${request.method}` : "bypass-path";
1353
+ resp.headers.set("X-Cache", "BYPASS");
1354
+ resp.headers.set("X-Cache-Profile", profile);
1355
+ resp.headers.set("X-Cache-Reason", reason);
1356
+ return resp;
1357
+ }
1326
1358
 
1327
- // Helper: dress a response with proper client-facing headers
1328
- function dressResponse(resp: Response, xCache: string, extra?: Record<string, string>): Response {
1329
- const out = new Response(resp.body, resp);
1330
- const hdrs = cacheHeaders(profile);
1331
- for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1359
+ // Cacheable request build segment-aware cache key
1360
+ const { key: cacheKey, segment } = buildCacheKey(request, env);
1361
+
1362
+ // Logged-in users always bypass the cache (personalized content)
1363
+ if (segment?.loggedIn) {
1364
+ const origin = await serverEntry.fetch(request, env, ctx);
1365
+ const resp = new Response(origin.body, origin);
1366
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1367
+ resp.headers.set("X-Cache", "BYPASS");
1368
+ resp.headers.set("X-Cache-Reason", "logged-in");
1369
+ return resp;
1370
+ }
1332
1371
 
1333
- // CDN-Cache-Control: controls Cloudflare's automatic CDN layer
1334
- // (separate from Cache API which the worker manages directly).
1335
- if (cdnCacheControlOpt === "no-store") {
1372
+ // Check Cache API (may not be available in local dev / miniflare)
1373
+ const cache =
1374
+ typeof caches !== "undefined"
1375
+ ? ((caches as unknown as { default?: Cache }).default ?? null)
1376
+ : null;
1377
+
1378
+ const profile = getProfile(url);
1379
+ const edgeConfig = edgeCacheConfig(profile);
1380
+
1381
+ // Helper: dress a response with proper client-facing headers
1382
+ function dressResponse(
1383
+ resp: Response,
1384
+ xCache: string,
1385
+ extra?: Record<string, string>,
1386
+ ): Response {
1387
+ const out = new Response(resp.body, resp);
1388
+ const hdrs = cacheHeaders(profile);
1389
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
1390
+
1391
+ // CDN-Cache-Control: controls Cloudflare's automatic CDN layer
1392
+ // (separate from Cache API which the worker manages directly).
1393
+ if (cdnCacheControlOpt === "no-store") {
1394
+ out.headers.set("CDN-Cache-Control", "no-store");
1395
+ } else if (cdnCacheControlOpt === "match-profile") {
1396
+ if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
1397
+ out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
1398
+ } else {
1336
1399
  out.headers.set("CDN-Cache-Control", "no-store");
1337
- } else if (cdnCacheControlOpt === "match-profile") {
1338
- if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
1339
- out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
1340
- } else {
1341
- out.headers.set("CDN-Cache-Control", "no-store");
1342
- }
1343
- } else if (typeof cdnCacheControlOpt === "function") {
1344
- const val = cdnCacheControlOpt(profile);
1345
- out.headers.set("CDN-Cache-Control", val ?? "no-store");
1346
1400
  }
1401
+ } else if (typeof cdnCacheControlOpt === "function") {
1402
+ const val = cdnCacheControlOpt(profile);
1403
+ out.headers.set("CDN-Cache-Control", val ?? "no-store");
1404
+ }
1347
1405
 
1348
- out.headers.set("X-Cache", xCache);
1349
- out.headers.set("X-Cache-Profile", profile);
1350
- if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
1351
- if (cacheVersionEnv !== false) {
1352
- const v = (env[cacheVersionEnv] as string) || "";
1353
- if (v) out.headers.set("X-Cache-Version", v);
1354
- }
1355
- if (extra) for (const [k, v] of Object.entries(extra)) out.headers.set(k, v);
1356
- appendResourceHints(out);
1357
- return out;
1406
+ out.headers.set("X-Cache", xCache);
1407
+ out.headers.set("X-Cache-Profile", profile);
1408
+ if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
1409
+ if (cacheVersionEnv !== false) {
1410
+ const v = (env[cacheVersionEnv] as string) || "";
1411
+ if (v) out.headers.set("X-Cache-Version", v);
1358
1412
  }
1413
+ if (extra) for (const [k, v] of Object.entries(extra)) out.headers.set(k, v);
1414
+ appendResourceHints(out);
1415
+ return out;
1416
+ }
1359
1417
 
1360
- // Helper: store a response in Cache API with the full retention window
1361
- function storeInCache(resp: Response) {
1362
- if (!cache) return;
1363
- try {
1364
- const storageTtl = edgeConfig.fresh + Math.max(edgeConfig.swr, edgeConfig.sie);
1365
- const toStore = resp.clone();
1366
- toStore.headers.set("Cache-Control", `public, max-age=${storageTtl}`);
1367
- toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1368
- toStore.headers.delete("CDN-Cache-Control");
1369
- ctx.waitUntil(cache.put(cacheKey, toStore));
1370
- } catch {
1371
- // Cache API unavailable
1372
- }
1418
+ // Helper: store a response in Cache API with the full retention window
1419
+ function storeInCache(resp: Response) {
1420
+ if (!cache) return;
1421
+ try {
1422
+ const storageTtl = edgeConfig.fresh + Math.max(edgeConfig.swr, edgeConfig.sie);
1423
+ const toStore = resp.clone();
1424
+ toStore.headers.set("Cache-Control", `public, max-age=${storageTtl}`);
1425
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
1426
+ toStore.headers.delete("CDN-Cache-Control");
1427
+ ctx.waitUntil(cache.put(cacheKey, toStore));
1428
+ } catch {
1429
+ // Cache API unavailable
1373
1430
  }
1431
+ }
1374
1432
 
1375
- // Helper: background revalidation (fetch origin, store result)
1376
- function revalidateInBackground() {
1377
- ctx.waitUntil(
1378
- Promise.resolve(serverEntry.fetch(request, env, ctx)).then((origin) => {
1433
+ // Helper: background revalidation (fetch origin, store result)
1434
+ function revalidateInBackground() {
1435
+ ctx.waitUntil(
1436
+ Promise.resolve(serverEntry.fetch(request, env, ctx))
1437
+ .then((origin) => {
1379
1438
  if (origin.status === 200) {
1380
1439
  // Only cache if response has no cookies or only safe cookies.
1381
1440
  // Strip safe cookies from the cached copy.
@@ -1386,120 +1445,126 @@ export function createDecoWorkerEntry(
1386
1445
  storeInCache(cleanOrigin);
1387
1446
  }
1388
1447
  }
1389
- }).catch(() => {
1448
+ })
1449
+ .catch(() => {
1390
1450
  // Background revalidation failed — stale entry stays until SIE expires
1391
1451
  }),
1392
- );
1452
+ );
1453
+ }
1454
+
1455
+ // --- Edge cache check with SWR + SIE ---
1456
+ let cached: Response | undefined;
1457
+ if (cache) {
1458
+ try {
1459
+ cached = (await cache.match(cacheKey)) ?? undefined;
1460
+ } catch {
1461
+ // Cache API unavailable
1393
1462
  }
1463
+ }
1394
1464
 
1395
- // --- Edge cache check with SWR + SIE ---
1396
- let cached: Response | undefined;
1397
- if (cache) {
1398
- try {
1399
- cached = await cache.match(cacheKey) ?? undefined;
1400
- } catch {
1401
- // Cache API unavailable
1402
- }
1465
+ if (cached && edgeConfig.isPublic && edgeConfig.fresh > 0) {
1466
+ const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1467
+ const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1468
+ const ageMs = storedAt > 0 ? Date.now() - storedAt : Infinity;
1469
+ const ageSec = ageMs / 1000;
1470
+
1471
+ if (ageSec < edgeConfig.fresh) {
1472
+ // FRESH HIT — serve immediately
1473
+ return dressResponse(cached, "HIT");
1403
1474
  }
1404
1475
 
1405
- if (cached && edgeConfig.isPublic && edgeConfig.fresh > 0) {
1406
- const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1407
- const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1408
- const ageMs = storedAt > 0 ? Date.now() - storedAt : Infinity;
1409
- const ageSec = ageMs / 1000;
1476
+ if (ageSec < edgeConfig.fresh + edgeConfig.swr) {
1477
+ // STALE-HIT within SWR window — serve stale, revalidate in background
1478
+ revalidateInBackground();
1479
+ return dressResponse(cached, "STALE-HIT", { "X-Cache-Age": String(Math.round(ageSec)) });
1480
+ }
1410
1481
 
1411
- if (ageSec < edgeConfig.fresh) {
1412
- // FRESH HIT serve immediately
1413
- return dressResponse(cached, "HIT");
1414
- }
1482
+ // Past SWR window but still in cache (within SIE window) — keep reference
1483
+ // for potential error fallback below
1484
+ }
1415
1485
 
1416
- if (ageSec < edgeConfig.fresh + edgeConfig.swr) {
1417
- // STALE-HIT within SWR window — serve stale, revalidate in background
1418
- revalidateInBackground();
1419
- return dressResponse(cached, "STALE-HIT", { "X-Cache-Age": String(Math.round(ageSec)) });
1486
+ // Cache MISS or past SWR window — fetch from origin
1487
+ let origin: Response;
1488
+ try {
1489
+ origin = await serverEntry.fetch(request, env, ctx);
1490
+ } catch (err) {
1491
+ // Origin fetch threw — SIE fallback if we have a stale entry
1492
+ if (cached && edgeConfig.sie > 0) {
1493
+ const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1494
+ const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1495
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1496
+ if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
1497
+ console.warn(
1498
+ `[edge-cache] Origin threw, serving stale (age=${Math.round(ageSec)}s, sie=${edgeConfig.sie}s)`,
1499
+ );
1500
+ return dressResponse(cached, "STALE-ERROR", {
1501
+ "X-Cache-Age": String(Math.round(ageSec)),
1502
+ });
1420
1503
  }
1421
-
1422
- // Past SWR window but still in cache (within SIE window) — keep reference
1423
- // for potential error fallback below
1424
1504
  }
1505
+ throw err;
1506
+ }
1425
1507
 
1426
- // Cache MISS or past SWR window — fetch from origin
1427
- let origin: Response;
1428
- try {
1429
- origin = await serverEntry.fetch(request, env, ctx);
1430
- } catch (err) {
1431
- // Origin fetch threw — SIE fallback if we have a stale entry
1508
+ if (origin.status !== 200) {
1509
+ // Non-200 origin — SIE fallback on 5xx/429
1510
+ if (origin.status >= 500 || origin.status === 429) {
1432
1511
  if (cached && edgeConfig.sie > 0) {
1433
1512
  const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1434
1513
  const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1435
1514
  const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1436
1515
  if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
1437
- console.warn(`[edge-cache] Origin threw, serving stale (age=${Math.round(ageSec)}s, sie=${edgeConfig.sie}s)`);
1438
- return dressResponse(cached, "STALE-ERROR", { "X-Cache-Age": String(Math.round(ageSec)) });
1439
- }
1440
- }
1441
- throw err;
1442
- }
1443
-
1444
- if (origin.status !== 200) {
1445
- // Non-200 origin — SIE fallback on 5xx/429
1446
- if (origin.status >= 500 || origin.status === 429) {
1447
- if (cached && edgeConfig.sie > 0) {
1448
- const storedAtStr = cached.headers.get("X-Deco-Stored-At");
1449
- const storedAt = storedAtStr ? Number(storedAtStr) : 0;
1450
- const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
1451
- if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
1452
- console.warn(`[edge-cache] Origin ${origin.status}, serving stale (age=${Math.round(ageSec)}s)`);
1453
- return dressResponse(cached, "STALE-ERROR", {
1454
- "X-Cache-Age": String(Math.round(ageSec)),
1455
- "X-Cache-Origin-Status": String(origin.status),
1456
- });
1457
- }
1516
+ console.warn(
1517
+ `[edge-cache] Origin ${origin.status}, serving stale (age=${Math.round(ageSec)}s)`,
1518
+ );
1519
+ return dressResponse(cached, "STALE-ERROR", {
1520
+ "X-Cache-Age": String(Math.round(ageSec)),
1521
+ "X-Cache-Origin-Status": String(origin.status),
1522
+ });
1458
1523
  }
1459
1524
  }
1460
- const resp = new Response(origin.body, origin);
1461
- resp.headers.set("X-Cache", "BYPASS");
1462
- resp.headers.set("X-Cache-Reason", `status:${origin.status}`);
1463
- appendResourceHints(resp);
1464
- return resp;
1465
1525
  }
1526
+ const resp = new Response(origin.body, origin);
1527
+ resp.headers.set("X-Cache", "BYPASS");
1528
+ resp.headers.set("X-Cache-Reason", `status:${origin.status}`);
1529
+ appendResourceHints(resp);
1530
+ return resp;
1531
+ }
1466
1532
 
1467
- // Responses with private Set-Cookie headers must never be cached —
1468
- // they carry per-user session/auth tokens that would leak to other users.
1469
- // Safe/public cookies (IS session, segment, etc.) are stripped from the
1470
- // cached copy but kept on the response served to the current user.
1471
- if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1472
- const resp = new Response(origin.body, origin);
1473
- resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1474
- resp.headers.delete("CDN-Cache-Control");
1475
- resp.headers.set("X-Cache", "BYPASS");
1476
- resp.headers.set("X-Cache-Reason", "private-set-cookie");
1477
- appendResourceHints(resp);
1478
- return resp;
1479
- }
1533
+ // Responses with private Set-Cookie headers must never be cached —
1534
+ // they carry per-user session/auth tokens that would leak to other users.
1535
+ // Safe/public cookies (IS session, segment, etc.) are stripped from the
1536
+ // cached copy but kept on the response served to the current user.
1537
+ if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
1538
+ const resp = new Response(origin.body, origin);
1539
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1540
+ resp.headers.delete("CDN-Cache-Control");
1541
+ resp.headers.set("X-Cache", "BYPASS");
1542
+ resp.headers.set("X-Cache-Reason", "private-set-cookie");
1543
+ appendResourceHints(resp);
1544
+ return resp;
1545
+ }
1480
1546
 
1481
- const profileConfig = getCacheProfile(profile);
1547
+ const profileConfig = getCacheProfile(profile);
1482
1548
 
1483
- if (!profileConfig.isPublic || profileConfig.edge.fresh === 0) {
1484
- const resp = new Response(origin.body, origin);
1485
- resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1486
- resp.headers.set("X-Cache", "BYPASS");
1487
- resp.headers.set("X-Cache-Reason", `profile:${profile}`);
1488
- appendResourceHints(resp);
1489
- return resp;
1490
- }
1549
+ if (!profileConfig.isPublic || profileConfig.edge.fresh === 0) {
1550
+ const resp = new Response(origin.body, origin);
1551
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1552
+ resp.headers.set("X-Cache", "BYPASS");
1553
+ resp.headers.set("X-Cache-Reason", `profile:${profile}`);
1554
+ appendResourceHints(resp);
1555
+ return resp;
1556
+ }
1491
1557
 
1492
- // Clone for cache BEFORE dressResponse consumes the body stream.
1493
- // dressResponse() calls new Response(resp.body, resp) which locks
1494
- // the ReadableStream. Calling clone() on a locked body corrupts
1495
- // the stream in Workers runtime, causing Error 1101.
1496
- // Strip safe cookies from the cached copy so they don't leak
1497
- // to other users, but the current user still gets them.
1498
- const cacheOrigin = origin.headers.has("set-cookie")
1499
- ? stripSafeCookiesForCache(origin, safeCookieSet)
1500
- : origin;
1501
- storeInCache(cacheOrigin);
1502
- return dressResponse(origin, "MISS");
1558
+ // Clone for cache BEFORE dressResponse consumes the body stream.
1559
+ // dressResponse() calls new Response(resp.body, resp) which locks
1560
+ // the ReadableStream. Calling clone() on a locked body corrupts
1561
+ // the stream in Workers runtime, causing Error 1101.
1562
+ // Strip safe cookies from the cached copy so they don't leak
1563
+ // to other users, but the current user still gets them.
1564
+ const cacheOrigin = origin.headers.has("set-cookie")
1565
+ ? stripSafeCookiesForCache(origin, safeCookieSet)
1566
+ : origin;
1567
+ storeInCache(cacheOrigin);
1568
+ return dressResponse(origin, "MISS");
1503
1569
  }
1504
1570
  }
1505
-