@decocms/start 1.2.10 → 1.3.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "1.2.10",
3
+ "version": "1.3.0",
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",
@@ -10,7 +10,7 @@
10
10
  * import { validateDeferredSectionInput } from "@decocms/start/middleware/validateSection";
11
11
  *
12
12
  * // In your storefront's middleware chain:
13
- * export const loadSection = createServerFn({ method: "POST" })
13
+ * export const loadSection = createServerFn({ method: "GET" })
14
14
  * .inputValidator(validateDeferredSectionInput)
15
15
  * .handler(async (ctx) => { ... });
16
16
  * ```
@@ -188,11 +188,12 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
188
188
  // ---------------------------------------------------------------------------
189
189
 
190
190
  /**
191
- * @deprecated Prefer TanStack native streaming via `deferredPromises` in the
192
- * route loader. This POST server function is kept for backward compatibility
193
- * and as a fallback for SPA navigations.
191
+ * Loads a single deferred CMS section on demand (IntersectionObserver lazy loading).
192
+ * Uses GET so responses benefit from standard HTTP caching at the edge/browser.
193
+ * Payload is serialized into the URL by TanStack Start (~3-4KB typical, well within
194
+ * Cloudflare Workers' 16KB URL limit).
194
195
  */
195
- export const loadDeferredSection = createServerFn({ method: "POST" })
196
+ export const loadDeferredSection = createServerFn({ method: "GET" })
196
197
  .inputValidator(
197
198
  (data: unknown) =>
198
199
  data as {
@@ -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
  }