@decocms/tanstack 8.0.0 → 8.1.0-next.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 +12 -5
- package/src/__conformance__/observability.test.ts +221 -0
- package/src/hooks/CdnSegmentMarker.tsx +38 -0
- package/src/hooks/DecoPageRenderer.tsx +91 -29
- package/src/hooks/DecoRootLayout.tsx +139 -58
- package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
- package/src/hooks/DraftPreviewIndicator.tsx +64 -0
- package/src/hooks/NavigationProgress.test.tsx +58 -0
- package/src/hooks/NavigationProgress.tsx +45 -3
- package/src/hooks/PreviewProviders.test.tsx +42 -0
- package/src/hooks/PreviewProviders.tsx +21 -2
- package/src/hooks/deferredObserverTiming.test.tsx +91 -0
- package/src/hooks/index.ts +1 -0
- package/src/index.ts +30 -13
- package/src/routes/cmsRoute.test.ts +168 -1
- package/src/routes/cmsRoute.ts +148 -39
- package/src/routes/pageUrl.test.ts +6 -6
- package/src/routes/pageUrl.ts +5 -4
- package/src/routes/withSiteGlobals.test.ts +37 -0
- package/src/routes/withSiteGlobals.ts +45 -24
- package/src/sdk/backgroundTasks.test.ts +68 -0
- package/src/sdk/backgroundTasks.ts +38 -0
- package/src/sdk/cdnSegment.test.ts +67 -0
- package/src/sdk/cdnSegment.ts +71 -0
- package/src/sdk/deferredSectionLoader.test.ts +1 -1
- package/src/sdk/draft.test.ts +341 -0
- package/src/sdk/draft.ts +277 -0
- package/src/sdk/draftShared.ts +16 -0
- package/src/sdk/kvHydration.test.ts +156 -0
- package/src/sdk/kvHydration.ts +82 -12
- package/src/sdk/kvRedirects.test.ts +132 -0
- package/src/sdk/kvRedirects.ts +125 -0
- package/src/sdk/router.ts +70 -61
- package/src/sdk/serverFnFetch.ts +59 -0
- package/src/sdk/speculationRules.test.ts +82 -0
- package/src/sdk/speculationRules.ts +136 -0
- package/src/sdk/startEntry.ts +31 -0
- package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
- package/src/sdk/workerEntry.test.ts +554 -16
- package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
- package/src/sdk/workerEntry.ts +915 -68
- package/src/v8/__conformance__/delivery.test.ts +100 -0
- package/src/v8/binding.ts +70 -0
- package/src/v8/kvLoader.bundle.test.ts +42 -0
- package/src/v8/kvLoader.test.ts +74 -0
- package/src/v8/kvLoader.ts +43 -0
- package/src/vite/plugin.js +180 -8
- package/src/vite/plugin.test.js +126 -1
- package/src/vite/plugin.test.ts +125 -1
|
@@ -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
|
+
});
|