@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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
package/src/sdk/router.ts CHANGED
@@ -6,61 +6,63 @@
6
6
  * filter URLs like `?filter.brand=Nike&filter.brand=Adidas` which must
7
7
  * round-trip correctly through the router's search system.
8
8
  */
9
- import { createRouter as createTanStackRouter } from "@tanstack/react-router";
9
+
10
+ import { getRequestNonce } from "@decocms/blocks/sdk/nonce";
10
11
  import type {
11
- SearchSerializer,
12
- SearchParser,
13
- AnyRoute,
14
- TrailingSlashOption,
12
+ AnyRoute,
13
+ SearchParser,
14
+ SearchSerializer,
15
+ TrailingSlashOption,
15
16
  } from "@tanstack/react-router";
17
+ import { createRouter as createTanStackRouter } from "@tanstack/react-router";
16
18
 
17
19
  export const decoParseSearch: SearchParser = (searchStr) => {
18
- const str = searchStr.startsWith("?") ? searchStr.slice(1) : searchStr;
19
- if (!str) return {};
20
+ const str = searchStr.startsWith("?") ? searchStr.slice(1) : searchStr;
21
+ if (!str) return {};
20
22
 
21
- const params = new URLSearchParams(str);
22
- const result: Record<string, string | string[]> = {};
23
+ const params = new URLSearchParams(str);
24
+ const result: Record<string, string | string[]> = {};
23
25
 
24
- for (const key of new Set(params.keys())) {
25
- const values = params.getAll(key);
26
- result[key] = values.length === 1 ? values[0] : values;
27
- }
28
- return result;
26
+ for (const key of new Set(params.keys())) {
27
+ const values = params.getAll(key);
28
+ result[key] = values.length === 1 ? values[0] : values;
29
+ }
30
+ return result;
29
31
  };
30
32
 
31
33
  export const decoStringifySearch: SearchSerializer = (search) => {
32
- const params = new URLSearchParams();
33
- for (const [key, value] of Object.entries(search)) {
34
- if (value === undefined || value === null || value === "") continue;
35
- if (Array.isArray(value)) {
36
- for (const v of value) params.append(key, String(v));
37
- } else {
38
- params.append(key, String(value));
39
- }
40
- }
41
- const str = params.toString();
42
- return str ? `?${str}` : "";
34
+ const params = new URLSearchParams();
35
+ for (const [key, value] of Object.entries(search)) {
36
+ if (value === undefined || value === null || value === "") continue;
37
+ if (Array.isArray(value)) {
38
+ for (const v of value) params.append(key, String(v));
39
+ } else {
40
+ params.append(key, String(value));
41
+ }
42
+ }
43
+ const str = params.toString();
44
+ return str ? `?${str}` : "";
43
45
  };
44
46
 
45
47
  export interface CreateDecoRouterOptions {
46
- routeTree: AnyRoute;
47
- scrollRestoration?: boolean;
48
- defaultPreload?: "intent" | "viewport" | "render" | false;
49
- trailingSlash?: TrailingSlashOption;
50
- /**
51
- * Router context — passed to all route loaders/components via routeContext.
52
- * Commonly used for { queryClient } per TanStack Query integration docs.
53
- */
54
- context?: Record<string, unknown>;
55
- /**
56
- * Non-DOM provider component to wrap the entire router.
57
- * Per TanStack docs, only non-DOM-rendering components (providers) should
58
- * be used — anything else causes hydration errors.
59
- *
60
- * Example: QueryClientProvider wrapping
61
- * Wrap: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>
62
- */
63
- Wrap?: (props: { children: any }) => any;
48
+ routeTree: AnyRoute;
49
+ scrollRestoration?: boolean;
50
+ defaultPreload?: "intent" | "viewport" | "render" | false;
51
+ trailingSlash?: TrailingSlashOption;
52
+ /**
53
+ * Router context — passed to all route loaders/components via routeContext.
54
+ * Commonly used for { queryClient } per TanStack Query integration docs.
55
+ */
56
+ context?: Record<string, unknown>;
57
+ /**
58
+ * Non-DOM provider component to wrap the entire router.
59
+ * Per TanStack docs, only non-DOM-rendering components (providers) should
60
+ * be used — anything else causes hydration errors.
61
+ *
62
+ * Example: QueryClientProvider wrapping
63
+ * Wrap: ({ children }) => <QueryClientProvider client={qc}>{children}</QueryClientProvider>
64
+ */
65
+ Wrap?: (props: { children: any }) => any;
64
66
  }
65
67
 
66
68
  /**
@@ -70,23 +72,30 @@ export interface CreateDecoRouterOptions {
70
72
  * - Preload on intent
71
73
  */
72
74
  export function createDecoRouter(options: CreateDecoRouterOptions) {
73
- const {
74
- routeTree,
75
- scrollRestoration = true,
76
- defaultPreload = "intent",
77
- trailingSlash,
78
- context,
79
- Wrap,
80
- } = options;
75
+ const {
76
+ routeTree,
77
+ scrollRestoration = true,
78
+ defaultPreload = "intent",
79
+ trailingSlash,
80
+ context,
81
+ Wrap,
82
+ } = options;
83
+
84
+ // On the server, `getRouter()` runs per request inside `RequestContext.run`,
85
+ // so this reads the request's CSP nonce (enforce mode) — `ScriptOnce` and
86
+ // TanStack's own hydration scripts read `router.options.ssr.nonce` and stamp
87
+ // it. `undefined` in report-only mode and on the client, where it's a no-op.
88
+ const nonce = getRequestNonce();
81
89
 
82
- return createTanStackRouter({
83
- routeTree,
84
- scrollRestoration,
85
- defaultPreload,
86
- trailingSlash,
87
- context: context as any,
88
- Wrap,
89
- parseSearch: decoParseSearch,
90
- stringifySearch: decoStringifySearch,
91
- });
90
+ return createTanStackRouter({
91
+ routeTree,
92
+ scrollRestoration,
93
+ defaultPreload,
94
+ trailingSlash,
95
+ context: context as any,
96
+ Wrap,
97
+ parseSearch: decoParseSearch,
98
+ stringifySearch: decoStringifySearch,
99
+ ...(nonce ? { ssr: { nonce } } : {}),
100
+ });
92
101
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Client-side `serverFns.fetch` hook that echoes the CDN segment marker onto
3
+ * `/_serverFn` URLs.
4
+ *
5
+ * Pairs with `cdnCacheControl: "serverfn-segment"` on `createDecoWorkerEntry`.
6
+ * Putting the segment in the URL is what makes the key of whatever caches in
7
+ * front of the Worker equivalent to the Worker's own — see `./cdnSegment`.
8
+ *
9
+ * The client does not COMPUTE the token, it repeats one the server issued and
10
+ * published on the page. That is deliberate: the segment includes dimensions
11
+ * only the server can see (region comes from `request.cf`), so a client-derived
12
+ * token never matched on a regionalized store and the feature stayed inert
13
+ * there.
14
+ *
15
+ * Only client-initiated requests carry it — SPA navigation and prefetch, which
16
+ * is the volume Speculation Rules generates. The initial HTML document is a
17
+ * browser navigation with no hook to attach anything to.
18
+ *
19
+ * SECURITY: the marker is a HINT. The worker recomputes the segment from the
20
+ * request itself and only relaxes `no-store` on an exact match
21
+ * (`cdnCacheableServerFn` in `./workerEntry`). A missing, stale, forged or
22
+ * mismatched marker just keeps today's `no-store` — it can never produce a
23
+ * wrong response.
24
+ *
25
+ * Wired automatically: `decoVitePlugin` supplies `sdk/startEntry` as the Start
26
+ * entry when a site has no `src/start.ts` of its own. A site that owns one
27
+ * composes this itself:
28
+ *
29
+ * ```ts
30
+ * import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";
31
+ * export const startInstance = createStart(() => ({
32
+ * serverFns: { fetch: decoServerFnFetch },
33
+ * }));
34
+ * ```
35
+ */
36
+
37
+ import { CSEG_GLOBAL, CSEG_PARAM } from "./cdnSegment";
38
+
39
+ function publishedMarker(): string | null {
40
+ if (typeof window === "undefined") return null;
41
+ const v = (window as unknown as Record<string, unknown>)[CSEG_GLOBAL];
42
+ return typeof v === "string" && v.length > 0 ? v : null;
43
+ }
44
+
45
+ /**
46
+ * Drop-in `serverFns.fetch`. Falls back to a plain `fetch` whenever there is no
47
+ * marker to echo — no marker simply means the response stays uncached in front
48
+ * of the Worker, which is the previous behaviour.
49
+ */
50
+ export const decoServerFnFetch: typeof fetch = (input, init) => {
51
+ // TanStack's serverFnFetcher always calls with the URL already built as a
52
+ // string (start-client-core/src/client-rpc/serverFnFetcher.ts). Anything else
53
+ // goes through untouched.
54
+ if (typeof input !== "string") return fetch(input, init);
55
+ const marker = publishedMarker();
56
+ if (!marker) return fetch(input, init);
57
+ const sep = input.includes("?") ? "&" : "?";
58
+ return fetch(`${input}${sep}${CSEG_PARAM}=${encodeURIComponent(marker)}`, init);
59
+ };
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildSpeculationRules,
4
+ DEFAULT_EXCLUDED_HREF_MATCHES,
5
+ getSpeculationRules,
6
+ setSpeculationRules,
7
+ } from "./speculationRules";
8
+
9
+ describe("buildSpeculationRules", () => {
10
+ it("defaults to prerender + moderate + document-wide, with safe exclusions", () => {
11
+ const rule = JSON.parse(buildSpeculationRules());
12
+ expect(rule.prerender).toHaveLength(1);
13
+ const entry = rule.prerender[0];
14
+ expect(entry.eagerness).toBe("moderate");
15
+ // first condition targets all internal links; rest are exclusions
16
+ expect(entry.where.and[0]).toEqual({ href_matches: "/*" });
17
+ const excluded = entry.where.and
18
+ .filter((c: { not?: { href_matches: string } }) => c.not)
19
+ .map((c: { not: { href_matches: string } }) => c.not.href_matches);
20
+ expect(excluded).toEqual([...DEFAULT_EXCLUDED_HREF_MATCHES]);
21
+ });
22
+
23
+ it("scopes to a link selector when given (excludes client-routed links)", () => {
24
+ const rule = JSON.parse(
25
+ buildSpeculationRules({ linkSelector: "[data-prerender] a[href]" }),
26
+ );
27
+ expect(rule.prerender[0].where.and[0]).toEqual({
28
+ selector_matches: "[data-prerender] a[href]",
29
+ });
30
+ });
31
+
32
+ it("merges extra exclusions and honors action/eagerness", () => {
33
+ const rule = JSON.parse(
34
+ buildSpeculationRules({
35
+ action: "prefetch",
36
+ eagerness: "conservative",
37
+ excludeHrefMatches: ["/*/p"],
38
+ }),
39
+ );
40
+ expect(rule.prefetch).toBeDefined();
41
+ expect(rule.prerender).toBeUndefined();
42
+ expect(rule.prefetch[0].eagerness).toBe("conservative");
43
+ const excluded = rule.prefetch[0].where.and
44
+ .filter((c: { not?: unknown }) => c.not)
45
+ .map((c: { not: { href_matches: string } }) => c.not.href_matches);
46
+ expect(excluded).toContain("/*/p");
47
+ expect(excluded).toContain("/checkout*");
48
+ });
49
+
50
+ it("can replace the default exclusions entirely", () => {
51
+ const rule = JSON.parse(
52
+ buildSpeculationRules({
53
+ excludeHrefMatches: ["/only-this*"],
54
+ overrideDefaultExclusions: true,
55
+ }),
56
+ );
57
+ const excluded = rule.prerender[0].where.and
58
+ .filter((c: { not?: unknown }) => c.not)
59
+ .map((c: { not: { href_matches: string } }) => c.not.href_matches);
60
+ expect(excluded).toEqual(["/only-this*"]);
61
+ });
62
+
63
+ it("emits valid JSON for selectors containing '>'", () => {
64
+ expect(() =>
65
+ JSON.parse(buildSpeculationRules({ linkSelector: "nav > a" })),
66
+ ).not.toThrow();
67
+ });
68
+ });
69
+
70
+ describe("speculation rules activation singleton", () => {
71
+ it("is disabled by default and toggles via set/get", () => {
72
+ setSpeculationRules(undefined);
73
+ expect(getSpeculationRules()).toBeUndefined();
74
+
75
+ const cfg = { linkSelector: "[data-prerender] a[href]" };
76
+ setSpeculationRules(cfg);
77
+ expect(getSpeculationRules()).toBe(cfg);
78
+
79
+ setSpeculationRules(undefined);
80
+ expect(getSpeculationRules()).toBeUndefined();
81
+ });
82
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Speculation Rules API helper.
3
+ *
4
+ * Builds the JSON for a `<script type="speculationrules">` tag so the browser
5
+ * prerenders (or prefetches) the next document ahead of a real navigation,
6
+ * making it feel instant. This only helps *document* navigations — raw `<a>`
7
+ * links that leave the SPA (mega-menu → category, footer → institutional).
8
+ * Client-routed `<Link>`s are intercepted by the router and never benefit, so
9
+ * scope the candidates to the elements that actually do hard navigations via
10
+ * `linkSelector` (e.g. a header/menu container marked `[data-prerender]`).
11
+ *
12
+ * Ships DISABLED: `DecoRootLayout` emits nothing unless a site passes a
13
+ * `speculationRules` config. Activate per-site only once the site's own
14
+ * analytics/pixel loaders are prerender-guarded (see ANALYTICS_SCRIPT /
15
+ * gtmScript in @decocms/blocks/sdk/analytics), otherwise a prerender that
16
+ * never activates can double-count pageviews.
17
+ *
18
+ * @see https://developer.chrome.com/docs/web-platform/prerender-pages
19
+ */
20
+
21
+ export type SpeculationAction = "prerender" | "prefetch";
22
+
23
+ /**
24
+ * How aggressively the browser speculates:
25
+ * - `immediate` — as soon as the rule is seen (use sparingly).
26
+ * - `eager` — on the smallest hint of interaction.
27
+ * - `moderate` — on hover (~200ms) / pointerdown. Good default for menus.
28
+ * - `conservative` — on pointerdown only (cheapest; dial down to this if
29
+ * speculative server load gets high).
30
+ */
31
+ export type SpeculationEagerness =
32
+ | "immediate"
33
+ | "eager"
34
+ | "moderate"
35
+ | "conservative";
36
+
37
+ export interface SpeculationRulesConfig {
38
+ /**
39
+ * `prerender` (instant nav, runs the page's JS in a hidden document — requires
40
+ * prerender-safe analytics) or `prefetch` (cheaper, fetches the HTML only, no
41
+ * JS execution). Default: `"prerender"`.
42
+ */
43
+ action?: SpeculationAction;
44
+ /** Default: `"moderate"`. */
45
+ eagerness?: SpeculationEagerness;
46
+ /**
47
+ * CSS selector for candidate anchors. Restricts speculation to hard-navigation
48
+ * links and excludes client-routed `<Link>`s by construction.
49
+ * Example: `"[data-prerender] a[href]"`. When omitted, all same-origin
50
+ * internal links (`/*`) are candidates.
51
+ */
52
+ linkSelector?: string;
53
+ /**
54
+ * Extra pathname URLPattern strings to exclude, merged with the built-in
55
+ * stateful/proxy exclusions. Example: `["/*\/p"]` to skip PDPs.
56
+ */
57
+ excludeHrefMatches?: string[];
58
+ /**
59
+ * Replace the built-in stateful/proxy exclusions instead of extending them.
60
+ * Only set this if you know none of the default-excluded paths exist.
61
+ * Default: `false` (keep the safe defaults).
62
+ */
63
+ overrideDefaultExclusions?: boolean;
64
+ }
65
+
66
+ /**
67
+ * Paths that are unsafe or wasteful to speculatively load: stateful/auth pages
68
+ * (session side effects) and typical proxied/API routes (uncacheable, fresh-0).
69
+ * Mirrors the `private` cache profile.
70
+ */
71
+ export const DEFAULT_EXCLUDED_HREF_MATCHES: readonly string[] = [
72
+ "/checkout*",
73
+ "/account*",
74
+ "/_secure/*",
75
+ "/login*",
76
+ "/logout*",
77
+ "/cart*",
78
+ "/api/*",
79
+ ];
80
+
81
+ type WhereCondition =
82
+ | { href_matches: string }
83
+ | { selector_matches: string }
84
+ | { not: WhereCondition }
85
+ | { and: WhereCondition[] };
86
+
87
+ /**
88
+ * Builds the JSON string for a `<script type="speculationrules">` tag from a
89
+ * typed config. Safe stateful/proxy exclusions are baked in by default.
90
+ */
91
+ export function buildSpeculationRules(config: SpeculationRulesConfig = {}): string {
92
+ const {
93
+ action = "prerender",
94
+ eagerness = "moderate",
95
+ linkSelector,
96
+ excludeHrefMatches = [],
97
+ overrideDefaultExclusions = false,
98
+ } = config;
99
+
100
+ const exclusions = overrideDefaultExclusions
101
+ ? excludeHrefMatches
102
+ : [...DEFAULT_EXCLUDED_HREF_MATCHES, ...excludeHrefMatches];
103
+
104
+ const conditions: WhereCondition[] = [
105
+ linkSelector
106
+ ? { selector_matches: linkSelector }
107
+ : { href_matches: "/*" },
108
+ ...exclusions.map((href) => ({ not: { href_matches: href } } as WhereCondition)),
109
+ ];
110
+
111
+ return JSON.stringify({
112
+ [action]: [{ where: { and: conditions }, eagerness }],
113
+ });
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Site-level activation (module singleton)
118
+ //
119
+ // Set once at worker init via `createDecoWorkerEntry({ speculationRules })` —
120
+ // same lifecycle and isolate as setRenderShell — and read at SSR render time by
121
+ // DecoRootLayout. This is why activation lives in the worker-entry options
122
+ // (like `asJson` / `renderJson`) while the tag is still rendered in the HTML
123
+ // (cache-safe) instead of injected at the edge. Ships DISABLED (undefined).
124
+ // ---------------------------------------------------------------------------
125
+
126
+ let activeConfig: SpeculationRulesConfig | undefined;
127
+
128
+ /** Enable Speculation Rules site-wide. Called by createDecoWorkerEntry. */
129
+ export function setSpeculationRules(config: SpeculationRulesConfig | undefined): void {
130
+ activeConfig = config;
131
+ }
132
+
133
+ /** Current site-wide Speculation Rules config, or undefined when disabled. */
134
+ export function getSpeculationRules(): SpeculationRulesConfig | undefined {
135
+ return activeConfig;
136
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Default TanStack Start entry, supplied by the framework when a site doesn't
3
+ * have its own `src/start.ts`.
4
+ *
5
+ * It exists so that CDN caching of `/_serverFn` is something a site gets from a
6
+ * version bump instead of a per-site PR. The only thing it wires is
7
+ * `decoServerFnFetch`, which appends the cache segment to server-function URLs
8
+ * — see `./cdnSegment` for why that is what makes the CDN key match the
9
+ * Worker's.
10
+ *
11
+ * A site that declares its own `src/start.ts` keeps it: `decoVitePlugin` only
12
+ * aliases this module in when that file is absent. Such a site opts into the
13
+ * CDN behaviour by composing `decoServerFnFetch` itself:
14
+ *
15
+ * ```ts
16
+ * import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";
17
+ * export const startInstance = createStart(() => ({
18
+ * serverFns: { fetch: decoServerFnFetch },
19
+ * }));
20
+ * ```
21
+ *
22
+ * The export name is load-bearing: `@tanstack/start-client-core`'s
23
+ * `hydrateStart` does `import { startInstance } from "#tanstack-start-entry"`.
24
+ */
25
+
26
+ import { createStart } from "@tanstack/react-start";
27
+ import { decoServerFnFetch } from "./serverFnFetch";
28
+
29
+ export const startInstance = createStart(() => ({
30
+ serverFns: { fetch: decoServerFnFetch },
31
+ }));
@@ -0,0 +1,147 @@
1
+ // @vitest-environment node
2
+
3
+ import { setBlocks } from "@decocms/blocks/cms";
4
+ import { clearLoaderCache, createCachedLoader } from "@decocms/blocks/sdk/cachedLoader";
5
+ import {
6
+ type CacheKVNamespace,
7
+ createKVCacheStorage,
8
+ getCacheStorageContext,
9
+ } from "@decocms/blocks/sdk/cacheStorage";
10
+ import { beforeEach, describe, expect, it, vi } from "vitest";
11
+ import { createDecoWorkerEntry } from "./workerEntry";
12
+
13
+ function makeKV(): CacheKVNamespace {
14
+ const values = new Map<string, string>();
15
+ return {
16
+ get: async (key) => values.get(key) ?? null,
17
+ put: async (key, value) => {
18
+ values.set(key, value);
19
+ },
20
+ delete: async (key) => {
21
+ values.delete(key);
22
+ },
23
+ };
24
+ }
25
+ const jobs: Promise<unknown>[] = [];
26
+ const ctx = { waitUntil: (work: Promise<unknown>) => jobs.push(work), passThroughOnException() {} };
27
+ async function flush() {
28
+ while (jobs.length) await Promise.all(jobs.splice(0));
29
+ }
30
+ const request = (path = "/", headers: Record<string, string> = {}) =>
31
+ new Request(`https://shop.test${path}`, {
32
+ headers: { accept: "text/html", ...headers },
33
+ });
34
+ const env = () => ({ CACHE: makeKV(), BUILD_HASH: "build-A", PURGE_TOKEN: "test-token" });
35
+ const options = {
36
+ observability: false as const,
37
+ outboundUserAgent: false as const,
38
+ geoCacheKey: "off" as const,
39
+ buildSegment: (req: Request) => ({
40
+ device: "desktop" as const,
41
+ loggedIn: req.headers.has("authorization"),
42
+ }),
43
+ cacheStorage: (bindings: Record<string, unknown>) =>
44
+ createKVCacheStorage(bindings.CACHE as CacheKVNamespace),
45
+ };
46
+ beforeEach(() => {
47
+ clearLoaderCache();
48
+ setBlocks({});
49
+ });
50
+
51
+ describe("worker cache storage injection", () => {
52
+ it("uses the injected KV for HTML, with no global Cache API, across worker instances", async () => {
53
+ const origin = {
54
+ fetch: vi.fn(async () => new Response("page", { headers: { "content-type": "text/html" } })),
55
+ };
56
+ const bindings = env();
57
+ const first = createDecoWorkerEntry(origin, options);
58
+ expect((await first.fetch(request(), bindings, ctx)).headers.get("x-cache")).toBe("MISS");
59
+ await flush();
60
+ const second = createDecoWorkerEntry(origin, options);
61
+ const hit = await second.fetch(request(), bindings, ctx);
62
+ expect(hit.headers.get("x-cache")).toBe("HIT");
63
+ expect(await hit.text()).toBe("page");
64
+ expect(origin.fetch).toHaveBeenCalledTimes(1);
65
+ const changed = await second.fetch(request(), { ...bindings, BUILD_HASH: "build-B" }, ctx);
66
+ expect(changed.headers.get("x-cache")).toBe("MISS");
67
+ await flush();
68
+ });
69
+
70
+ it("uses the same adapter for loader results and isolates authenticated requests", async () => {
71
+ const upstream = vi.fn(async () => ({ n: upstream.mock.calls.length }));
72
+ const loader = createCachedLoader("worker-cache-test", upstream, {
73
+ policy: "no-cache",
74
+ maxAge: 60_000,
75
+ });
76
+ const origin = {
77
+ fetch: vi.fn(async () => {
78
+ expect(getCacheStorageContext()).toBeDefined();
79
+ return Response.json(await loader({}), { headers: { "cache-control": "no-store" } });
80
+ }),
81
+ };
82
+ const worker = createDecoWorkerEntry(origin, options);
83
+ const bindings = env();
84
+ await worker.fetch(request("/one"), bindings, ctx);
85
+ await flush();
86
+ clearLoaderCache();
87
+ await worker.fetch(request("/two"), bindings, ctx);
88
+ expect(upstream).toHaveBeenCalledTimes(1);
89
+ await worker.fetch(request("/two", { authorization: "Bearer private" }), bindings, ctx);
90
+ expect(upstream).toHaveBeenCalledTimes(2);
91
+ await flush();
92
+ });
93
+
94
+ it("stores public server-function POSTs by body and excludes unmarked responses", async () => {
95
+ const origin = {
96
+ fetch: vi.fn(
97
+ async (req: Request) =>
98
+ new Response(await req.text(), {
99
+ headers: { "X-Deco-Cacheable": "true", "content-type": "application/json" },
100
+ }),
101
+ ),
102
+ };
103
+ const worker = createDecoWorkerEntry(origin, options);
104
+ const bindings = env();
105
+ const post = (body: string) =>
106
+ new Request("https://shop.test/_serverFn/public", { method: "POST", body });
107
+ expect((await worker.fetch(post('"first"'), bindings, ctx)).headers.get("x-cache")).toBe(
108
+ "MISS",
109
+ );
110
+ await flush();
111
+ const hit = await worker.fetch(post('"first"'), bindings, ctx);
112
+ expect(hit.headers.get("x-cache")).toBe("HIT");
113
+ expect(await hit.text()).toBe('"first"');
114
+ expect((await worker.fetch(post('"second"'), bindings, ctx)).headers.get("x-cache")).toBe(
115
+ "MISS",
116
+ );
117
+ await flush();
118
+ expect(origin.fetch).toHaveBeenCalledTimes(2);
119
+ const unmarked = createDecoWorkerEntry({ fetch: async () => new Response("private") }, options);
120
+ expect((await unmarked.fetch(post('"third"'), bindings, ctx)).headers.get("x-cache")).toBe(
121
+ "BYPASS",
122
+ );
123
+ });
124
+
125
+ it("purges HTML through the configured adapter", async () => {
126
+ const worker = createDecoWorkerEntry(
127
+ { fetch: async () => new Response("page", { headers: { "content-type": "text/html" } }) },
128
+ options,
129
+ );
130
+ const bindings = env();
131
+ await worker.fetch(request(), bindings, ctx);
132
+ await flush();
133
+ expect((await worker.fetch(request(), bindings, ctx)).headers.get("x-cache")).toBe("HIT");
134
+ const purge = await worker.fetch(
135
+ new Request("https://shop.test/_cache/purge", {
136
+ method: "POST",
137
+ headers: { authorization: "Bearer test-token" },
138
+ body: JSON.stringify({ paths: ["/"] }),
139
+ }),
140
+ bindings,
141
+ ctx,
142
+ );
143
+ expect(purge.status).toBe(200);
144
+ expect((await worker.fetch(request(), bindings, ctx)).headers.get("x-cache")).toBe("MISS");
145
+ await flush();
146
+ });
147
+ });