Skip to content
Open
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
4 changes: 3 additions & 1 deletion packages/tanstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
"./daemon": "./src/daemon/index.ts",
"./sdk/createInvoke": "./src/sdk/createInvoke.ts",
"./sdk/cookiePassthrough": "./src/sdk/cookiePassthrough.ts",
"./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts"
"./sdk/deferredSectionLoader": "./src/sdk/deferredSectionLoader.ts",
"./sdk/serverFnFetch": "./src/sdk/serverFnFetch.ts",
"./sdk/cdnSegment": "./src/sdk/cdnSegment.ts"
},
"scripts": {
"build": "tsc",
Expand Down
5 changes: 5 additions & 0 deletions packages/tanstack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,8 @@ export type {
// parses (never imports) to emit real top-level createServerFn declarations
// into each site's own src/server/invoke.gen.ts. Import it from the
// dedicated "@decocms/tanstack/sdk/createInvoke" subpath instead.
// decoServerFnFetch is intentionally NOT re-exported from this root barrel
// either. A site wires it in `src/start.ts`, which is part of the CLIENT
// bundle; importing it from here would drag `createDecoWorkerEntry` (and the
// whole server graph behind it) into that bundle. Import it from the dedicated
// "@decocms/tanstack/sdk/serverFnFetch" subpath instead.
55 changes: 55 additions & 0 deletions packages/tanstack/src/sdk/cdnSegment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { segmentToken } from "./cdnSegment";

const BUILD = "abc123";

describe("segmentToken", () => {
it("anonymous: token is device.build", () => {
expect(segmentToken({ device: "mobile" }, BUILD)).toBe("mobile.abc123");
expect(segmentToken({ device: "desktop" }, BUILD)).toBe("desktop.abc123");
// tablet is its own detectDevice value — it must not collapse into mobile
expect(segmentToken({ device: "tablet" }, BUILD)).toBe("tablet.abc123");
});

it("mobile and desktop never share a token", () => {
expect(segmentToken({ device: "mobile" }, BUILD)).not.toBe(
segmentToken({ device: "desktop" }, BUILD),
);
});

it("personalization disables CDN caching", () => {
expect(segmentToken({ device: "mobile", loggedIn: true }, BUILD)).toBeNull();
expect(segmentToken({ device: "mobile", regionId: "v2.XYZ" }, BUILD)).toBeNull();
expect(segmentToken({ device: "mobile", salesChannel: "3" }, BUILD)).toBeNull();
});

it("an unknown custom dimension fails closed", () => {
// A site adding its own SegmentKey field must not silently share entries
// across that dimension.
expect(segmentToken({ device: "mobile", storeId: "sp-01" }, BUILD)).toBeNull();
expect(segmentToken({ device: "mobile", flags: ["promo"] }, BUILD)).toBeNull();
});

it("empty-ish custom values do not disable caching", () => {
// These carry no dimension — hashSegment skips them too, so the Worker key
// is identical with or without them.
expect(segmentToken({ device: "mobile", storeId: "" }, BUILD)).toBe("mobile.abc123");
expect(segmentToken({ device: "mobile", beta: false }, BUILD)).toBe("mobile.abc123");
expect(segmentToken({ device: "mobile", flags: [] }, BUILD)).toBe("mobile.abc123");
expect(segmentToken({ device: "mobile", loggedIn: undefined }, BUILD)).toBe("mobile.abc123");
});

it("missing or dev build hash disables CDN caching", () => {
// without a build hash there is no way to invalidate on deploy — the CDN
// would serve stale code
expect(segmentToken({ device: "mobile" }, undefined)).toBeNull();
expect(segmentToken({ device: "mobile" }, "")).toBeNull();
expect(segmentToken({ device: "mobile" }, "dev")).toBeNull();
});

it("a different build yields a different token (invalidates on deploy)", () => {
expect(segmentToken({ device: "mobile" }, "buildA")).not.toBe(
segmentToken({ device: "mobile" }, "buildB"),
);
});
});
74 changes: 74 additions & 0 deletions packages/tanstack/src/sdk/cdnSegment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Segment marker on `/_serverFn` URLs, so Cloudflare's CDN can serve the
* response without invoking the Worker.
*
* The problem: the CDN keys on the raw URL. The Worker keys on a SYNTHETIC
* Request carrying `__seg`/`__v`/`__bot`/`__fetch`/`__abf` (`buildCacheKey` in
* `./workerEntry`) — params the CDN never sees. That mismatch is why the
* framework stamps `CDN-Cache-Control: no-store` on every public response, and
* why 100% of traffic comes back `cf-cache-status: BYPASS`.
*
* The fix: put the segment in the URL itself. The CDN's key then becomes
* equivalent to the Worker's, and relaxing the `no-store` is safe.
*
* This is the ONLY definition of the token format. The client uses it to build
* the marker, the worker uses it to recompute and compare — same function on
* both sides, so they cannot drift.
*
* Note the split of responsibilities: this module covers what is observable on
* BOTH sides (device + build). Request-only dimensions — bot UA, the A/B
* cookie — are checked by the worker alone, in `cdnServerFnToken`. A client
* that can't see them just emits a marker that fails verification, which keeps
* the existing `no-store`.
*/

import type { Device } from "@decocms/blocks/sdk/detectDevice";

/** `__d` is reserved: `workerEntry` uses `?__d=` as an OTel debug flag. */
export const CSEG_PARAM = "__cseg";

/**
* The subset of `SegmentKey` this token can express.
*
* Deliberately structural rather than importing `SegmentKey` from
* `./workerEntry`: this module is bundled into the CLIENT, and workerEntry
* pulls in the whole server graph.
*/
export interface CdnSegment {
device: Device;
loggedIn?: boolean;
salesChannel?: string;
regionId?: string;
[key: string]: unknown;
}

/**
* The segment token, or `null` when this request must not be CDN-cached.
*
* Returns `null` — keeping today's `no-store` — when:
*
* - there is any personalization beyond device (`loggedIn`, `salesChannel`,
* `regionId`, or any custom `SegmentKey` field a site added). Only device is
* safe to expose in a URL; everything else has to keep resolving in the
* Worker. Unknown fields fail closed precisely because we can't know whether
* a site's custom dimension is personal.
* - there is no build hash, or it is `"dev"`. The build is part of the token
* because deploying does NOT purge the CDN (the framework's purge clears
* `caches.default`), so the URL has to change on its own when the bundle does.
*/
export function segmentToken(seg: CdnSegment, buildHash: string | undefined): string | null {
if (!buildHash || buildHash === "dev") return null;
if (!seg.device) return null;
if (seg.loggedIn || seg.salesChannel || seg.regionId) return null;

// Any dimension we don't recognize is assumed personal.
for (const [key, value] of Object.entries(seg)) {
if (key === "device") continue;
if (value === undefined || value === false) continue;
if (Array.isArray(value) && value.length === 0) continue;
if (value === "") continue;
return null;
}

return `${seg.device}.${buildHash}`;
}
63 changes: 63 additions & 0 deletions packages/tanstack/src/sdk/serverFnFetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Client-side `serverFns.fetch` hook that attaches the CDN segment marker to
* `/_serverFn` URLs.
*
* Pairs with `cdnCacheControl: "serverfn-segment"` on `createDecoWorkerEntry`.
* Attaching the segment to the URL makes Cloudflare's CDN key (the raw URL)
* equivalent to the key the Worker builds internally — see `./cdnSegment` for
* why that is the whole problem.
*
* Only the client can do this: the initial HTML document is a browser
* navigation with no JS hook. This covers SPA data requests and prefetches,
* which is the volume Speculation Rules creates.
*
* SECURITY: the marker is a HINT, not a source of truth. The worker recomputes
* the segment from the request itself and only releases the CDN when it matches
* exactly (`cdnCacheableServerFn` in `./workerEntry`). A missing, diverging,
* forged or stale-build marker just keeps today's `no-store` — it can never
* produce a wrong response.
*
* @example
* ```ts
* // src/start.ts
* import { createStart } from "@tanstack/react-start";
* import { decoServerFnFetch } from "@decocms/tanstack";
*
* export const startInstance = createStart(() => ({
* serverFns: { fetch: decoServerFnFetch },
* }));
* ```
*/

import { detectDevice } from "@decocms/blocks/sdk/detectDevice";
import { CSEG_PARAM, segmentToken } from "./cdnSegment";

declare const __DECO_BUILD_HASH__: string | undefined;

function buildHash(): string | undefined {
return typeof __DECO_BUILD_HASH__ !== "undefined" ? __DECO_BUILD_HASH__ : undefined;
}

function segmentMarker(): string | null {
if (typeof navigator === "undefined") return null;
// Device is the only dimension observable on the client. If this request is
// in fact from a logged-in user, or in a region, or an A/B cohort, the worker
// catches it during verification and keeps the no-store — the marker simply
// won't match.
return segmentToken({ device: detectDevice(navigator.userAgent) }, buildHash());
}

/**
* Drop-in `serverFns.fetch` implementation. Falls back to a plain `fetch` when
* there is no marker to add.
*/
export const decoServerFnFetch: typeof fetch = (input, init) => {
// TanStack's serverFnFetcher always calls with the URL already built as a
// string (start-client-core/src/client-rpc/serverFnFetcher.ts). Anything else
// goes through untouched.
if (typeof input !== "string") return fetch(input, init);
const marker = segmentMarker();
if (!marker) return fetch(input, init);
const sep = input.includes("?") ? "&" : "?";
return fetch(`${input}${sep}${CSEG_PARAM}=${marker}`, init);
};
123 changes: 123 additions & 0 deletions packages/tanstack/src/sdk/workerEntry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,107 @@ describe("draft preview (pull-based)", () => {
});
});

describe('cdnCacheControl: "serverfn-segment"', () => {
const BUILD = "abc123";
const ENV = { BUILD_HASH: BUILD };

function worker(overrides: Record<string, unknown> = {}) {
return createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
observability: false,
cdnCacheControl: "serverfn-segment",
buildSegment: (req: Request) => ({
device: req.headers.get("user-agent")?.includes("iPhone")
? ("mobile" as const)
: ("desktop" as const),
...(req.headers.get("cookie")?.includes("auth=1") ? { loggedIn: true } : {}),
}),
...overrides,
});
}

const sfnUrl = (marker?: string) =>
`https://example.com/_serverFn/loadCmsPage${marker ? `?__cseg=${marker}` : ""}`;

async function cdnHeader(url: string, headers: Record<string, string> = {}) {
const res = await worker().fetch(new Request(url, { headers }), ENV, MOCK_CTX);
return res.headers.get("CDN-Cache-Control");
}

it("releases the CDN when the marker matches the recomputed segment", async () => {
expect(await cdnHeader(sfnUrl(`desktop.${BUILD}`))).toMatch(/^public, max-age=\d+$/);
});

it("keeps no-store without a marker (bot, curl, old client)", async () => {
expect(await cdnHeader(sfnUrl())).toBe("no-store");
});

it("keeps no-store when the marker is for another device", async () => {
// The forged/diverging case: a desktop request claiming a mobile entry
// would let the CDN serve mobile HTML to desktop.
expect(await cdnHeader(sfnUrl(`mobile.${BUILD}`))).toBe("no-store");
});

it("keeps no-store when the marker is from an older build", async () => {
// Deploying does not purge the CDN, so a stale build token must not match.
expect(await cdnHeader(sfnUrl("desktop.oldbuild"))).toBe("no-store");
});

it("keeps no-store when there is no build hash", async () => {
const res = await worker().fetch(new Request(sfnUrl(`desktop.${BUILD}`)), {}, MOCK_CTX);
expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
});

it("keeps no-store for a bot UA even with a valid marker", async () => {
// Bots render every section eagerly (~10x payload). Sharing one CDN entry
// would serve that to humans, or the deferred one to crawlers.
expect(
await cdnHeader(sfnUrl(`desktop.${BUILD}`), {
"user-agent": "Mozilla/5.0 (compatible; Googlebot/2.1)",
}),
).toBe("no-store");
});

it("keeps no-store for an A/B cohort cookie even with a valid marker", async () => {
expect(
await cdnHeader(sfnUrl(`desktop.${BUILD}`), {
cookie: "deco_segment=eyJhY3RpdmUiOlsiYSJdfQ==",
}),
).toBe("no-store");
});

it("keeps no-store for a logged-in request", async () => {
expect(await cdnHeader(sfnUrl(`desktop.${BUILD}`), { cookie: "auth=1" })).toBe("no-store");
});

it("keeps no-store on HTML documents — they carry no marker", async () => {
expect(await cdnHeader("https://example.com/some-category")).toBe("no-store");
});

it("keeps no-store when the cache key varies by geo", async () => {
// `__cf_geo` is in the Worker key but cannot be expressed in the marker nor
// reproduced by the CDN, so a site with geo keying must not release it —
// otherwise one region's regionalized data (pricing, stock, store) is
// served to another from the same colo.
const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
observability: false,
cdnCacheControl: "serverfn-segment",
geoCacheKey: "region",
buildSegment: () => ({ device: "desktop" as const }),
});
const res = await w.fetch(new Request(sfnUrl(`desktop.${BUILD}`)), ENV, MOCK_CTX);
expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
});

it("keeps no-store without buildSegment, since the logged-in bypass is inert", async () => {
const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
observability: false,
cdnCacheControl: "serverfn-segment",
});
const res = await w.fetch(new Request(sfnUrl(`desktop.${BUILD}`)), ENV, 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 Expand Up @@ -765,3 +866,25 @@ describe("CDN-Cache-Control at the single response exit", () => {
expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/);
});
});

describe('cdnCacheControl: "match-profile" guard', () => {
it("is ignored while the cache key is segmented (deviceSpecificKeys defaults to true)", async () => {
const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
observability: false,
cdnCacheControl: "match-profile",
});
const res = await w.fetch(new Request("https://example.com/some-category"), {}, MOCK_CTX);
expect(res.headers.get("CDN-Cache-Control")).toBe("no-store");
});

it("is honored only when the key really is the raw URL", async () => {
const w = createDecoWorkerEntry(MOCK_SERVER_ENTRY, {
observability: false,
cdnCacheControl: "match-profile",
deviceSpecificKeys: false,
geoCacheKey: "off",
});
const res = await w.fetch(new Request("https://example.com/some-category"), {}, MOCK_CTX);
expect(res.headers.get("CDN-Cache-Control")).toMatch(/^public, max-age=\d+$/);
});
});
Loading