Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/tanstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
31 changes: 31 additions & 0 deletions packages/tanstack/src/sdk/startEntry.ts
Original file line number Diff line number Diff line change
@@ -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 },
}));
50 changes: 50 additions & 0 deletions packages/tanstack/src/sdk/workerEntry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 14 additions & 9 deletions packages/tanstack/src/sdk/workerEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions packages/tanstack/src/vite/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down