@decocms/start 1.2.9 → 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.9",
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 {
@@ -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
 
@@ -379,7 +379,7 @@ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
379
379
  "Cross-Origin-Opener-Policy": "same-origin-allow-popups",
380
380
  };
381
381
 
382
- const DEFAULT_BYPASS_PATHS = ["/_server", "/_build", "/deco/", "/live/", "/.decofile"];
382
+ const DEFAULT_BYPASS_PATHS = ["/_build", "/deco/", "/live/", "/.decofile"];
383
383
 
384
384
  const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+$/;
385
385
 
@@ -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);
@@ -850,10 +1005,27 @@ export function createDecoWorkerEntry(
850
1005
  }
851
1006
 
852
1007
  const resp = new Response(origin.body, origin);
1008
+
1009
+ // Responses with Set-Cookie carry per-user tokens — never expose
1010
+ // them with public cache headers regardless of profile.
1011
+ if (origin.headers.has("set-cookie")) {
1012
+ resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
1013
+ resp.headers.delete("CDN-Cache-Control");
1014
+ resp.headers.set("X-Cache", "BYPASS");
1015
+ resp.headers.set("X-Cache-Reason", "set-cookie");
1016
+ return resp;
1017
+ }
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
+
853
1024
  const reason = request.method !== "GET"
854
1025
  ? `method:${request.method}`
855
1026
  : "bypass-path";
856
1027
  resp.headers.set("X-Cache", "BYPASS");
1028
+ resp.headers.set("X-Cache-Profile", profile);
857
1029
  resp.headers.set("X-Cache-Reason", reason);
858
1030
  return resp;
859
1031
  }