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
35 changes: 35 additions & 0 deletions packages/tanstack/src/hooks/CdnSegmentMarker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Publishes the CDN segment token this server computed, so `decoServerFnFetch`
* can echo it back on `/_serverFn` URLs.
*
* Only the server can produce this value: the segment includes dimensions the
* browser cannot observe — region is resolved from `request.cf.regionCode`.
* A client that derives its own token therefore never matches on a
* regionalized store, which is exactly why this component exists.
*
* - **Server:** read the token from the RequestContext bag (filled by
* `createDecoWorkerEntry`) and emit a tiny inline `<script>`.
* - **Client:** the bag is a no-op stub, so the read yields undefined and this
* renders nothing — the global set during SSR is already on `window`.
*
* Safe in a cached response: the Worker's edge cache keys on the segment, so a
* stored entry carries the token of its own segment. And a stale token is
* harmless by construction — the Worker verifies by recomputing and falls back
* to `no-store` when it doesn't match.
*/
import { RequestContext } from "@decocms/blocks/sdk/requestContext";
import { CSEG_BAG_KEY, CSEG_GLOBAL } from "../sdk/cdnSegment";

export function CdnSegmentMarker() {
const token = RequestContext.getBag<string>(CSEG_BAG_KEY);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When SSR publishes a token, the client hydration tree omits this server-rendered <script> because the browser RequestContext bag is empty. React therefore reports a hydration mismatch and can discard the SSR tree; read window.__DECO_CSEG on the client and render the same script node, as DraftPreviewIndicator does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tanstack/src/hooks/CdnSegmentMarker.tsx, line 24:

<comment>When SSR publishes a token, the client hydration tree omits this server-rendered `<script>` because the browser `RequestContext` bag is empty. React therefore reports a hydration mismatch and can discard the SSR tree; read `window.__DECO_CSEG` on the client and render the same script node, as `DraftPreviewIndicator` does.</comment>

<file context>
@@ -0,0 +1,35 @@
+import { CSEG_BAG_KEY, CSEG_GLOBAL } from "../sdk/cdnSegment";
+
+export function CdnSegmentMarker() {
+  const token = RequestContext.getBag<string>(CSEG_BAG_KEY);
+  if (!token) return null;
+  return (
</file context>

if (!token) return null;
return (
<script
// A hash this server computed, not user input. JSON.stringify keeps it
// inert regardless.
dangerouslySetInnerHTML={{
__html: `window.${CSEG_GLOBAL}=${JSON.stringify(token)}`,
}}
/>
);
}
2 changes: 2 additions & 0 deletions packages/tanstack/src/hooks/DecoRootLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CdnSegmentMarker } from "./CdnSegmentMarker";
import { useEffect, type ReactNode } from "react";
import { HeadContent, Scripts, ScriptOnce, useRouterState } from "@tanstack/react-router";
import { LiveControls, Stats } from "@decocms/blocks/hooks";
Expand Down Expand Up @@ -141,6 +142,7 @@ export function DecoRootLayout({
<html lang={lang} data-theme={dataTheme} suppressHydrationWarning>
<head>
<HeadContent />
<CdnSegmentMarker />
{speculation && (
<script
type="speculationrules"
Expand Down
78 changes: 45 additions & 33 deletions packages/tanstack/src/sdk/cdnSegment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,52 +4,64 @@ 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("produces a token for an anonymous visitor", () => {
expect(segmentToken("desktop", false, BUILD)).toBeTruthy();
});

it("mobile and desktop never share a token", () => {
expect(segmentToken({ device: "mobile" }, BUILD)).not.toBe(
segmentToken({ device: "desktop" }, BUILD),
it("gives different segments different tokens", () => {
// The whole point: the URL has to distinguish what the Worker's key
// distinguishes, or a cache in front serves one visitor another's response.
const tokens = ["desktop", "mobile", "desktop|r=RJ", "desktop|r=SP", "desktop|sc=3|r=RJ"].map(
(s) => segmentToken(s, false, BUILD),
);
expect(new Set(tokens).size).toBe(tokens.length);
});

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("regionalized and non-regionalized visitors never share a token", () => {
// This is the case that made the first version inert on real stores: region
// is resolved server-side from cf.regionCode, so it MUST be in the token.
expect(segmentToken("desktop", false, BUILD)).not.toBe(
segmentToken("desktop|r=RJ", false, BUILD),
);
});

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("is stable for the same segment and build", () => {
// The worker verifies by recomputing, so the same input must always give
// the same token — otherwise nothing would ever match.
expect(segmentToken("desktop|r=RJ", false, BUILD)).toBe(
segmentToken("desktop|r=RJ", false, BUILD),
);
});

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("refuses a logged-in visitor regardless of segment precision", () => {
// Personalized responses never belong in a shared entry, however exact the
// key is.
expect(segmentToken("desktop|auth|r=RJ", true, BUILD)).toBeNull();
});

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("refuses a missing or dev build hash", () => {
// Without a build hash there is no way to invalidate on deploy — the cache
// in front would keep serving stale code.
expect(segmentToken("desktop", false, undefined)).toBeNull();
expect(segmentToken("desktop", false, "")).toBeNull();
expect(segmentToken("desktop", false, "dev")).toBeNull();
});

it("a different build yields a different token (invalidates on deploy)", () => {
expect(segmentToken({ device: "mobile" }, "buildA")).not.toBe(
segmentToken({ device: "mobile" }, "buildB"),
it("changes with the build, so a deploy invalidates it", () => {
expect(segmentToken("desktop", false, "buildA")).not.toBe(
segmentToken("desktop", false, "buildB"),
);
});

it("refuses an empty segment descriptor", () => {
expect(segmentToken("", false, BUILD)).toBeNull();
});

it("is URL-safe even when the segment carries a VTEX region id", () => {
// `v2.XXXX` contains the character an earlier format used as a separator;
// hashing sidesteps escaping entirely.
const t = segmentToken("desktop|r=v2.ABC-123|sc=3", false, BUILD);
expect(t).toMatch(/^[a-z0-9]+$/);
expect(encodeURIComponent(t as string)).toBe(t);
});
});
105 changes: 51 additions & 54 deletions packages/tanstack/src/sdk/cdnSegment.ts
Original file line number Diff line number Diff line change
@@ -1,74 +1,71 @@
/**
* Segment marker on `/_serverFn` URLs, so Cloudflare's CDN can serve the
* response without invoking the Worker.
* Segment marker on `/_serverFn` URLs, so a cache in front of the Worker can
* serve the response without invoking it.
*
* 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 problem: whatever sits in front (Workers Cache, a CDN rule) keys on the
* raw URL. The Worker keys on a SYNTHETIC Request carrying
* `__seg`/`__cf_device`/`__bot`/`__fetch`/`__abf` (`buildCacheKey` in
* `./workerEntry`) — params the URL never shows. That mismatch is why the
* framework stamps `CDN-Cache-Control: no-store` on public responses.
*
* 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.
* The fix: put the segment in the URL itself, so the two keys become
* equivalent.
*
* 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.
* ## Why the SERVER issues the token
*
* 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`.
* The first version had the client derive the token from what it could see
* (`navigator.userAgent`). That only ever worked on sites whose segment
* reduces to device alone. A regionalized VTEX store — most of them — segments
* on region too, and region is resolved from `request.cf.regionCode`, which
* exists **only** on the server. The client cannot see it, so its token never
* matched and the feature stayed permanently inert.
*
* So the server computes the token from the segment it already built, publishes
* it to the page, and the client only echoes it back. The Worker still verifies
* by recomputing — the marker is never trusted, so a stale or forged one just
* keeps the `no-store`.
*
* The token is a hash rather than readable fields: `regionId` can contain the
* separator (`v2.XXXX`), values would need escaping, and there is no reason to
* publish a visitor's region in a URL that ends up in logs.
*/

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

/** `__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;
}
/** Global the SSR publishes the token on, read back by `decoServerFnFetch`. */
export const CSEG_GLOBAL = "__DECO_CSEG";

/** RequestContext bag key the worker entry fills before rendering. */
export const CSEG_BAG_KEY = "deco.cdn.segmentToken";

/**
* The segment token, or `null` when this request must not be CDN-cached.
* Build the token for a segment, or `null` when this request must not be
* cached in front of the Worker.
*
* Returns `null` — keeping today's `no-store` — when:
* `null` — i.e. keep `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.
* - the visitor is logged in. Personalized responses never belong in a shared
* entry, no matter how precise the key is.
* - 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.
* because deploying does not purge the cache in front, so the URL has to
* change on its own when the bundle does.
*
* Everything else in the segment — device, sales channel, region, a site's own
* custom dimensions — is folded INTO the token rather than rejected. That is
* the difference from the first version: those are what the key needs to
* distinguish, not reasons to give up on caching.
*/
export function segmentToken(seg: CdnSegment, buildHash: string | undefined): string | null {
export function segmentToken(
segmentDescriptor: string,
loggedIn: boolean,
buildHash: string | undefined,
): string | null {
if (loggedIn) return 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;
}
if (!segmentDescriptor) return null;

return `${seg.device}.${buildHash}`;
return djb2Hex(`${segmentDescriptor}|${buildHash}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When two segment descriptors collide in this 32-bit hash, cdnCacheableServerFn accepts the same __cseg marker for both requests, allowing a front cache to serve one region or custom-dimension response to another. Use a collision-resistant digest, making token generation and verification asynchronous if necessary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/tanstack/src/sdk/cdnSegment.ts, line 70:

<comment>When two segment descriptors collide in this 32-bit hash, `cdnCacheableServerFn` accepts the same `__cseg` marker for both requests, allowing a front cache to serve one region or custom-dimension response to another. Use a collision-resistant digest, making token generation and verification asynchronous if necessary.</comment>

<file context>
@@ -1,74 +1,71 @@
+  if (!segmentDescriptor) return null;
 
-  return `${seg.device}.${buildHash}`;
+  return djb2Hex(`${segmentDescriptor}|${buildHash}`);
 }
</file context>

}
68 changes: 32 additions & 36 deletions packages/tanstack/src/sdk/serverFnFetch.ts
Original file line number Diff line number Diff line change
@@ -1,63 +1,59 @@
/**
* Client-side `serverFns.fetch` hook that attaches the CDN segment marker to
* Client-side `serverFns.fetch` hook that echoes the CDN segment marker onto
* `/_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.
* Putting the segment in the URL is what makes the key of whatever caches in
* front of the Worker equivalent to the Worker's own — see `./cdnSegment`.
*
* 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.
* The client does not COMPUTE the token, it repeats one the server issued and
* published on the page. That is deliberate: the segment includes dimensions
* only the server can see (region comes from `request.cf`), so a client-derived
* token never matched on a regionalized store and the feature stayed inert
* there.
*
* 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.
* Only client-initiated requests carry it — SPA navigation and prefetch, which
* is the volume Speculation Rules generates. The initial HTML document is a
* browser navigation with no hook to attach anything to.
*
* @example
* ```ts
* // src/start.ts
* import { createStart } from "@tanstack/react-start";
* import { decoServerFnFetch } from "@decocms/tanstack";
* SECURITY: the marker is a HINT. The worker recomputes the segment from the
* request itself and only relaxes `no-store` on an exact match
* (`cdnCacheableServerFn` in `./workerEntry`). A missing, stale, forged or
* mismatched marker just keeps today's `no-store` — it can never produce a
* wrong response.
*
* Wired automatically: `decoVitePlugin` supplies `sdk/startEntry` as the Start
* entry when a site has no `src/start.ts` of its own. A site that owns one
* composes this itself:
*
* ```ts
* import { decoServerFnFetch } from "@decocms/tanstack/sdk/serverFnFetch";
* 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;
}
import { CSEG_GLOBAL, CSEG_PARAM } from "./cdnSegment";

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());
function publishedMarker(): string | null {
if (typeof window === "undefined") return null;
const v = (window as unknown as Record<string, unknown>)[CSEG_GLOBAL];
return typeof v === "string" && v.length > 0 ? v : null;
}

/**
* Drop-in `serverFns.fetch` implementation. Falls back to a plain `fetch` when
* there is no marker to add.
* Drop-in `serverFns.fetch`. Falls back to a plain `fetch` whenever there is no
* marker to echo — no marker simply means the response stays uncached in front
* of the Worker, which is the previous behaviour.
*/
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();
const marker = publishedMarker();
if (!marker) return fetch(input, init);
const sep = input.includes("?") ? "&" : "?";
return fetch(`${input}${sep}${CSEG_PARAM}=${marker}`, init);
return fetch(`${input}${sep}${CSEG_PARAM}=${encodeURIComponent(marker)}`, init);
};
Loading