@decocms/tanstack 7.71.1 → 7.71.2
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/tanstack",
|
|
3
|
-
"version": "7.71.
|
|
3
|
+
"version": "7.71.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Deco framework binding for TanStack Start + Cloudflare Workers",
|
|
6
6
|
"repository": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"lint:unused": "knip"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@decocms/blocks": "7.71.
|
|
31
|
-
"@decocms/blocks-admin": "7.71.
|
|
32
|
-
"@decocms/blocks-cli": "7.71.
|
|
30
|
+
"@decocms/blocks": "7.71.2",
|
|
31
|
+
"@decocms/blocks-admin": "7.71.2",
|
|
32
|
+
"@decocms/blocks-cli": "7.71.2",
|
|
33
33
|
"@deco-cx/warp-node": "^0.3.16",
|
|
34
34
|
"fast-json-patch": "^3.1.0",
|
|
35
35
|
"ws": "^8.18.0"
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
|
|
3
|
+
import { setBlocks } from "@decocms/blocks/cms";
|
|
4
|
+
import { clearLoaderCache } from "@decocms/blocks/sdk/cachedLoader";
|
|
5
|
+
import { type CacheKVNamespace, createKVCacheStorage } from "@decocms/blocks/sdk/cacheStorage";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { createDecoWorkerEntry } from "./workerEntry";
|
|
8
|
+
|
|
9
|
+
// Regression suite for cache poisoning via tracking params, measured on a
|
|
10
|
+
// production storefront: a MISS on `/produtos/vestido?gclid=POISON42` stored the
|
|
11
|
+
// page under the clean key, and the next visitor on `/produtos/vestido` got a
|
|
12
|
+
// HIT with `POISON42` embedded 14 times (page URL, router match id, analytics
|
|
13
|
+
// `category_url`, pagination base URL).
|
|
14
|
+
|
|
15
|
+
function makeKV(): CacheKVNamespace {
|
|
16
|
+
const values = new Map<string, string>();
|
|
17
|
+
return {
|
|
18
|
+
get: async (key) => values.get(key) ?? null,
|
|
19
|
+
put: async (key, value) => {
|
|
20
|
+
values.set(key, value);
|
|
21
|
+
},
|
|
22
|
+
delete: async (key) => {
|
|
23
|
+
values.delete(key);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const jobs: Promise<unknown>[] = [];
|
|
29
|
+
const ctx = { waitUntil: (w: Promise<unknown>) => jobs.push(w), passThroughOnException() {} };
|
|
30
|
+
async function flush() {
|
|
31
|
+
while (jobs.length) await Promise.all(jobs.splice(0));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const options = {
|
|
35
|
+
observability: false as const,
|
|
36
|
+
outboundUserAgent: false as const,
|
|
37
|
+
geoCacheKey: "off" as const,
|
|
38
|
+
buildSegment: () => ({ device: "desktop" as const }),
|
|
39
|
+
cacheStorage: (bindings: Record<string, unknown>) =>
|
|
40
|
+
createKVCacheStorage(bindings.CACHE as CacheKVNamespace),
|
|
41
|
+
};
|
|
42
|
+
const env = () => ({ CACHE: makeKV(), BUILD_HASH: "build-A" });
|
|
43
|
+
|
|
44
|
+
/** Origin that embeds the URL it rendered — like a real page does. */
|
|
45
|
+
const echoOrigin = () => ({
|
|
46
|
+
fetch: vi.fn(
|
|
47
|
+
async (req: Request) =>
|
|
48
|
+
new Response(`<html>rendered ${req.url}</html>`, {
|
|
49
|
+
headers: { "content-type": "text/html" },
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const page = (path: string) =>
|
|
55
|
+
new Request(`https://shop.test${path}`, { headers: { accept: "text/html" } });
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Move the clock forward without fake timers (the cache storage and tracing
|
|
59
|
+
* helpers await real promises). The `listing` profile is fresh=120s, swr=900s,
|
|
60
|
+
* so +200s lands squarely in the stale-while-revalidate window.
|
|
61
|
+
*/
|
|
62
|
+
let clockOffsetMs = 0;
|
|
63
|
+
const INSIDE_SWR_MS = 200_000;
|
|
64
|
+
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
clearLoaderCache();
|
|
67
|
+
setBlocks({});
|
|
68
|
+
clockOffsetMs = 0;
|
|
69
|
+
const realNow = Date.now.bind(Date);
|
|
70
|
+
vi.spyOn(Date, "now").mockImplementation(() => realNow() + clockOffsetMs);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
afterEach(() => vi.restoreAllMocks());
|
|
74
|
+
|
|
75
|
+
describe("tracking params never poison the edge cache", () => {
|
|
76
|
+
it("does not store a page rendered from a tracked URL", async () => {
|
|
77
|
+
const origin = echoOrigin();
|
|
78
|
+
const bindings = env();
|
|
79
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
80
|
+
|
|
81
|
+
const tracked = await worker.fetch(page("/produtos?gclid=POISON42"), bindings, ctx);
|
|
82
|
+
expect(tracked.headers.get("X-Cache")).toBe("MISS");
|
|
83
|
+
expect(tracked.headers.get("X-Cache-Store")).toBe("skipped-tracking");
|
|
84
|
+
await flush();
|
|
85
|
+
|
|
86
|
+
const clean = await worker.fetch(page("/produtos"), bindings, ctx);
|
|
87
|
+
expect(clean.headers.get("X-Cache")).toBe("MISS");
|
|
88
|
+
expect(await clean.text()).not.toContain("POISON42");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("still serves tracked requests from the clean entry", async () => {
|
|
92
|
+
const origin = echoOrigin();
|
|
93
|
+
const bindings = env();
|
|
94
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
95
|
+
|
|
96
|
+
await worker.fetch(page("/produtos"), bindings, ctx);
|
|
97
|
+
await flush();
|
|
98
|
+
|
|
99
|
+
const tracked = await worker.fetch(page("/produtos?utm_source=ads&gclid=X1"), bindings, ctx);
|
|
100
|
+
expect(tracked.headers.get("X-Cache")).toBe("HIT");
|
|
101
|
+
const body = await tracked.text();
|
|
102
|
+
expect(body).not.toContain("X1");
|
|
103
|
+
expect(body).not.toContain("utm_source");
|
|
104
|
+
expect(origin.fetch).toHaveBeenCalledTimes(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("revalidates a stale entry from the clean URL, not the tracked one", async () => {
|
|
108
|
+
const origin = echoOrigin();
|
|
109
|
+
const bindings = env();
|
|
110
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
111
|
+
|
|
112
|
+
await worker.fetch(page("/produtos"), bindings, ctx);
|
|
113
|
+
await flush();
|
|
114
|
+
|
|
115
|
+
clockOffsetMs = INSIDE_SWR_MS;
|
|
116
|
+
const stale = await worker.fetch(page("/produtos?gclid=POISON42"), bindings, ctx);
|
|
117
|
+
expect(stale.headers.get("X-Cache")).toBe("STALE-HIT");
|
|
118
|
+
await flush();
|
|
119
|
+
|
|
120
|
+
// The background render must see the clean URL. Rendering the tracked one
|
|
121
|
+
// would be discarded by the store guard — a full SSR for nothing, on every
|
|
122
|
+
// ad visitor, for the rest of the SWR window.
|
|
123
|
+
const rendered = origin.fetch.mock.calls.map((c) => (c[0] as Request).url);
|
|
124
|
+
expect(rendered).toHaveLength(2);
|
|
125
|
+
expect(rendered[1]).not.toContain("POISON42");
|
|
126
|
+
|
|
127
|
+
// And it refreshed the entry, so paid traffic keeps the cache warm.
|
|
128
|
+
clockOffsetMs = INSIDE_SWR_MS + 1_000;
|
|
129
|
+
const next = await worker.fetch(page("/produtos"), bindings, ctx);
|
|
130
|
+
expect(next.headers.get("X-Cache")).toBe("HIT");
|
|
131
|
+
expect(await next.text()).not.toContain("POISON42");
|
|
132
|
+
expect(origin.fetch).toHaveBeenCalledTimes(2);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("revalidates a stale GET server fn from the clean payload", async () => {
|
|
136
|
+
const origin = echoOrigin();
|
|
137
|
+
const bindings = env();
|
|
138
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
139
|
+
const sfn = (pageUrl: string) =>
|
|
140
|
+
new Request(
|
|
141
|
+
`https://shop.test/_serverFn/loadCmsPage?payload=${encodeURIComponent(
|
|
142
|
+
JSON.stringify({ data: { pageUrl } }),
|
|
143
|
+
)}`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
await worker.fetch(sfn("/produtos"), bindings, ctx);
|
|
147
|
+
await flush();
|
|
148
|
+
|
|
149
|
+
clockOffsetMs = INSIDE_SWR_MS;
|
|
150
|
+
// The tracking lives inside `payload`, not in the URL's own query — so
|
|
151
|
+
// cleaning the URL alone would render (and store) the tracked page.
|
|
152
|
+
const stale = await worker.fetch(sfn("/produtos?gclid=POISON42"), bindings, ctx);
|
|
153
|
+
expect(stale.headers.get("X-Cache")).toBe("STALE-HIT");
|
|
154
|
+
await flush();
|
|
155
|
+
|
|
156
|
+
clockOffsetMs = INSIDE_SWR_MS + 1_000;
|
|
157
|
+
const next = await worker.fetch(sfn("/produtos"), bindings, ctx);
|
|
158
|
+
expect(next.headers.get("X-Cache")).toBe("HIT");
|
|
159
|
+
expect(await next.text()).not.toContain("POISON42");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("stores clean pages as before", async () => {
|
|
163
|
+
const origin = echoOrigin();
|
|
164
|
+
const bindings = env();
|
|
165
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
166
|
+
|
|
167
|
+
const first = await worker.fetch(page("/produtos?sort=price:asc"), bindings, ctx);
|
|
168
|
+
expect(first.headers.get("X-Cache-Store")).toBeNull();
|
|
169
|
+
await flush();
|
|
170
|
+
const second = await worker.fetch(page("/produtos?sort=price:asc"), bindings, ctx);
|
|
171
|
+
expect(second.headers.get("X-Cache")).toBe("HIT");
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("POST server fn: tracked pageUrl converges on one key and is never stored", () => {
|
|
176
|
+
const sfnOrigin = () => ({
|
|
177
|
+
fetch: vi.fn(async (req: Request) => {
|
|
178
|
+
const body = await req.text();
|
|
179
|
+
return new Response(`section for ${body}`, {
|
|
180
|
+
headers: { "content-type": "application/json", "X-Deco-Cacheable": "true" },
|
|
181
|
+
});
|
|
182
|
+
}),
|
|
183
|
+
});
|
|
184
|
+
const post = (pageUrl: string) =>
|
|
185
|
+
new Request("https://shop.test/_serverFn/abc123", {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: { "content-type": "application/json" },
|
|
188
|
+
body: JSON.stringify({ data: { component: "Shelf", pageUrl } }),
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("serves a tracked body from the clean body's entry", async () => {
|
|
192
|
+
const origin = sfnOrigin();
|
|
193
|
+
const bindings = env();
|
|
194
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
195
|
+
|
|
196
|
+
const clean = await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
|
|
197
|
+
expect(clean.headers.get("X-Cache")).toBe("MISS");
|
|
198
|
+
await flush();
|
|
199
|
+
|
|
200
|
+
const tracked = await worker.fetch(
|
|
201
|
+
post("https://shop.test/produtos?gclid=POISON42"),
|
|
202
|
+
bindings,
|
|
203
|
+
ctx,
|
|
204
|
+
);
|
|
205
|
+
expect(tracked.headers.get("X-Cache")).toBe("HIT");
|
|
206
|
+
expect(await tracked.text()).not.toContain("POISON42");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("does not store a response rendered from a tracked body", async () => {
|
|
210
|
+
const origin = sfnOrigin();
|
|
211
|
+
const bindings = env();
|
|
212
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
213
|
+
|
|
214
|
+
await worker.fetch(post("https://shop.test/produtos?gclid=POISON42"), bindings, ctx);
|
|
215
|
+
await flush();
|
|
216
|
+
|
|
217
|
+
const clean = await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
|
|
218
|
+
expect(clean.headers.get("X-Cache")).toBe("MISS");
|
|
219
|
+
expect(await clean.text()).not.toContain("POISON42");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("labels the skipped store, like the HTML path does", async () => {
|
|
223
|
+
const worker = createDecoWorkerEntry(sfnOrigin(), options);
|
|
224
|
+
|
|
225
|
+
const tracked = await worker.fetch(
|
|
226
|
+
post("https://shop.test/produtos?gclid=POISON42"),
|
|
227
|
+
env(),
|
|
228
|
+
ctx,
|
|
229
|
+
);
|
|
230
|
+
expect(tracked.headers.get("X-Cache")).toBe("MISS");
|
|
231
|
+
expect(tracked.headers.get("X-Cache-Store")).toBe("skipped-tracking");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("revalidates a stale entry from the canonical body", async () => {
|
|
235
|
+
const origin = sfnOrigin();
|
|
236
|
+
const bindings = env();
|
|
237
|
+
const worker = createDecoWorkerEntry(origin, options);
|
|
238
|
+
|
|
239
|
+
await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
|
|
240
|
+
await flush();
|
|
241
|
+
|
|
242
|
+
clockOffsetMs = INSIDE_SWR_MS;
|
|
243
|
+
const stale = await worker.fetch(
|
|
244
|
+
post("https://shop.test/produtos?gclid=POISON42"),
|
|
245
|
+
bindings,
|
|
246
|
+
ctx,
|
|
247
|
+
);
|
|
248
|
+
expect(stale.headers.get("X-Cache")).toBe("STALE-HIT");
|
|
249
|
+
await flush();
|
|
250
|
+
|
|
251
|
+
// Refreshed rather than discarded: a third request HITs without a third
|
|
252
|
+
// origin render, and the stored section never saw the landing URL.
|
|
253
|
+
clockOffsetMs = INSIDE_SWR_MS + 1_000;
|
|
254
|
+
const next = await worker.fetch(post("https://shop.test/produtos"), bindings, ctx);
|
|
255
|
+
expect(next.headers.get("X-Cache")).toBe("HIT");
|
|
256
|
+
expect(await next.text()).not.toContain("POISON42");
|
|
257
|
+
expect(origin.fetch).toHaveBeenCalledTimes(2);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("buildSegment geo split warning", () => {
|
|
262
|
+
const geo = (path: string) =>
|
|
263
|
+
new Request(`https://shop.test${path}`, {
|
|
264
|
+
headers: { accept: "text/html", "cf-region-code": "RJ" },
|
|
265
|
+
});
|
|
266
|
+
const cfRegionSegment = (req: Request) => ({
|
|
267
|
+
device: "desktop" as const,
|
|
268
|
+
regionId: req.headers.get("cf-region-code") ?? undefined,
|
|
269
|
+
});
|
|
270
|
+
const warned = (warn: { mock: { calls: unknown[][] } }) =>
|
|
271
|
+
warn.mock.calls.filter((c) => String(c[0]).includes("buildSegment sets regionId"));
|
|
272
|
+
|
|
273
|
+
it("warns once when regionId is the CF region and no location matcher exists", async () => {
|
|
274
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
275
|
+
// A loaded decofile with no location matcher — that's what makes
|
|
276
|
+
// `geoCacheKey: "auto"` resolving to "off" an answer and not a default.
|
|
277
|
+
setBlocks({ "/": { __resolveType: "website/pages/Page.tsx" } });
|
|
278
|
+
const worker = createDecoWorkerEntry(echoOrigin(), {
|
|
279
|
+
...options,
|
|
280
|
+
geoCacheKey: "auto",
|
|
281
|
+
buildSegment: cfRegionSegment,
|
|
282
|
+
});
|
|
283
|
+
await worker.fetch(geo("/a"), env(), ctx);
|
|
284
|
+
await worker.fetch(geo("/b"), env(), ctx);
|
|
285
|
+
expect(warned(warn)).toHaveLength(1);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("stays quiet before the decofile loads", async () => {
|
|
289
|
+
// `_autoGeoKey` defaults to "off", so warning here would tell a site that
|
|
290
|
+
// DOES have a location matcher to delete the fallback it needs — and the
|
|
291
|
+
// flag latches, so the bogus advice is all they ever see.
|
|
292
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
293
|
+
const worker = createDecoWorkerEntry(echoOrigin(), {
|
|
294
|
+
...options,
|
|
295
|
+
geoCacheKey: "auto",
|
|
296
|
+
buildSegment: cfRegionSegment,
|
|
297
|
+
});
|
|
298
|
+
await worker.fetch(geo("/a"), env(), ctx);
|
|
299
|
+
expect(warned(warn)).toHaveLength(0);
|
|
300
|
+
|
|
301
|
+
// Once the decofile lands and really has no matcher, it does warn.
|
|
302
|
+
setBlocks({ "/": { __resolveType: "website/pages/Page.tsx" } });
|
|
303
|
+
await worker.fetch(geo("/b"), env(), ctx);
|
|
304
|
+
expect(warned(warn)).toHaveLength(1);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("stays quiet for a VTEX regionId", async () => {
|
|
308
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
309
|
+
const worker = createDecoWorkerEntry(echoOrigin(), {
|
|
310
|
+
...options,
|
|
311
|
+
geoCacheKey: "auto",
|
|
312
|
+
buildSegment: () => ({
|
|
313
|
+
device: "desktop" as const,
|
|
314
|
+
regionId: "v2.1BB18CE648B5111D0933734ED83EC783",
|
|
315
|
+
}),
|
|
316
|
+
});
|
|
317
|
+
await worker.fetch(
|
|
318
|
+
new Request("https://shop.test/a", {
|
|
319
|
+
headers: { accept: "text/html", "cf-region-code": "RJ" },
|
|
320
|
+
}),
|
|
321
|
+
env(),
|
|
322
|
+
ctx,
|
|
323
|
+
);
|
|
324
|
+
expect(warn.mock.calls.some((c) => String(c[0]).includes("buildSegment sets regionId"))).toBe(
|
|
325
|
+
false,
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
detectCacheProfile,
|
|
49
49
|
edgeCacheConfig,
|
|
50
50
|
getCacheProfile,
|
|
51
|
+
payloadHasTrackingParams,
|
|
51
52
|
serverFnPagePath,
|
|
52
53
|
} from "@decocms/blocks/sdk/cacheHeaders";
|
|
53
54
|
import {
|
|
@@ -85,7 +86,7 @@ import {
|
|
|
85
86
|
} from "@decocms/blocks/sdk/redirects";
|
|
86
87
|
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
|
|
87
88
|
import { createResponseCache } from "@decocms/blocks/sdk/responseCache";
|
|
88
|
-
import { cleanPathForCacheKey } from "@decocms/blocks/sdk/urlUtils";
|
|
89
|
+
import { cleanPathForCacheKey, hasTrackingParams } from "@decocms/blocks/sdk/urlUtils";
|
|
89
90
|
import { type Device, isMobileUA } from "@decocms/blocks/sdk/useDevice";
|
|
90
91
|
import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
|
|
91
92
|
import { reconfigureAppsOnce } from "@decocms/blocks-admin/apps/autoconfig";
|
|
@@ -966,6 +967,15 @@ const FINGERPRINTED_ASSET_RE = /(?:\/_build)?\/assets\/.*-[a-zA-Z0-9_-]{8,}\.\w+
|
|
|
966
967
|
// Sites that need city precision should set geoCacheKey: "city" explicitly.
|
|
967
968
|
|
|
968
969
|
let _autoGeoKey: "off" | "region" = "off";
|
|
970
|
+
/**
|
|
971
|
+
* Whether `_autoGeoKey` is an answer or still the pre-hydration default. It
|
|
972
|
+
* starts `"off"` and only becomes `"region"` once a decofile carrying a
|
|
973
|
+
* location matcher loads, so anything that *reports* on `"off"` (rather than
|
|
974
|
+
* just keying on it) has to wait for a non-empty decofile first. Recomputed on
|
|
975
|
+
* every change rather than latched, so a reset back to an empty decofile
|
|
976
|
+
* un-settles it.
|
|
977
|
+
*/
|
|
978
|
+
let _autoGeoSettled = false;
|
|
969
979
|
let _autoGeoListenerInstalled = false;
|
|
970
980
|
|
|
971
981
|
function installAutoGeoListener(): void {
|
|
@@ -973,6 +983,7 @@ function installAutoGeoListener(): void {
|
|
|
973
983
|
_autoGeoListenerInstalled = true;
|
|
974
984
|
const update = (blocks: Record<string, unknown>) => {
|
|
975
985
|
_autoGeoKey = detectLocationMatcher(blocks) ? "region" : "off";
|
|
986
|
+
_autoGeoSettled = Object.keys(blocks).length > 0;
|
|
976
987
|
};
|
|
977
988
|
onChange(update);
|
|
978
989
|
update(loadBlocks());
|
|
@@ -1138,9 +1149,40 @@ export function createDecoWorkerEntry(
|
|
|
1138
1149
|
return fromHeader || undefined;
|
|
1139
1150
|
}
|
|
1140
1151
|
|
|
1152
|
+
// Sites generated by older migration templates set `regionId` from the
|
|
1153
|
+
// Cloudflare state on every request, which splits every page's cache ~27x
|
|
1154
|
+
// (one copy per BR state) even when no content varies by location. The
|
|
1155
|
+
// framework already does this backfill below, and only when a location
|
|
1156
|
+
// matcher exists — so flag the redundant, unconditional version once.
|
|
1157
|
+
let warnedGeoSegment = false;
|
|
1158
|
+
/**
|
|
1159
|
+
* Whether `effectiveGeoKey() === "off"` is a real answer or just the
|
|
1160
|
+
* pre-hydration default. Under `geoCacheKey: "auto"` a request that lands
|
|
1161
|
+
* before the decofile loads would otherwise latch the warning below and tell
|
|
1162
|
+
* the team to delete a geo fallback its location matcher actually needs. An
|
|
1163
|
+
* explicit `geoCacheKey` has no such race.
|
|
1164
|
+
*/
|
|
1165
|
+
function geoKeySettled(): boolean {
|
|
1166
|
+
return geoCacheKeyOpt !== "auto" || _autoGeoSettled;
|
|
1167
|
+
}
|
|
1141
1168
|
const buildSegment = rawBuildSegment
|
|
1142
1169
|
? (request: Request): SegmentKey => {
|
|
1143
1170
|
const seg = rawBuildSegment(request);
|
|
1171
|
+
if (
|
|
1172
|
+
!warnedGeoSegment &&
|
|
1173
|
+
seg.regionId &&
|
|
1174
|
+
effectiveGeoKey() === "off" &&
|
|
1175
|
+
geoKeySettled() &&
|
|
1176
|
+
seg.regionId === readRegionFromRequest(request)
|
|
1177
|
+
) {
|
|
1178
|
+
warnedGeoSegment = true;
|
|
1179
|
+
console.warn(
|
|
1180
|
+
`[deco] buildSegment sets regionId to the Cloudflare region ("${seg.regionId}") ` +
|
|
1181
|
+
"but no website/matchers/location.ts block exists, so every page's edge cache " +
|
|
1182
|
+
"is split per region for nothing. Return only the VTEX regionId from buildSegment " +
|
|
1183
|
+
'and let geoCacheKey: "auto" backfill the region when a location matcher is added.',
|
|
1184
|
+
);
|
|
1185
|
+
}
|
|
1144
1186
|
if (seg.regionId) return seg;
|
|
1145
1187
|
if (effectiveGeoKey() === "off") return seg;
|
|
1146
1188
|
const region = readRegionFromRequest(request);
|
|
@@ -1412,6 +1454,15 @@ export function createDecoWorkerEntry(
|
|
|
1412
1454
|
return parts.join("|");
|
|
1413
1455
|
}
|
|
1414
1456
|
|
|
1457
|
+
function requestCarriesTracking(url: URL): boolean {
|
|
1458
|
+
if (hasTrackingParams(url.toString())) return true;
|
|
1459
|
+
if (url.pathname.startsWith("/_serverFn/") || url.pathname.startsWith("/_server/")) {
|
|
1460
|
+
const payload = url.searchParams.get("payload");
|
|
1461
|
+
return !!payload && payloadHasTrackingParams(payload);
|
|
1462
|
+
}
|
|
1463
|
+
return false;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1415
1466
|
function buildCacheKey(
|
|
1416
1467
|
request: Request,
|
|
1417
1468
|
env: Record<string, unknown>,
|
|
@@ -2543,7 +2594,14 @@ export function createDecoWorkerEntry(
|
|
|
2543
2594
|
// work correctly. We only read the body for the cache key hash.
|
|
2544
2595
|
const originClone = request.clone();
|
|
2545
2596
|
const body = await request.text();
|
|
2546
|
-
|
|
2597
|
+
// Key on the body with tracking params stripped (a deferred section's
|
|
2598
|
+
// `pageUrl` carries the landing URL, so `?gclid=…` otherwise mints one
|
|
2599
|
+
// entry per ad click), but never *store* a response rendered from a
|
|
2600
|
+
// tracked body: it embeds that URL, and every later visitor would get it.
|
|
2601
|
+
const sfnCarriesTracking = shouldStripTracking && payloadHasTrackingParams(body);
|
|
2602
|
+
const bodyHash = await hashText(
|
|
2603
|
+
shouldStripTracking ? canonicalizeServerFnPayloadForCacheKey(body, []) : body,
|
|
2604
|
+
);
|
|
2547
2605
|
|
|
2548
2606
|
// Build a synthetic GET cache key from the URL + body hash + segment
|
|
2549
2607
|
// Includes device, salesChannel, regionId, flags — so users in
|
|
@@ -2606,7 +2664,15 @@ export function createDecoWorkerEntry(
|
|
|
2606
2664
|
ctx.waitUntil(
|
|
2607
2665
|
(async () => {
|
|
2608
2666
|
try {
|
|
2609
|
-
|
|
2667
|
+
// Revalidate from the canonical body, for the same reason the
|
|
2668
|
+
// HTML path revalidates from the clean URL: the entry is keyed
|
|
2669
|
+
// on it, and a render from the tracked body embeds that landing
|
|
2670
|
+
// URL. Rendering the tracked body would only be discarded,
|
|
2671
|
+
// leaving the entry stale for the rest of the SWR window.
|
|
2672
|
+
const bgBody = sfnCarriesTracking
|
|
2673
|
+
? canonicalizeServerFnPayloadForCacheKey(body, [])
|
|
2674
|
+
: body;
|
|
2675
|
+
const bgReq = new Request(request, { body: bgBody, method: "POST" });
|
|
2610
2676
|
const bgOrigin = await serverEntry.fetch(bgReq, env, ctx);
|
|
2611
2677
|
if (
|
|
2612
2678
|
bgOrigin.status === 200 &&
|
|
@@ -2660,8 +2726,8 @@ export function createDecoWorkerEntry(
|
|
|
2660
2726
|
return resp;
|
|
2661
2727
|
}
|
|
2662
2728
|
|
|
2663
|
-
// Store in edge cache
|
|
2664
|
-
if (serverFnCache) {
|
|
2729
|
+
// Store in edge cache (see sfnCarriesTracking above)
|
|
2730
|
+
if (serverFnCache && !sfnCarriesTracking) {
|
|
2665
2731
|
try {
|
|
2666
2732
|
const ttl = sfnEdge.fresh + Math.max(sfnEdge.swr, sfnEdge.sie);
|
|
2667
2733
|
const toStore = origin.clone();
|
|
@@ -2686,6 +2752,9 @@ export function createDecoWorkerEntry(
|
|
|
2686
2752
|
for (const [k, v] of Object.entries(hdrs)) resp.headers.set(k, v);
|
|
2687
2753
|
resp.headers.set("X-Cache", "MISS");
|
|
2688
2754
|
resp.headers.set("X-Cache-Profile", sfnProfile);
|
|
2755
|
+
// Same label the HTML path emits, so the documented probe works on a
|
|
2756
|
+
// deferred section too.
|
|
2757
|
+
if (sfnCarriesTracking) resp.headers.set("X-Cache-Store", "skipped-tracking");
|
|
2689
2758
|
return resp;
|
|
2690
2759
|
}
|
|
2691
2760
|
|
|
@@ -2731,6 +2800,13 @@ export function createDecoWorkerEntry(
|
|
|
2731
2800
|
// Cacheable request — build segment-aware cache key
|
|
2732
2801
|
const { key: cacheKey, segment } = buildCacheKey(request, env);
|
|
2733
2802
|
|
|
2803
|
+
// The key drops tracking params, but the renderer sees the raw URL and
|
|
2804
|
+
// embeds it (page URL, router match id, analytics payloads, pagination
|
|
2805
|
+
// base URLs). Storing that response under the clean key served one ad
|
|
2806
|
+
// visitor's `gclid` to everyone. Tracked requests still READ the clean
|
|
2807
|
+
// entry; only clean requests WRITE it.
|
|
2808
|
+
const skipStoreTracking = shouldStripTracking && requestCarriesTracking(url);
|
|
2809
|
+
|
|
2734
2810
|
// Logged-in users always bypass the cache (personalized content)
|
|
2735
2811
|
if (segment?.loggedIn) {
|
|
2736
2812
|
const origin = await serverEntry.fetch(request, env, ctx);
|
|
@@ -2810,7 +2886,9 @@ export function createDecoWorkerEntry(
|
|
|
2810
2886
|
return out;
|
|
2811
2887
|
}
|
|
2812
2888
|
|
|
2813
|
-
// Helper: store a response in Cache API with the full retention window
|
|
2889
|
+
// Helper: store a response in Cache API with the full retention window.
|
|
2890
|
+
// Callers own the tracking decision: a response is storable iff it was
|
|
2891
|
+
// rendered from the clean URL this `cacheKey` represents.
|
|
2814
2892
|
function storeInCache(resp: Response) {
|
|
2815
2893
|
if (!cache) return;
|
|
2816
2894
|
try {
|
|
@@ -2830,10 +2908,46 @@ export function createDecoWorkerEntry(
|
|
|
2830
2908
|
}
|
|
2831
2909
|
}
|
|
2832
2910
|
|
|
2911
|
+
// A tracked request must revalidate from the CLEAN URL. The renderer
|
|
2912
|
+
// embeds the URL it was given, and the entry being refreshed is keyed
|
|
2913
|
+
// clean — so rendering the tracked URL here is either poisoning (what this
|
|
2914
|
+
// used to do) or dead weight (`storeInCache` drops it), which would leave
|
|
2915
|
+
// the entry stale for the whole SWR window while every ad visitor pays a
|
|
2916
|
+
// full SSR that refreshes nothing. Rendering clean costs the same fetch
|
|
2917
|
+
// and actually warms the entry, so paid traffic keeps the cache alive.
|
|
2918
|
+
function revalidationRequest(): Request {
|
|
2919
|
+
if (!skipStoreTracking) return request;
|
|
2920
|
+
const clean = new URL(cleanPathForCacheKey(url.toString()), url.origin);
|
|
2921
|
+
// A GET server fn can carry the tracking inside `payload` (the page it
|
|
2922
|
+
// loads), not in its own query — strip it there too, or this renders and
|
|
2923
|
+
// stores the tracked page under the clean key.
|
|
2924
|
+
const payload = clean.searchParams.get("payload");
|
|
2925
|
+
if (
|
|
2926
|
+
payload &&
|
|
2927
|
+
(clean.pathname.startsWith("/_serverFn/") || clean.pathname.startsWith("/_server/"))
|
|
2928
|
+
) {
|
|
2929
|
+
clean.searchParams.set("payload", canonicalizeServerFnPayloadForCacheKey(payload, []));
|
|
2930
|
+
}
|
|
2931
|
+
// Build the init field by field instead of passing `request` as init:
|
|
2932
|
+
// whether the `new Request(url, request)` form carries `cf` over is
|
|
2933
|
+
// runtime-dependent, and a location matcher reading a dropped `cf` would
|
|
2934
|
+
// store a wrong-region render under a region-keyed entry. No body — this
|
|
2935
|
+
// path is GET-only (every other method bypasses the cache above) — and
|
|
2936
|
+
// no `signal`, so a client disconnecting mid-revalidation doesn't abort
|
|
2937
|
+
// the refresh that the next visitor is going to read.
|
|
2938
|
+
const cf = (request as unknown as { cf?: unknown }).cf;
|
|
2939
|
+
return new Request(clean.toString(), {
|
|
2940
|
+
method: request.method,
|
|
2941
|
+
headers: request.headers,
|
|
2942
|
+
redirect: request.redirect,
|
|
2943
|
+
...(cf ? { cf } : {}),
|
|
2944
|
+
} as RequestInit);
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2833
2947
|
// Helper: background revalidation (fetch origin, store result)
|
|
2834
2948
|
function revalidateInBackground() {
|
|
2835
2949
|
ctx.waitUntil(
|
|
2836
|
-
Promise.resolve(serverEntry.fetch(
|
|
2950
|
+
Promise.resolve(serverEntry.fetch(revalidationRequest(), env, ctx))
|
|
2837
2951
|
.then((origin) => {
|
|
2838
2952
|
// Never overwrite a good entry with a degraded 200 (a critical
|
|
2839
2953
|
// section failed) — that would poison the cache. Leave the stale
|
|
@@ -3008,7 +3122,13 @@ export function createDecoWorkerEntry(
|
|
|
3008
3122
|
const cacheOrigin = origin.headers.has("set-cookie")
|
|
3009
3123
|
? stripSafeCookiesForCache(origin, safeCookieSet)
|
|
3010
3124
|
: origin;
|
|
3011
|
-
|
|
3012
|
-
|
|
3125
|
+
// This render saw the tracked URL and embedded it — never store it under
|
|
3126
|
+
// the clean key. The next clean visitor fills the entry.
|
|
3127
|
+
if (!skipStoreTracking) storeInCache(cacheOrigin);
|
|
3128
|
+
return dressResponse(
|
|
3129
|
+
origin,
|
|
3130
|
+
"MISS",
|
|
3131
|
+
skipStoreTracking ? { "X-Cache-Store": "skipped-tracking" } : undefined,
|
|
3132
|
+
);
|
|
3013
3133
|
}
|
|
3014
3134
|
}
|