diff --git a/packages/tanstack/package.json b/packages/tanstack/package.json index 809e172b..b7f89a07 100644 --- a/packages/tanstack/package.json +++ b/packages/tanstack/package.json @@ -17,6 +17,7 @@ "./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts", "./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts", "./sdk/serverFnFetch": "./src/sdk/serverFnFetch.ts", + "./sdk/startEntry": "./src/sdk/startEntry.ts", "./sdk/cdnSegment": "./src/sdk/cdnSegment.ts" }, "scripts": { diff --git a/packages/tanstack/src/sdk/startEntry.ts b/packages/tanstack/src/sdk/startEntry.ts new file mode 100644 index 00000000..81cb56c7 --- /dev/null +++ b/packages/tanstack/src/sdk/startEntry.ts @@ -0,0 +1,31 @@ +/** + * Default TanStack Start entry, supplied by the framework when a site doesn't + * have its own `src/start.ts`. + * + * It exists so that CDN caching of `/_serverFn` is something a site gets from a + * version bump instead of a per-site PR. The only thing it wires is + * `decoServerFnFetch`, which appends the cache segment to server-function URLs + * — see `./cdnSegment` for why that is what makes the CDN key match the + * Worker's. + * + * A site that declares its own `src/start.ts` keeps it: `decoVitePlugin` only + * aliases this module in when that file is absent. Such a site opts into the + * CDN behaviour by composing `decoServerFnFetch` itself: + * + * ```ts + * import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch"; + * export const startInstance = createStart(() => ({ + * serverFns: { fetch: decoServerFnFetch }, + * })); + * ``` + * + * The export name is load-bearing: `@tanstack/start-client-core`'s + * `hydrateStart` does `import { startInstance } from "#tanstack-start-entry"`. + */ + +import { createStart } from "@tanstack/react-start"; +import { decoServerFnFetch } from "./serverFnFetch"; + +export const startInstance = createStart(() => ({ + serverFns: { fetch: decoServerFnFetch }, +})); diff --git a/packages/tanstack/src/sdk/workerEntry.test.ts b/packages/tanstack/src/sdk/workerEntry.test.ts index aca60e5d..a0b97f69 100644 --- a/packages/tanstack/src/sdk/workerEntry.test.ts +++ b/packages/tanstack/src/sdk/workerEntry.test.ts @@ -838,6 +838,56 @@ describe('cdnCacheControl: "serverfn-segment"', () => { }); }); +describe("cdnCacheControl default", () => { + const BUILD_D = "abc123"; + + // The default flipped from "no-store" to "serverfn-segment". That is only + // safe because it is inert until a verified marker arrives — these two tests + // are what make that claim checkable rather than asserted in a comment. + it("is inert for a client that never sends a marker", async () => { + const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + buildSegment: () => ({ device: "desktop" as const }), + }); + const html = await w.fetch(new Request("https://example.com/"), { BUILD_HASH: BUILD_D }, MOCK_CTX); + expect(html.headers.get("CDN-Cache-Control")).toBe("no-store"); + + const sfn = await w.fetch( + new Request("https://example.com/_serverFn/loadCmsPage"), + { BUILD_HASH: BUILD_D }, + MOCK_CTX, + ); + expect(sfn.headers.get("CDN-Cache-Control")).toBe("no-store"); + }); + + it("engages once the client sends a valid marker, with no site config", async () => { + const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + buildSegment: () => ({ device: "desktop" as const }), + }); + const res = await w.fetch( + new Request(`https://example.com/_serverFn/loadCmsPage?__cseg=desktop.${BUILD_D}`), + { BUILD_HASH: BUILD_D }, + MOCK_CTX, + ); + expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/); + }); + + it('still honours an explicit opt-out with "no-store"', async () => { + const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, { + observability: false, + cdnCacheControl: "no-store", + buildSegment: () => ({ device: "desktop" as const }), + }); + const res = await w.fetch( + new Request(`https://example.com/_serverFn/loadCmsPage?__cseg=desktop.${BUILD_D}`), + { BUILD_HASH: BUILD_D }, + MOCK_CTX, + ); + expect(res.headers.get("CDN-Cache-Control")).toBe("no-store"); + }); +}); + describe("CDN-Cache-Control at the single response exit", () => { it("defaults to no-store on early returns that never reach dressResponse", async () => { // `?asJson` returns the fully resolved page — loaders run with the caller's diff --git a/packages/tanstack/src/sdk/workerEntry.ts b/packages/tanstack/src/sdk/workerEntry.ts index d46ad6f2..007a270b 100644 --- a/packages/tanstack/src/sdk/workerEntry.ts +++ b/packages/tanstack/src/sdk/workerEntry.ts @@ -508,13 +508,18 @@ export interface DecoWorkerEntryOptions { * desktop HTML to mobile, one region's to another, or a crawler's eager * render to humans. * - * - `"no-store"` (default): the CDN never caches; every request invokes the - * Worker. Always correct, never fast. - * - `"serverfn-segment"`: opt in to CDN caching for `/_serverFn` requests - * whose URL carries a verified `__cseg` marker (see `./cdnSegment` and - * `decoServerFnFetch`). The marker makes the CDN's key equivalent to the - * Worker's. HTML documents keep `no-store` — the initial navigation is a - * browser request with no client hook to attach a marker. + * - `"serverfn-segment"` (default): allow CDN caching for `/_serverFn` + * requests whose URL carries a verified `__cseg` marker (see + * `./cdnSegment` and `decoServerFnFetch`). The marker makes the CDN's key + * equivalent to the Worker's. HTML documents keep `no-store` — the initial + * navigation is a browser request with no client hook to attach a marker. + * + * This is the default because it is inert until a marker actually arrives: + * a client that never sends `__cseg` keeps getting `no-store`, so enabling + * it cannot change behaviour on its own. That is what lets a site pick the + * feature up from a version bump rather than a per-site change. + * - `"no-store"`: the CDN never caches; every request invokes the Worker. + * Always correct, never fast. Set this to opt OUT. * - `"match-profile"`: mirror the profile's `edge.fresh` as a CDN TTL. Sound * ONLY when the cache key is the raw URL — no `buildSegment`, * `deviceSpecificKeys: false`, `geoCacheKey: "off"`. Since @@ -528,7 +533,7 @@ export interface DecoWorkerEntryOptions { * whatever sits in front of the Worker has to reproduce the key above, and * the initial navigation has no client hook to attach a marker to. * - * @default "no-store" + * @default "serverfn-segment" */ cdnCacheControl?: | "no-store" @@ -995,7 +1000,7 @@ export function createDecoWorkerEntry( geoCacheKey: geoCacheKeyOpt = "auto", safeCookies: safeCookiesOpt = DEFAULT_SAFE_COOKIES, staticPaths: staticPathsOpt = DEFAULT_STATIC_PATHS, - cdnCacheControl: cdnCacheControlOpt = "no-store", + cdnCacheControl: cdnCacheControlOpt = "serverfn-segment", observability: observabilityOpt, outboundUserAgent: outboundUserAgentOpt, speculationRules: speculationRulesOpt, diff --git a/packages/tanstack/src/vite/plugin.js b/packages/tanstack/src/vite/plugin.js index 130fab4f..05c4b2f8 100644 --- a/packages/tanstack/src/vite/plugin.js +++ b/packages/tanstack/src/vite/plugin.js @@ -690,6 +690,33 @@ export function decoVitePlugin() { /** @type {import("vite").UserConfig} */ const cfg = {}; + // Supply a default TanStack Start entry when the site has none. + // + // `#tanstack-start-entry` is a subpath import of + // @tanstack/start-client-core; with no `src/start.ts` it resolves to a + // fake entry exporting `startInstance = undefined`, and the site gets no + // `serverFns.fetch` hook. That hook is what appends the cache segment to + // `/_serverFn` URLs (see sdk/cdnSegment), so without it CDN caching of + // SPA navigation can never engage. + // + // Aliasing it here means a site gets that from a version bump instead of + // a per-site PR. Strictly conditional on the file being absent: a site + // that owns its `src/start.ts` must keep it, since ours would otherwise + // silently replace whatever else it configures. + const siteStartEntry = ["ts", "tsx", "js", "jsx"] + .map((ext) => path.join(process.cwd(), "src", `start.${ext}`)) + .find((f) => existsSync(f)); + + if (!siteStartEntry) { + cfg.resolve = { + ...cfg.resolve, + alias: { + ...cfg.resolve?.alias, + "#tanstack-start-entry": "@decocms/tanstack/sdk/startEntry", + }, + }; + } + // Allow tunnel domains through Vite's host check. // .deco.studio is the new admin frontend; both real-world Deco sites // (casaevideo-storefront, baggagio-tanstack) duplicated this list to