@decocms/start 2.28.1 → 2.29.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/README.md +178 -79
- package/package.json +16 -14
- package/src/cms/resolve.ts +81 -63
- package/src/cms/sectionLoaders.ts +11 -0
- package/src/index.ts +3 -0
- package/src/sdk/cachedLoader.ts +36 -13
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/instrumentedFetch.ts +56 -0
- package/src/sdk/logger.test.ts +135 -0
- package/src/sdk/logger.ts +166 -0
- package/src/sdk/observability.ts +75 -0
- package/src/sdk/otel.test.ts +59 -0
- package/src/sdk/otel.ts +270 -29
- package/src/sdk/otelAdapters.test.ts +135 -0
- package/src/sdk/otelAdapters.ts +401 -0
- package/src/sdk/sampler.test.ts +127 -0
- package/src/sdk/sampler.ts +183 -0
- package/src/sdk/workerEntry.ts +541 -476
- package/src/vite/plugin.js +6 -3
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
* ```
|
|
26
26
|
*/
|
|
27
27
|
|
|
28
|
+
import { getRenderShellConfig } from "../admin/setup";
|
|
29
|
+
import { loadBlocks } from "../cms/loader";
|
|
30
|
+
import type { MatcherContext } from "../cms/resolve";
|
|
31
|
+
import { resolveDecoPage } from "../cms/resolve";
|
|
32
|
+
import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
|
|
33
|
+
import { logRequest, recordRequestMetric, withTracing } from "../middleware/observability";
|
|
28
34
|
import {
|
|
29
35
|
type CacheProfileName,
|
|
30
36
|
cacheHeaders,
|
|
@@ -33,15 +39,11 @@ import {
|
|
|
33
39
|
getCacheProfile,
|
|
34
40
|
} from "./cacheHeaders";
|
|
35
41
|
import { buildHtmlShell } from "./htmlShell";
|
|
36
|
-
import {
|
|
37
|
-
import { type Device, isMobileUA } from "./useDevice";
|
|
38
|
-
import { getRenderShellConfig } from "../admin/setup";
|
|
42
|
+
import { setRuntimeEnv } from "./otelAdapters";
|
|
39
43
|
import { RequestContext } from "./requestContext";
|
|
40
44
|
import { getAppMiddleware } from "./setupApps";
|
|
41
|
-
import
|
|
42
|
-
import {
|
|
43
|
-
import { runSectionLoaders, runSingleSectionLoader } from "../cms/sectionLoaders";
|
|
44
|
-
import { loadBlocks } from "../cms/loader";
|
|
45
|
+
import { cleanPathForCacheKey } from "./urlUtils";
|
|
46
|
+
import { type Device, isMobileUA } from "./useDevice";
|
|
45
47
|
|
|
46
48
|
/**
|
|
47
49
|
* Append Link preload headers for CSS and fonts so the browser starts
|
|
@@ -671,7 +673,10 @@ export function createDecoWorkerEntry(
|
|
|
671
673
|
return parts.join("|");
|
|
672
674
|
}
|
|
673
675
|
|
|
674
|
-
function buildCacheKey(
|
|
676
|
+
function buildCacheKey(
|
|
677
|
+
request: Request,
|
|
678
|
+
env: Record<string, unknown>,
|
|
679
|
+
): { key: Request; segment?: SegmentKey } {
|
|
675
680
|
const url = new URL(request.url);
|
|
676
681
|
|
|
677
682
|
if (shouldStripTracking) {
|
|
@@ -795,7 +800,9 @@ export function createDecoWorkerEntry(
|
|
|
795
800
|
const key = new Request(url.toString(), { method: "GET" });
|
|
796
801
|
try {
|
|
797
802
|
if (await cache.delete(key)) {
|
|
798
|
-
const label = cc
|
|
803
|
+
const label = cc
|
|
804
|
+
? `${p} (${hashSegment(seg)}, ${cc})`
|
|
805
|
+
: `${p} (${hashSegment(seg)})`;
|
|
799
806
|
purged.push(label);
|
|
800
807
|
}
|
|
801
808
|
} catch {
|
|
@@ -924,6 +931,10 @@ export function createDecoWorkerEntry(
|
|
|
924
931
|
env: Record<string, unknown>,
|
|
925
932
|
ctx: WorkerExecutionContext,
|
|
926
933
|
): Promise<Response> {
|
|
934
|
+
const startedAt = performance.now();
|
|
935
|
+
const reqUrl = new URL(request.url);
|
|
936
|
+
const method = request.method;
|
|
937
|
+
|
|
927
938
|
// Inject CF geo data as cookies for location matchers (before anything reads cookies)
|
|
928
939
|
if (geoOpt) {
|
|
929
940
|
request = injectGeoCookies(request);
|
|
@@ -933,20 +944,55 @@ export function createDecoWorkerEntry(
|
|
|
933
944
|
// in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
|
|
934
945
|
// can access the request and write response headers.
|
|
935
946
|
const response = await RequestContext.run(request, async () => {
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
947
|
+
// Stash env so request-scoped adapters (Workers Analytics Engine,
|
|
948
|
+
// future binding-driven destinations) can resolve their bindings
|
|
949
|
+
// via getRuntimeEnv() in sdk/otelAdapters.ts.
|
|
950
|
+
setRuntimeEnv(env);
|
|
951
|
+
|
|
952
|
+
// Wrap inner handler in a single root span. `@microlabs/otel-cf-workers`
|
|
953
|
+
// already creates an outer span via its `instrument()` wrapper; this
|
|
954
|
+
// adds a nested span carrying our normalized path/status attributes
|
|
955
|
+
// that microlabs doesn't capture (it uses url.path verbatim).
|
|
956
|
+
return withTracing(
|
|
957
|
+
"deco.http.request",
|
|
958
|
+
async () => {
|
|
959
|
+
// Run app middleware (injects app state into RequestContext.bag,
|
|
960
|
+
// runs registered middleware like VTEX cookie forwarding).
|
|
961
|
+
const appMw = getAppMiddleware();
|
|
962
|
+
if (appMw) {
|
|
963
|
+
return appMw(request, () => handleRequest(request, env, ctx));
|
|
964
|
+
}
|
|
965
|
+
return handleRequest(request, env, ctx);
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
"http.method": method,
|
|
969
|
+
"url.path": reqUrl.pathname,
|
|
970
|
+
},
|
|
971
|
+
);
|
|
943
972
|
});
|
|
944
973
|
|
|
945
974
|
// Deduplicate Set-Cookie headers — multiple layers (VTEX middleware,
|
|
946
975
|
// invoke handlers, etc.) may independently append the same cookie.
|
|
947
976
|
deduplicateSetCookies(response);
|
|
948
977
|
|
|
949
|
-
|
|
978
|
+
const finalResponse = applySecurityHeaders(response);
|
|
979
|
+
|
|
980
|
+
// Metrics + structured request log. Done after security headers so
|
|
981
|
+
// the recorded status reflects what the client actually receives.
|
|
982
|
+
// Both calls are no-ops when no meter / logger is configured.
|
|
983
|
+
const durationMs = performance.now() - startedAt;
|
|
984
|
+
try {
|
|
985
|
+
recordRequestMetric(method, reqUrl.pathname, finalResponse.status, durationMs);
|
|
986
|
+
} catch {
|
|
987
|
+
/* swallow — observability must never fail the request */
|
|
988
|
+
}
|
|
989
|
+
try {
|
|
990
|
+
logRequest(request, finalResponse.status, durationMs);
|
|
991
|
+
} catch {
|
|
992
|
+
/* swallow */
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
return finalResponse;
|
|
950
996
|
},
|
|
951
997
|
};
|
|
952
998
|
|
|
@@ -957,425 +1003,438 @@ export function createDecoWorkerEntry(
|
|
|
957
1003
|
env: Record<string, unknown>,
|
|
958
1004
|
ctx: WorkerExecutionContext,
|
|
959
1005
|
): Promise<Response> {
|
|
960
|
-
|
|
1006
|
+
const url = new URL(request.url);
|
|
1007
|
+
|
|
1008
|
+
// Admin routes (/_meta, /.decofile, /live/previews) — always handled first
|
|
1009
|
+
const adminResponse = await tryAdminRoute(request);
|
|
1010
|
+
if (adminResponse) return adminResponse;
|
|
961
1011
|
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
1012
|
+
// Purge endpoint
|
|
1013
|
+
if (url.pathname === "/_cache/purge" && request.method === "POST") {
|
|
1014
|
+
return handlePurge(request, env);
|
|
1015
|
+
}
|
|
965
1016
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1017
|
+
// ?asJson — return resolved page data as JSON (legacy deco compat)
|
|
1018
|
+
if (url.searchParams.has("asJson") && request.method === "GET") {
|
|
1019
|
+
const basePath = url.pathname;
|
|
1020
|
+
const cookies: Record<string, string> = {};
|
|
1021
|
+
for (const pair of (request.headers.get("cookie") ?? "").split(";")) {
|
|
1022
|
+
const [k, ...v] = pair.split("=");
|
|
1023
|
+
if (k?.trim()) cookies[k.trim()] = v.join("=").trim();
|
|
969
1024
|
}
|
|
1025
|
+
const matcherCtx: MatcherContext = {
|
|
1026
|
+
userAgent: request.headers.get("user-agent") ?? "",
|
|
1027
|
+
url: url.toString(),
|
|
1028
|
+
path: basePath,
|
|
1029
|
+
cookies,
|
|
1030
|
+
request,
|
|
1031
|
+
};
|
|
1032
|
+
const page = await resolveDecoPage(basePath, matcherCtx);
|
|
1033
|
+
if (!page) {
|
|
1034
|
+
return Response.json(null, {
|
|
1035
|
+
status: 404,
|
|
1036
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
const enrichedSections = await runSectionLoaders(page.resolvedSections, request);
|
|
970
1040
|
|
|
971
|
-
//
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
}
|
|
979
|
-
const matcherCtx: MatcherContext = {
|
|
980
|
-
userAgent: request.headers.get("user-agent") ?? "",
|
|
981
|
-
url: url.toString(),
|
|
982
|
-
path: basePath,
|
|
983
|
-
cookies,
|
|
984
|
-
request,
|
|
985
|
-
};
|
|
986
|
-
const page = await resolveDecoPage(basePath, matcherCtx);
|
|
987
|
-
if (!page) {
|
|
988
|
-
return Response.json(null, { status: 404, headers: { "Access-Control-Allow-Origin": "*" } });
|
|
989
|
-
}
|
|
990
|
-
const enrichedSections = await runSectionLoaders(page.resolvedSections, request);
|
|
991
|
-
|
|
992
|
-
// Run SEO section loader if registered
|
|
993
|
-
let seoResult = page.seoSection;
|
|
994
|
-
if (seoResult) {
|
|
995
|
-
try {
|
|
996
|
-
seoResult = await runSingleSectionLoader(seoResult, request);
|
|
997
|
-
} catch {
|
|
998
|
-
// use unloaded seoSection
|
|
999
|
-
}
|
|
1041
|
+
// Run SEO section loader if registered
|
|
1042
|
+
let seoResult = page.seoSection;
|
|
1043
|
+
if (seoResult) {
|
|
1044
|
+
try {
|
|
1045
|
+
seoResult = await runSingleSectionLoader(seoResult, request);
|
|
1046
|
+
} catch {
|
|
1047
|
+
// use unloaded seoSection
|
|
1000
1048
|
}
|
|
1049
|
+
}
|
|
1001
1050
|
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
},
|
|
1051
|
+
// Merge site-wide SEO defaults into seo props
|
|
1052
|
+
const blocks = loadBlocks();
|
|
1053
|
+
const site = blocks["Site"] as Record<string, unknown> | undefined;
|
|
1054
|
+
const fullSiteSeo = (site?.seo as Record<string, unknown>) ?? {};
|
|
1055
|
+
|
|
1056
|
+
// When SeoV2 loader ran, use its output as base (preserves key order)
|
|
1057
|
+
// and only fill in missing fields from the site-wide SEO config.
|
|
1058
|
+
const loaderProps = seoResult?.props ?? {};
|
|
1059
|
+
const seoProps: Record<string, unknown> = { ...loaderProps };
|
|
1060
|
+
for (const [k, v] of Object.entries(fullSiteSeo)) {
|
|
1061
|
+
if (!(k in seoProps)) seoProps[k] = v;
|
|
1062
|
+
}
|
|
1063
|
+
// Strip internal template fields
|
|
1064
|
+
delete seoProps.titleTemplate;
|
|
1065
|
+
delete seoProps.descriptionTemplate;
|
|
1066
|
+
|
|
1067
|
+
// Build resolveChain statically to match legacy deco-cx/deco format.
|
|
1068
|
+
type FieldResolver = { type: string; value: string | number };
|
|
1069
|
+
const rawKey = page.blockKey ?? `pages-${page.name}`;
|
|
1070
|
+
const encodedKey = rawKey.replace(
|
|
1071
|
+
/^(pages-)(.+)$/,
|
|
1072
|
+
(_m, prefix, rest) => prefix + encodeURIComponent(rest),
|
|
1073
|
+
);
|
|
1074
|
+
const pageChain: FieldResolver[] = [
|
|
1075
|
+
{ type: "resolver", value: "website/handlers/fresh.ts" },
|
|
1076
|
+
{ type: "prop", value: "page" },
|
|
1077
|
+
{ type: "resolver", value: "resolved" },
|
|
1078
|
+
{ type: "resolvable", value: encodedKey },
|
|
1079
|
+
{ type: "resolver", value: "website/pages/Page.tsx" },
|
|
1080
|
+
];
|
|
1081
|
+
|
|
1082
|
+
const seoChain: FieldResolver[] = [
|
|
1083
|
+
...pageChain,
|
|
1084
|
+
{ type: "prop", value: "seo" },
|
|
1085
|
+
{ type: "resolver", value: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx" },
|
|
1086
|
+
];
|
|
1087
|
+
|
|
1088
|
+
const result = {
|
|
1089
|
+
props: {
|
|
1090
|
+
name: page.name,
|
|
1091
|
+
path: page.path,
|
|
1092
|
+
seo: {
|
|
1093
|
+
props: seoProps,
|
|
1094
|
+
metadata: {
|
|
1095
|
+
resolveChain: seoChain,
|
|
1096
|
+
component: seoResult?.component ?? "website/sections/Seo/SeoV2.tsx",
|
|
1049
1097
|
},
|
|
1050
|
-
sections: enrichedSections.map((s, i) => ({
|
|
1051
|
-
props: s.props,
|
|
1052
|
-
metadata: {
|
|
1053
|
-
resolveChain: [
|
|
1054
|
-
...pageChain,
|
|
1055
|
-
{ type: "prop", value: "sections" },
|
|
1056
|
-
{ type: "prop", value: String(i) },
|
|
1057
|
-
{ type: "resolver", value: s.component },
|
|
1058
|
-
],
|
|
1059
|
-
component: s.component,
|
|
1060
|
-
},
|
|
1061
|
-
})),
|
|
1062
|
-
devMode: false,
|
|
1063
|
-
unindexedDomain: false,
|
|
1064
1098
|
},
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1099
|
+
sections: enrichedSections.map((s, i) => ({
|
|
1100
|
+
props: s.props,
|
|
1101
|
+
metadata: {
|
|
1102
|
+
resolveChain: [
|
|
1103
|
+
...pageChain,
|
|
1104
|
+
{ type: "prop", value: "sections" },
|
|
1105
|
+
{ type: "prop", value: String(i) },
|
|
1106
|
+
{ type: "resolver", value: s.component },
|
|
1107
|
+
],
|
|
1108
|
+
component: s.component,
|
|
1109
|
+
},
|
|
1110
|
+
})),
|
|
1111
|
+
devMode: false,
|
|
1112
|
+
unindexedDomain: false,
|
|
1113
|
+
},
|
|
1114
|
+
metadata: {
|
|
1115
|
+
resolveChain: pageChain,
|
|
1116
|
+
component: "website/pages/Page.tsx",
|
|
1117
|
+
},
|
|
1118
|
+
};
|
|
1119
|
+
return Response.json(result, {
|
|
1120
|
+
headers: {
|
|
1121
|
+
"Access-Control-Allow-Origin": "*",
|
|
1122
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
1123
|
+
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
|
1124
|
+
},
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1078
1127
|
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1128
|
+
// Commerce proxy (checkout, account, API, etc.)
|
|
1129
|
+
if (options.proxyHandler) {
|
|
1130
|
+
const proxyResponse = await options.proxyHandler(request, url);
|
|
1131
|
+
if (proxyResponse) return proxyResponse;
|
|
1132
|
+
}
|
|
1084
1133
|
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
}
|
|
1093
|
-
const resp = new Response(origin.body, origin);
|
|
1094
|
-
for (const [k, v] of Object.entries(IMMUTABLE_HEADERS)) {
|
|
1095
|
-
resp.headers.set(k, v);
|
|
1096
|
-
}
|
|
1097
|
-
return resp;
|
|
1134
|
+
// Static fingerprinted assets — serve from origin with immutable headers
|
|
1135
|
+
if (isStaticAsset(url.pathname)) {
|
|
1136
|
+
const origin = await serverEntry.fetch(request, env, ctx);
|
|
1137
|
+
if (origin.status === 200) {
|
|
1138
|
+
const ct = origin.headers.get("content-type") ?? "";
|
|
1139
|
+
if (ct.includes("text/html")) {
|
|
1140
|
+
return new Response("Not Found", { status: 404 });
|
|
1098
1141
|
}
|
|
1099
|
-
|
|
1142
|
+
const resp = new Response(origin.body, origin);
|
|
1143
|
+
for (const [k, v] of Object.entries(IMMUTABLE_HEADERS)) {
|
|
1144
|
+
resp.headers.set(k, v);
|
|
1145
|
+
}
|
|
1146
|
+
return resp;
|
|
1100
1147
|
}
|
|
1148
|
+
return origin;
|
|
1149
|
+
}
|
|
1101
1150
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
// Build segment once — used for logged-in check and cache key
|
|
1117
|
-
const sfnSegment = buildSegment ? buildSegment(request) : undefined;
|
|
1118
|
-
|
|
1119
|
-
// Logged-in users always bypass — personalized content must not leak
|
|
1120
|
-
if (sfnSegment?.loggedIn) {
|
|
1121
|
-
const origin = await serverEntry.fetch(request, env, ctx);
|
|
1122
|
-
const resp = new Response(origin.body, origin);
|
|
1123
|
-
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1124
|
-
resp.headers.set("X-Cache", "BYPASS");
|
|
1125
|
-
resp.headers.set("X-Cache-Reason", "logged-in");
|
|
1126
|
-
return resp;
|
|
1127
|
-
}
|
|
1151
|
+
// -----------------------------------------------------------------
|
|
1152
|
+
// POST _serverFn — edge-cacheable using body-hash as cache key.
|
|
1153
|
+
// These carry public CMS section data (shelves, deferred sections)
|
|
1154
|
+
// that benefits from edge caching despite being POST requests.
|
|
1155
|
+
// -----------------------------------------------------------------
|
|
1156
|
+
if (
|
|
1157
|
+
request.method === "POST" &&
|
|
1158
|
+
(url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/"))
|
|
1159
|
+
) {
|
|
1160
|
+
const serverFnCache =
|
|
1161
|
+
typeof caches !== "undefined"
|
|
1162
|
+
? ((caches as unknown as { default?: Cache }).default ?? null)
|
|
1163
|
+
: null;
|
|
1128
1164
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
// work correctly. We only read the body for the cache key hash.
|
|
1132
|
-
const originClone = request.clone();
|
|
1133
|
-
const body = await request.text();
|
|
1134
|
-
const bodyHash = await hashText(body);
|
|
1135
|
-
|
|
1136
|
-
// Build a synthetic GET cache key from the URL + body hash + segment
|
|
1137
|
-
// Includes device, salesChannel, regionId, flags — so users in
|
|
1138
|
-
// different regions or channels get separate cache entries.
|
|
1139
|
-
const cacheKeyUrl = new URL(request.url);
|
|
1140
|
-
cacheKeyUrl.searchParams.set("__body", bodyHash);
|
|
1141
|
-
if (cacheVersionEnv !== false) {
|
|
1142
|
-
const version = (env[cacheVersionEnv] as string) || "";
|
|
1143
|
-
if (version) cacheKeyUrl.searchParams.set("__v", version);
|
|
1144
|
-
}
|
|
1145
|
-
if (sfnSegment) {
|
|
1146
|
-
cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
|
|
1147
|
-
} else if (deviceSpecificKeys) {
|
|
1148
|
-
const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
|
|
1149
|
-
cacheKeyUrl.searchParams.set("__cf_device", device);
|
|
1150
|
-
}
|
|
1151
|
-
// Include CF geo data so location-based content doesn't leak across geos
|
|
1152
|
-
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
|
|
1153
|
-
if (cf) {
|
|
1154
|
-
const geoParts: string[] = [];
|
|
1155
|
-
if (cf.country) geoParts.push(cf.country);
|
|
1156
|
-
if (cf.region) geoParts.push(cf.region);
|
|
1157
|
-
if (cf.city) geoParts.push(cf.city);
|
|
1158
|
-
if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
|
|
1159
|
-
}
|
|
1160
|
-
const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
|
|
1161
|
-
|
|
1162
|
-
// Use "listing" profile for server function responses
|
|
1163
|
-
const sfnProfile: CacheProfileName = "listing";
|
|
1164
|
-
const sfnEdge = edgeCacheConfig(sfnProfile);
|
|
1165
|
-
|
|
1166
|
-
// Check edge cache
|
|
1167
|
-
let sfnCached: Response | undefined;
|
|
1168
|
-
if (serverFnCache) {
|
|
1169
|
-
try {
|
|
1170
|
-
sfnCached = await serverFnCache.match(sfnCacheKey) ?? undefined;
|
|
1171
|
-
} catch { /* Cache API unavailable */ }
|
|
1172
|
-
}
|
|
1165
|
+
// Build segment once — used for logged-in check and cache key
|
|
1166
|
+
const sfnSegment = buildSegment ? buildSegment(request) : undefined;
|
|
1173
1167
|
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1168
|
+
// Logged-in users always bypass — personalized content must not leak
|
|
1169
|
+
if (sfnSegment?.loggedIn) {
|
|
1170
|
+
const origin = await serverEntry.fetch(request, env, ctx);
|
|
1171
|
+
const resp = new Response(origin.body, origin);
|
|
1172
|
+
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1173
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1174
|
+
resp.headers.set("X-Cache-Reason", "logged-in");
|
|
1175
|
+
return resp;
|
|
1176
|
+
}
|
|
1177
1177
|
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1178
|
+
// Clone request before consuming body — the clone goes to origin
|
|
1179
|
+
// untouched so TanStack Start internals (cookie passthrough, etc.)
|
|
1180
|
+
// work correctly. We only read the body for the cache key hash.
|
|
1181
|
+
const originClone = request.clone();
|
|
1182
|
+
const body = await request.text();
|
|
1183
|
+
const bodyHash = await hashText(body);
|
|
1184
|
+
|
|
1185
|
+
// Build a synthetic GET cache key from the URL + body hash + segment
|
|
1186
|
+
// Includes device, salesChannel, regionId, flags — so users in
|
|
1187
|
+
// different regions or channels get separate cache entries.
|
|
1188
|
+
const cacheKeyUrl = new URL(request.url);
|
|
1189
|
+
cacheKeyUrl.searchParams.set("__body", bodyHash);
|
|
1190
|
+
if (cacheVersionEnv !== false) {
|
|
1191
|
+
const version = (env[cacheVersionEnv] as string) || "";
|
|
1192
|
+
if (version) cacheKeyUrl.searchParams.set("__v", version);
|
|
1193
|
+
}
|
|
1194
|
+
if (sfnSegment) {
|
|
1195
|
+
cacheKeyUrl.searchParams.set("__seg", hashSegment(sfnSegment));
|
|
1196
|
+
} else if (deviceSpecificKeys) {
|
|
1197
|
+
const device = isMobileUA(request.headers.get("user-agent") ?? "") ? "mobile" : "desktop";
|
|
1198
|
+
cacheKeyUrl.searchParams.set("__cf_device", device);
|
|
1199
|
+
}
|
|
1200
|
+
// Include CF geo data so location-based content doesn't leak across geos
|
|
1201
|
+
const cf = (request as unknown as { cf?: Record<string, string> }).cf;
|
|
1202
|
+
if (cf) {
|
|
1203
|
+
const geoParts: string[] = [];
|
|
1204
|
+
if (cf.country) geoParts.push(cf.country);
|
|
1205
|
+
if (cf.region) geoParts.push(cf.region);
|
|
1206
|
+
if (cf.city) geoParts.push(cf.city);
|
|
1207
|
+
if (geoParts.length) cacheKeyUrl.searchParams.set("__cf_geo", geoParts.join("|"));
|
|
1208
|
+
}
|
|
1209
|
+
const sfnCacheKey = new Request(cacheKeyUrl.toString(), { method: "GET" });
|
|
1186
1210
|
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
serverFnCache
|
|
1199
|
-
) {
|
|
1200
|
-
const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
|
|
1201
|
-
const toStore = bgOrigin.clone();
|
|
1202
|
-
toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
|
1203
|
-
toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
|
|
1204
|
-
toStore.headers.delete("CDN-Cache-Control");
|
|
1205
|
-
toStore.headers.delete("X-Deco-Cacheable");
|
|
1206
|
-
await serverFnCache.put(sfnCacheKey, toStore);
|
|
1207
|
-
}
|
|
1208
|
-
} catch { /* background revalidation failed */ }
|
|
1209
|
-
})(),
|
|
1210
|
-
);
|
|
1211
|
-
const out = new Response(sfnCached.body, sfnCached);
|
|
1212
|
-
const hdrs = cacheHeaders(sfnProfile);
|
|
1213
|
-
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
1214
|
-
out.headers.set("X-Cache", "STALE-HIT");
|
|
1215
|
-
out.headers.set("X-Cache-Profile", sfnProfile);
|
|
1216
|
-
out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
|
|
1217
|
-
return out;
|
|
1218
|
-
}
|
|
1211
|
+
// Use "listing" profile for server function responses
|
|
1212
|
+
const sfnProfile: CacheProfileName = "listing";
|
|
1213
|
+
const sfnEdge = edgeCacheConfig(sfnProfile);
|
|
1214
|
+
|
|
1215
|
+
// Check edge cache
|
|
1216
|
+
let sfnCached: Response | undefined;
|
|
1217
|
+
if (serverFnCache) {
|
|
1218
|
+
try {
|
|
1219
|
+
sfnCached = (await serverFnCache.match(sfnCacheKey)) ?? undefined;
|
|
1220
|
+
} catch {
|
|
1221
|
+
/* Cache API unavailable */
|
|
1219
1222
|
}
|
|
1223
|
+
}
|
|
1220
1224
|
|
|
1221
|
-
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
if (!isCacheableResponse) {
|
|
1233
|
-
const resp = new Response(origin.body, origin);
|
|
1234
|
-
resp.headers.delete("X-Deco-Cacheable");
|
|
1235
|
-
resp.headers.set("X-Cache", "BYPASS");
|
|
1236
|
-
resp.headers.set("X-Cache-Reason", origin.headers.has("set-cookie")
|
|
1237
|
-
? "set-cookie"
|
|
1238
|
-
: "not-cacheable");
|
|
1239
|
-
return resp;
|
|
1225
|
+
if (sfnCached && sfnEdge.fresh > 0) {
|
|
1226
|
+
const storedAt = Number(sfnCached.headers.get("X-Deco-Stored-At") || "0");
|
|
1227
|
+
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
1228
|
+
|
|
1229
|
+
if (ageSec < sfnEdge.fresh) {
|
|
1230
|
+
const out = new Response(sfnCached.body, sfnCached);
|
|
1231
|
+
const hdrs = cacheHeaders(sfnProfile);
|
|
1232
|
+
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
1233
|
+
out.headers.set("X-Cache", "HIT");
|
|
1234
|
+
out.headers.set("X-Cache-Profile", sfnProfile);
|
|
1235
|
+
return out;
|
|
1240
1236
|
}
|
|
1241
1237
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1238
|
+
if (ageSec < sfnEdge.fresh + sfnEdge.swr) {
|
|
1239
|
+
// Stale-while-revalidate: serve stale, refresh in background
|
|
1240
|
+
ctx.waitUntil(
|
|
1241
|
+
(async () => {
|
|
1242
|
+
try {
|
|
1243
|
+
const bgReq = new Request(request, { body, method: "POST" });
|
|
1244
|
+
const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
|
|
1245
|
+
if (
|
|
1246
|
+
bgOrigin.status === 200 &&
|
|
1247
|
+
bgOrigin.headers.get("X-Deco-Cacheable") === "true" &&
|
|
1248
|
+
!bgOrigin.headers.has("set-cookie") &&
|
|
1249
|
+
serverFnCache
|
|
1250
|
+
) {
|
|
1251
|
+
const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
|
|
1252
|
+
const toStore = bgOrigin.clone();
|
|
1253
|
+
toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
|
1254
|
+
toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
|
|
1255
|
+
toStore.headers.delete("CDN-Cache-Control");
|
|
1256
|
+
toStore.headers.delete("X-Deco-Cacheable");
|
|
1257
|
+
await serverFnCache.put(sfnCacheKey, toStore);
|
|
1258
|
+
}
|
|
1259
|
+
} catch {
|
|
1260
|
+
/* background revalidation failed */
|
|
1261
|
+
}
|
|
1262
|
+
})(),
|
|
1263
|
+
);
|
|
1264
|
+
const out = new Response(sfnCached.body, sfnCached);
|
|
1265
|
+
const hdrs = cacheHeaders(sfnProfile);
|
|
1266
|
+
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
1267
|
+
out.headers.set("X-Cache", "STALE-HIT");
|
|
1268
|
+
out.headers.set("X-Cache-Profile", sfnProfile);
|
|
1269
|
+
out.headers.set("X-Cache-Age", String(Math.round(ageSec)));
|
|
1270
|
+
return out;
|
|
1253
1271
|
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Cache MISS — fetch origin with the body we already read
|
|
1275
|
+
const origin = await serverEntry.fetch(originClone, env, ctx);
|
|
1276
|
+
|
|
1277
|
+
// Only cache responses explicitly marked as cacheable by the handler
|
|
1278
|
+
// (loadDeferredSection sets X-Deco-Cacheable: true). Checkout actions,
|
|
1279
|
+
// invoke mutations, and other server functions are passed through.
|
|
1280
|
+
const isCacheableResponse =
|
|
1281
|
+
origin.headers.get("X-Deco-Cacheable") === "true" &&
|
|
1282
|
+
!origin.headers.has("set-cookie") &&
|
|
1283
|
+
origin.status === 200;
|
|
1254
1284
|
|
|
1285
|
+
if (!isCacheableResponse) {
|
|
1255
1286
|
const resp = new Response(origin.body, origin);
|
|
1256
1287
|
resp.headers.delete("X-Deco-Cacheable");
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1288
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1289
|
+
resp.headers.set(
|
|
1290
|
+
"X-Cache-Reason",
|
|
1291
|
+
origin.headers.has("set-cookie") ? "set-cookie" : "not-cacheable",
|
|
1292
|
+
);
|
|
1261
1293
|
return resp;
|
|
1262
1294
|
}
|
|
1263
1295
|
|
|
1264
|
-
//
|
|
1265
|
-
if (
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1296
|
+
// Store in edge cache
|
|
1297
|
+
if (serverFnCache) {
|
|
1298
|
+
try {
|
|
1299
|
+
const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
|
|
1300
|
+
const toStore = origin.clone();
|
|
1301
|
+
toStore.headers.set("Cache-Control", `public, max-age=${ttl}`);
|
|
1302
|
+
toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
|
|
1303
|
+
toStore.headers.delete("CDN-Cache-Control");
|
|
1304
|
+
toStore.headers.delete("X-Deco-Cacheable");
|
|
1305
|
+
ctx.waitUntil(serverFnCache.put(sfnCacheKey, toStore));
|
|
1306
|
+
} catch {
|
|
1307
|
+
/* Cache API unavailable */
|
|
1276
1308
|
}
|
|
1309
|
+
}
|
|
1277
1310
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
resp.headers.set("X-Cache", "BYPASS");
|
|
1287
|
-
resp.headers.set("X-Cache-Reason", "private-set-cookie");
|
|
1288
|
-
return resp;
|
|
1289
|
-
}
|
|
1311
|
+
const resp = new Response(origin.body, origin);
|
|
1312
|
+
resp.headers.delete("X-Deco-Cacheable");
|
|
1313
|
+
const hdrs = cacheHeaders(sfnProfile);
|
|
1314
|
+
for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
|
|
1315
|
+
resp.headers.set("X-Cache", "MISS");
|
|
1316
|
+
resp.headers.set("X-Cache-Profile", sfnProfile);
|
|
1317
|
+
return resp;
|
|
1318
|
+
}
|
|
1290
1319
|
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1320
|
+
// Non-cacheable requests — pass through but protect against accidental caching
|
|
1321
|
+
if (!isCacheable(request, url)) {
|
|
1322
|
+
const origin = await serverEntry.fetch(request, env, ctx);
|
|
1323
|
+
const profile = getProfile(url);
|
|
1295
1324
|
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1325
|
+
if (profile === "private" || profile === "none" || profile === "cart") {
|
|
1326
|
+
const resp = new Response(origin.body, origin);
|
|
1327
|
+
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1328
|
+
resp.headers.delete("CDN-Cache-Control");
|
|
1299
1329
|
resp.headers.set("X-Cache", "BYPASS");
|
|
1300
|
-
resp.headers.set("X-Cache-
|
|
1301
|
-
resp.headers.set("X-Cache-Reason", reason);
|
|
1330
|
+
resp.headers.set("X-Cache-Reason", `non-cacheable:${profile}`);
|
|
1302
1331
|
return resp;
|
|
1303
1332
|
}
|
|
1304
1333
|
|
|
1305
|
-
|
|
1306
|
-
const { key: cacheKey, segment } = buildCacheKey(request, env);
|
|
1334
|
+
const resp = new Response(origin.body, origin);
|
|
1307
1335
|
|
|
1308
|
-
//
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1336
|
+
// Responses with private Set-Cookie headers carry per-user tokens —
|
|
1337
|
+
// never expose them with public cache headers.
|
|
1338
|
+
// Safe/public cookies (e.g., vtex_is_session) are allowed through.
|
|
1339
|
+
if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
|
|
1312
1340
|
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1341
|
+
resp.headers.delete("CDN-Cache-Control");
|
|
1313
1342
|
resp.headers.set("X-Cache", "BYPASS");
|
|
1314
|
-
resp.headers.set("X-Cache-Reason", "
|
|
1343
|
+
resp.headers.set("X-Cache-Reason", "private-set-cookie");
|
|
1315
1344
|
return resp;
|
|
1316
1345
|
}
|
|
1317
1346
|
|
|
1318
|
-
//
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
: null;
|
|
1347
|
+
// Set cache headers from the detected profile so the response
|
|
1348
|
+
// is explicit about cacheability (avoids ambiguous empty header).
|
|
1349
|
+
const hdrsNc = cacheHeaders(profile);
|
|
1350
|
+
for (const [k, v] of Object.entries(hdrsNc)) resp.headers.set(k, v);
|
|
1323
1351
|
|
|
1324
|
-
const
|
|
1325
|
-
|
|
1352
|
+
const reason = request.method !== "GET" ? `method:${request.method}` : "bypass-path";
|
|
1353
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1354
|
+
resp.headers.set("X-Cache-Profile", profile);
|
|
1355
|
+
resp.headers.set("X-Cache-Reason", reason);
|
|
1356
|
+
return resp;
|
|
1357
|
+
}
|
|
1326
1358
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1359
|
+
// Cacheable request — build segment-aware cache key
|
|
1360
|
+
const { key: cacheKey, segment } = buildCacheKey(request, env);
|
|
1361
|
+
|
|
1362
|
+
// Logged-in users always bypass the cache (personalized content)
|
|
1363
|
+
if (segment?.loggedIn) {
|
|
1364
|
+
const origin = await serverEntry.fetch(request, env, ctx);
|
|
1365
|
+
const resp = new Response(origin.body, origin);
|
|
1366
|
+
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1367
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1368
|
+
resp.headers.set("X-Cache-Reason", "logged-in");
|
|
1369
|
+
return resp;
|
|
1370
|
+
}
|
|
1332
1371
|
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1372
|
+
// Check Cache API (may not be available in local dev / miniflare)
|
|
1373
|
+
const cache =
|
|
1374
|
+
typeof caches !== "undefined"
|
|
1375
|
+
? ((caches as unknown as { default?: Cache }).default ?? null)
|
|
1376
|
+
: null;
|
|
1377
|
+
|
|
1378
|
+
const profile = getProfile(url);
|
|
1379
|
+
const edgeConfig = edgeCacheConfig(profile);
|
|
1380
|
+
|
|
1381
|
+
// Helper: dress a response with proper client-facing headers
|
|
1382
|
+
function dressResponse(
|
|
1383
|
+
resp: Response,
|
|
1384
|
+
xCache: string,
|
|
1385
|
+
extra?: Record<string, string>,
|
|
1386
|
+
): Response {
|
|
1387
|
+
const out = new Response(resp.body, resp);
|
|
1388
|
+
const hdrs = cacheHeaders(profile);
|
|
1389
|
+
for (const [k, v] of Object.entries(hdrs)) out.headers.set(k, v);
|
|
1390
|
+
|
|
1391
|
+
// CDN-Cache-Control: controls Cloudflare's automatic CDN layer
|
|
1392
|
+
// (separate from Cache API which the worker manages directly).
|
|
1393
|
+
if (cdnCacheControlOpt === "no-store") {
|
|
1394
|
+
out.headers.set("CDN-Cache-Control", "no-store");
|
|
1395
|
+
} else if (cdnCacheControlOpt === "match-profile") {
|
|
1396
|
+
if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
|
|
1397
|
+
out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
|
|
1398
|
+
} else {
|
|
1336
1399
|
out.headers.set("CDN-Cache-Control", "no-store");
|
|
1337
|
-
} else if (cdnCacheControlOpt === "match-profile") {
|
|
1338
|
-
if (edgeConfig.isPublic && edgeConfig.fresh > 0) {
|
|
1339
|
-
out.headers.set("CDN-Cache-Control", `public, max-age=${edgeConfig.fresh}`);
|
|
1340
|
-
} else {
|
|
1341
|
-
out.headers.set("CDN-Cache-Control", "no-store");
|
|
1342
|
-
}
|
|
1343
|
-
} else if (typeof cdnCacheControlOpt === "function") {
|
|
1344
|
-
const val = cdnCacheControlOpt(profile);
|
|
1345
|
-
out.headers.set("CDN-Cache-Control", val ?? "no-store");
|
|
1346
1400
|
}
|
|
1401
|
+
} else if (typeof cdnCacheControlOpt === "function") {
|
|
1402
|
+
const val = cdnCacheControlOpt(profile);
|
|
1403
|
+
out.headers.set("CDN-Cache-Control", val ?? "no-store");
|
|
1404
|
+
}
|
|
1347
1405
|
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
}
|
|
1355
|
-
if (extra) for (const [k, v] of Object.entries(extra)) out.headers.set(k, v);
|
|
1356
|
-
appendResourceHints(out);
|
|
1357
|
-
return out;
|
|
1406
|
+
out.headers.set("X-Cache", xCache);
|
|
1407
|
+
out.headers.set("X-Cache-Profile", profile);
|
|
1408
|
+
if (segment) out.headers.set("X-Cache-Segment", hashSegment(segment));
|
|
1409
|
+
if (cacheVersionEnv !== false) {
|
|
1410
|
+
const v = (env[cacheVersionEnv] as string) || "";
|
|
1411
|
+
if (v) out.headers.set("X-Cache-Version", v);
|
|
1358
1412
|
}
|
|
1413
|
+
if (extra) for (const [k, v] of Object.entries(extra)) out.headers.set(k, v);
|
|
1414
|
+
appendResourceHints(out);
|
|
1415
|
+
return out;
|
|
1416
|
+
}
|
|
1359
1417
|
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
}
|
|
1418
|
+
// Helper: store a response in Cache API with the full retention window
|
|
1419
|
+
function storeInCache(resp: Response) {
|
|
1420
|
+
if (!cache) return;
|
|
1421
|
+
try {
|
|
1422
|
+
const storageTtl = edgeConfig.fresh + Math.max(edgeConfig.swr, edgeConfig.sie);
|
|
1423
|
+
const toStore = resp.clone();
|
|
1424
|
+
toStore.headers.set("Cache-Control", `public, max-age=${storageTtl}`);
|
|
1425
|
+
toStore.headers.set("X-Deco-Stored-At", String(Date.now()));
|
|
1426
|
+
toStore.headers.delete("CDN-Cache-Control");
|
|
1427
|
+
ctx.waitUntil(cache.put(cacheKey, toStore));
|
|
1428
|
+
} catch {
|
|
1429
|
+
// Cache API unavailable
|
|
1373
1430
|
}
|
|
1431
|
+
}
|
|
1374
1432
|
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1433
|
+
// Helper: background revalidation (fetch origin, store result)
|
|
1434
|
+
function revalidateInBackground() {
|
|
1435
|
+
ctx.waitUntil(
|
|
1436
|
+
Promise.resolve(serverEntry.fetch(request, env, ctx))
|
|
1437
|
+
.then((origin) => {
|
|
1379
1438
|
if (origin.status === 200) {
|
|
1380
1439
|
// Only cache if response has no cookies or only safe cookies.
|
|
1381
1440
|
// Strip safe cookies from the cached copy.
|
|
@@ -1386,120 +1445,126 @@ export function createDecoWorkerEntry(
|
|
|
1386
1445
|
storeInCache(cleanOrigin);
|
|
1387
1446
|
}
|
|
1388
1447
|
}
|
|
1389
|
-
})
|
|
1448
|
+
})
|
|
1449
|
+
.catch(() => {
|
|
1390
1450
|
// Background revalidation failed — stale entry stays until SIE expires
|
|
1391
1451
|
}),
|
|
1392
|
-
|
|
1452
|
+
);
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// --- Edge cache check with SWR + SIE ---
|
|
1456
|
+
let cached: Response | undefined;
|
|
1457
|
+
if (cache) {
|
|
1458
|
+
try {
|
|
1459
|
+
cached = (await cache.match(cacheKey)) ?? undefined;
|
|
1460
|
+
} catch {
|
|
1461
|
+
// Cache API unavailable
|
|
1393
1462
|
}
|
|
1463
|
+
}
|
|
1394
1464
|
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1465
|
+
if (cached && edgeConfig.isPublic && edgeConfig.fresh > 0) {
|
|
1466
|
+
const storedAtStr = cached.headers.get("X-Deco-Stored-At");
|
|
1467
|
+
const storedAt = storedAtStr ? Number(storedAtStr) : 0;
|
|
1468
|
+
const ageMs = storedAt > 0 ? Date.now() - storedAt : Infinity;
|
|
1469
|
+
const ageSec = ageMs / 1000;
|
|
1470
|
+
|
|
1471
|
+
if (ageSec < edgeConfig.fresh) {
|
|
1472
|
+
// FRESH HIT — serve immediately
|
|
1473
|
+
return dressResponse(cached, "HIT");
|
|
1403
1474
|
}
|
|
1404
1475
|
|
|
1405
|
-
if (
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1476
|
+
if (ageSec < edgeConfig.fresh + edgeConfig.swr) {
|
|
1477
|
+
// STALE-HIT within SWR window — serve stale, revalidate in background
|
|
1478
|
+
revalidateInBackground();
|
|
1479
|
+
return dressResponse(cached, "STALE-HIT", { "X-Cache-Age": String(Math.round(ageSec)) });
|
|
1480
|
+
}
|
|
1410
1481
|
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
}
|
|
1482
|
+
// Past SWR window but still in cache (within SIE window) — keep reference
|
|
1483
|
+
// for potential error fallback below
|
|
1484
|
+
}
|
|
1415
1485
|
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1486
|
+
// Cache MISS or past SWR window — fetch from origin
|
|
1487
|
+
let origin: Response;
|
|
1488
|
+
try {
|
|
1489
|
+
origin = await serverEntry.fetch(request, env, ctx);
|
|
1490
|
+
} catch (err) {
|
|
1491
|
+
// Origin fetch threw — SIE fallback if we have a stale entry
|
|
1492
|
+
if (cached && edgeConfig.sie > 0) {
|
|
1493
|
+
const storedAtStr = cached.headers.get("X-Deco-Stored-At");
|
|
1494
|
+
const storedAt = storedAtStr ? Number(storedAtStr) : 0;
|
|
1495
|
+
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
1496
|
+
if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
|
|
1497
|
+
console.warn(
|
|
1498
|
+
`[edge-cache] Origin threw, serving stale (age=${Math.round(ageSec)}s, sie=${edgeConfig.sie}s)`,
|
|
1499
|
+
);
|
|
1500
|
+
return dressResponse(cached, "STALE-ERROR", {
|
|
1501
|
+
"X-Cache-Age": String(Math.round(ageSec)),
|
|
1502
|
+
});
|
|
1420
1503
|
}
|
|
1421
|
-
|
|
1422
|
-
// Past SWR window but still in cache (within SIE window) — keep reference
|
|
1423
|
-
// for potential error fallback below
|
|
1424
1504
|
}
|
|
1505
|
+
throw err;
|
|
1506
|
+
}
|
|
1425
1507
|
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
origin = await serverEntry.fetch(request, env, ctx);
|
|
1430
|
-
} catch (err) {
|
|
1431
|
-
// Origin fetch threw — SIE fallback if we have a stale entry
|
|
1508
|
+
if (origin.status !== 200) {
|
|
1509
|
+
// Non-200 origin — SIE fallback on 5xx/429
|
|
1510
|
+
if (origin.status >= 500 || origin.status === 429) {
|
|
1432
1511
|
if (cached && edgeConfig.sie > 0) {
|
|
1433
1512
|
const storedAtStr = cached.headers.get("X-Deco-Stored-At");
|
|
1434
1513
|
const storedAt = storedAtStr ? Number(storedAtStr) : 0;
|
|
1435
1514
|
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
1436
1515
|
if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
|
|
1437
|
-
console.warn(
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
if (origin.status !== 200) {
|
|
1445
|
-
// Non-200 origin — SIE fallback on 5xx/429
|
|
1446
|
-
if (origin.status >= 500 || origin.status === 429) {
|
|
1447
|
-
if (cached && edgeConfig.sie > 0) {
|
|
1448
|
-
const storedAtStr = cached.headers.get("X-Deco-Stored-At");
|
|
1449
|
-
const storedAt = storedAtStr ? Number(storedAtStr) : 0;
|
|
1450
|
-
const ageSec = storedAt > 0 ? (Date.now() - storedAt) / 1000 : Infinity;
|
|
1451
|
-
if (ageSec < edgeConfig.fresh + edgeConfig.sie) {
|
|
1452
|
-
console.warn(`[edge-cache] Origin ${origin.status}, serving stale (age=${Math.round(ageSec)}s)`);
|
|
1453
|
-
return dressResponse(cached, "STALE-ERROR", {
|
|
1454
|
-
"X-Cache-Age": String(Math.round(ageSec)),
|
|
1455
|
-
"X-Cache-Origin-Status": String(origin.status),
|
|
1456
|
-
});
|
|
1457
|
-
}
|
|
1516
|
+
console.warn(
|
|
1517
|
+
`[edge-cache] Origin ${origin.status}, serving stale (age=${Math.round(ageSec)}s)`,
|
|
1518
|
+
);
|
|
1519
|
+
return dressResponse(cached, "STALE-ERROR", {
|
|
1520
|
+
"X-Cache-Age": String(Math.round(ageSec)),
|
|
1521
|
+
"X-Cache-Origin-Status": String(origin.status),
|
|
1522
|
+
});
|
|
1458
1523
|
}
|
|
1459
1524
|
}
|
|
1460
|
-
const resp = new Response(origin.body, origin);
|
|
1461
|
-
resp.headers.set("X-Cache", "BYPASS");
|
|
1462
|
-
resp.headers.set("X-Cache-Reason", `status:${origin.status}`);
|
|
1463
|
-
appendResourceHints(resp);
|
|
1464
|
-
return resp;
|
|
1465
1525
|
}
|
|
1526
|
+
const resp = new Response(origin.body, origin);
|
|
1527
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1528
|
+
resp.headers.set("X-Cache-Reason", `status:${origin.status}`);
|
|
1529
|
+
appendResourceHints(resp);
|
|
1530
|
+
return resp;
|
|
1531
|
+
}
|
|
1466
1532
|
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1533
|
+
// Responses with private Set-Cookie headers must never be cached —
|
|
1534
|
+
// they carry per-user session/auth tokens that would leak to other users.
|
|
1535
|
+
// Safe/public cookies (IS session, segment, etc.) are stripped from the
|
|
1536
|
+
// cached copy but kept on the response served to the current user.
|
|
1537
|
+
if (origin.headers.has("set-cookie") && !hasOnlySafeCookies(origin, safeCookieSet)) {
|
|
1538
|
+
const resp = new Response(origin.body, origin);
|
|
1539
|
+
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1540
|
+
resp.headers.delete("CDN-Cache-Control");
|
|
1541
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1542
|
+
resp.headers.set("X-Cache-Reason", "private-set-cookie");
|
|
1543
|
+
appendResourceHints(resp);
|
|
1544
|
+
return resp;
|
|
1545
|
+
}
|
|
1480
1546
|
|
|
1481
|
-
|
|
1547
|
+
const profileConfig = getCacheProfile(profile);
|
|
1482
1548
|
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1549
|
+
if (!profileConfig.isPublic || profileConfig.edge.fresh === 0) {
|
|
1550
|
+
const resp = new Response(origin.body, origin);
|
|
1551
|
+
resp.headers.set("Cache-Control", "private, no-cache, no-store, must-revalidate");
|
|
1552
|
+
resp.headers.set("X-Cache", "BYPASS");
|
|
1553
|
+
resp.headers.set("X-Cache-Reason", `profile:${profile}`);
|
|
1554
|
+
appendResourceHints(resp);
|
|
1555
|
+
return resp;
|
|
1556
|
+
}
|
|
1491
1557
|
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1558
|
+
// Clone for cache BEFORE dressResponse consumes the body stream.
|
|
1559
|
+
// dressResponse() calls new Response(resp.body, resp) which locks
|
|
1560
|
+
// the ReadableStream. Calling clone() on a locked body corrupts
|
|
1561
|
+
// the stream in Workers runtime, causing Error 1101.
|
|
1562
|
+
// Strip safe cookies from the cached copy so they don't leak
|
|
1563
|
+
// to other users, but the current user still gets them.
|
|
1564
|
+
const cacheOrigin = origin.headers.has("set-cookie")
|
|
1565
|
+
? stripSafeCookiesForCache(origin, safeCookieSet)
|
|
1566
|
+
: origin;
|
|
1567
|
+
storeInCache(cacheOrigin);
|
|
1568
|
+
return dressResponse(origin, "MISS");
|
|
1503
1569
|
}
|
|
1504
1570
|
}
|
|
1505
|
-
|