@decocms/start 1.3.5 → 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.5",
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";
@@ -228,6 +229,11 @@ export const loadDeferredSection = createServerFn({ method: "POST" })
228
229
  });
229
230
  const enriched = await runSingleSectionLoader(section, request);
230
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
+
231
237
  return normalizeUrlsInObject(enriched);
232
238
  });
233
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
  },
@@ -436,7 +436,7 @@ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
436
436
  "Cross-Origin-Opener-Policy": "same-origin-allow-popups",
437
437
  };
438
438
 
439
- const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile", "/_server"];
439
+ const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
440
440
 
441
441
  /**
442
442
  * Cookie names that are safe for caching — they carry anonymous/public
@@ -548,6 +548,14 @@ const IMMUTABLE_HEADERS: Record<string, string> = {
548
548
  Vary: "Accept-Encoding",
549
549
  };
550
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
+ }
551
559
 
552
560
  // ---------------------------------------------------------------------------
553
561
  // Factory
@@ -1007,6 +1015,168 @@ export function createDecoWorkerEntry(
1007
1015
  return origin;
1008
1016
  }
1009
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
+
1010
1180
  // Non-cacheable requests — pass through but protect against accidental caching
1011
1181
  if (!isCacheable(request, url)) {
1012
1182
  const origin = await serverEntry.fetch(request, env, ctx);