@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
|
@@ -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: "
|
|
13
|
+
* export const loadSection = createServerFn({ method: "GET" })
|
|
14
14
|
* .inputValidator(validateDeferredSectionInput)
|
|
15
15
|
* .handler(async (ctx) => { ... });
|
|
16
16
|
* ```
|
package/src/routes/cmsRoute.ts
CHANGED
|
@@ -188,11 +188,12 @@ export const loadCmsHomePage = createServerFn({ method: "GET" }).handler(async (
|
|
|
188
188
|
// ---------------------------------------------------------------------------
|
|
189
189
|
|
|
190
190
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
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: "
|
|
196
|
+
export const loadDeferredSection = createServerFn({ method: "GET" })
|
|
196
197
|
.inputValidator(
|
|
197
198
|
(data: unknown) =>
|
|
198
199
|
data as {
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -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
|
}
|