@decocms/start 1.2.10 → 1.3.1

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.2.10",
3
+ "version": "1.3.1",
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",
@@ -388,6 +388,15 @@ const IMMUTABLE_HEADERS: Record<string, string> = {
388
388
  Vary: "Accept-Encoding",
389
389
  };
390
390
 
391
+ /** SHA-256 hex hash of a string — used for POST body cache keys. */
392
+ async function hashText(text: string): Promise<string> {
393
+ const data = new TextEncoder().encode(text);
394
+ const buf = await crypto.subtle.digest("SHA-256", data);
395
+ return Array.from(new Uint8Array(buf))
396
+ .map((b) => b.toString(16).padStart(2, "0"))
397
+ .join("");
398
+ }
399
+
391
400
  // ---------------------------------------------------------------------------
392
401
  // Factory
393
402
  // ---------------------------------------------------------------------------
@@ -835,6 +844,152 @@ export function createDecoWorkerEntry(
835
844
  return origin;
836
845
  }
837
846
 
847
+ // -----------------------------------------------------------------
848
+ // POST _serverFn — edge-cacheable using body-hash as cache key.
849
+ // These carry public CMS section data (shelves, deferred sections)
850
+ // that benefits from edge caching despite being POST requests.
851
+ // -----------------------------------------------------------------
852
+ if (
853
+ request.method === "POST" &&
854
+ (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
855
+ ) {
856
+ const serverFnCache =
857
+ typeof caches !== "undefined"
858
+ ? ((caches as unknown as { default?: Cache }).default ?? null)
859
+ : null;
860
+
861
+ // Build segment once — used for logged-in check and cache key
862
+ const sfnSegment = buildSegment ? buildSegment(request) : undefined;
863
+
864
+ // Logged-in users always bypass — personalized content must not leak
865
+ if (sfnSegment?.loggedIn) {
866
+ const body = await request.text();
867
+ const originReq = new Request(request, { body, method: "POST" });
868
+ const origin = await serverEntry.fetch(originReq, env, ctx);
869
+ const resp = new Response(origin.body, origin);
870
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
871
+ resp.headers.set("X-Cache", "BYPASS");
872
+ resp.headers.set("X-Cache-Reason", "logged-in");
873
+ return resp;
874
+ }
875
+
876
+ // Read body once and create a cloned request for origin fetch
877
+ const body = await request.text();
878
+ const bodyHash = await hashText(body);
879
+
880
+ // Build a synthetic GET cache key from the URL + body hash + segment
881
+ // Includes device, salesChannel, regionId, flags — so users in
882
+ // different regions or channels get separate cache entries.
883
+ const cacheKeyUrl = new URL(request.url);
884
+ cacheKeyUrl.searchParams.set("__body", bodyHash);
885
+ if (cacheVersionEnv !== false) {
886
+ const version = (env[cacheVersionEnv] as string) || "";
887
+ if (version) cacheKeyUrl.searchParams.set("__v", version);
888
+ }
889
+ if (sfnSegment) {
890
+ cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
891
+ } else if (deviceSpecificKeys) {
892
+ const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
893
+ cacheKeyUrl.searchParams.set("__cf_device", device);
894
+ }
895
+ // Include CF geo data so location-based content doesn't leak across geos
896
+ const cf = (request as unknown as { cf?: Record<string, string> }).cf;
897
+ if (cf) {
898
+ const geoParts: string[] = [];
899
+ if (cf.country) geoParts.push(cf.country);
900
+ if (cf.region) geoParts.push(cf.region);
901
+ if (cf.city) geoParts.push(cf.city);
902
+ if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
903
+ }
904
+ const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
905
+
906
+ // Use "listing" profile for server function responses
907
+ const sfnProfile: CacheProfileName = "listing";
908
+ const sfnEdge = edgeCacheConfig(sfnProfile);
909
+
910
+ // Check edge cache
911
+ let sfnCached: Response | undefined;
912
+ if (serverFnCache) {
913
+ try {
914
+ sfnCached = await serverFnCache.match(sfnCacheKey) ?? undefined;
915
+ } catch { /* Cache API unavailable */ }
916
+ }
917
+
918
+ if (sfnCached && sfnEdge.fresh > 0) {
919
+ const storedAt = Number(sfnCached.headers.get("X-Deco-Stored-At") || "0");
920
+ const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
921
+
922
+ if (ageSec < sfnEdge.fresh) {
923
+ const out = new Response(sfnCached.body, sfnCached);
924
+ const hdrs = cacheHeaders(sfnProfile);
925
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
926
+ out.headers.set("X-Cache", "HIT");
927
+ out.headers.set("X-Cache-Profile", sfnProfile);
928
+ return out;
929
+ }
930
+
931
+ if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
932
+ // Stale-while-revalidate: serve stale, refresh in background
933
+ ctx.waitUntil(
934
+ (async () => {
935
+ try {
936
+ const bgReq = new Request(request, { body, method: "POST" });
937
+ const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
938
+ if (bgOrigin.status === 200 && !bgOrigin.headers.has("set-cookie") && serverFnCache) {
939
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
940
+ const toStore = bgOrigin.clone();
941
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
942
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
943
+ toStore.headers.delete("CDN-Cache-Control");
944
+ await serverFnCache.put(sfnCacheKey, toStore);
945
+ }
946
+ } catch { /* background revalidation failed */ }
947
+ })(),
948
+ );
949
+ const out = new Response(sfnCached.body, sfnCached);
950
+ const hdrs = cacheHeaders(sfnProfile);
951
+ for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
952
+ out.headers.set("X-Cache", "STALE-HIT");
953
+ out.headers.set("X-Cache-Profile", sfnProfile);
954
+ out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
955
+ return out;
956
+ }
957
+ }
958
+
959
+ // Cache MISS — fetch origin with the body we already read
960
+ const originReq = new Request(request, { body, method: "POST" });
961
+ const origin = await serverEntry.fetch(originReq, env, ctx);
962
+
963
+ // Never cache responses with Set-Cookie (cart/auth)
964
+ if (origin.headers.has("set-cookie")) {
965
+ const resp = new Response(origin.body, origin);
966
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
967
+ resp.headers.delete("CDN-Cache-Control");
968
+ resp.headers.set("X-Cache", "BYPASS");
969
+ resp.headers.set("X-Cache-Reason", "set-cookie");
970
+ return resp;
971
+ }
972
+
973
+ // Store in edge cache
974
+ if (origin.status === 200 && serverFnCache) {
975
+ try {
976
+ const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
977
+ const toStore = origin.clone();
978
+ toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
979
+ toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
980
+ toStore.headers.delete("CDN-Cache-Control");
981
+ ctx.waitUntil(serverFnCache.put(sfnCacheKey, toStore));
982
+ } catch { /* Cache API unavailable */ }
983
+ }
984
+
985
+ const resp = new Response(origin.body, origin);
986
+ const hdrs = cacheHeaders(sfnProfile);
987
+ for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
988
+ resp.headers.set("X-Cache", "MISS");
989
+ resp.headers.set("X-Cache-Profile", sfnProfile);
990
+ return resp;
991
+ }
992
+
838
993
  // Non-cacheable requests — pass through but protect against accidental caching
839
994
  if (!isCacheable(request, url)) {
840
995
  const origin = await serverEntry.fetch(request, env, ctx);
@@ -861,10 +1016,16 @@ export function createDecoWorkerEntry(
861
1016
  return resp;
862
1017
  }
863
1018
 
1019
+ // Set cache headers from the detected profile so the response
1020
+ // is explicit about cacheability (avoids ambiguous empty header).
1021
+ const hdrsNc = cacheHeaders(profile);
1022
+ for (const [k, v] of Object.entries(hdrsNc)) resp.headers.set(k, v);
1023
+
864
1024
  const reason = request.method !== "GET"
865
1025
  ? `method:${request.method}`
866
1026
  : "bypass-path";
867
1027
  resp.headers.set("X-Cache", "BYPASS");
1028
+ resp.headers.set("X-Cache-Profile", profile);
868
1029
  resp.headers.set("X-Cache-Reason", reason);
869
1030
  return resp;
870
1031
  }