@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
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
|
-
|
|
9
|
+
|
|
10
|
+
import { getRequestNonce } from "@decocms/blocks/sdk/nonce";
|
|
10
11
|
import type {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
+
const str = searchStr.startsWith("?") ? searchStr.slice(1) : searchStr;
|
|
21
|
+
if (!str) return {};
|
|
20
22
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
const params = new URLSearchParams(str);
|
|
24
|
+
const result: Record<string, string | string[]> = {};
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
+
});
|