@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
|
@@ -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/cacheHeaders.ts
CHANGED
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -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 `/
|
|
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 = ["/
|
|
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
|
}
|