-
Notifications
You must be signed in to change notification settings - Fork 2
feat(tanstack): server issues the CDN segment token, so regionalized stores engage #524
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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); | ||
| 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)}`, | ||
| }} | ||
| /> | ||
| ); | ||
| } | ||
| 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}`); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: When two segment descriptors collide in this 32-bit hash, Prompt for AI agents |
||
| } | ||
| 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); | ||
| }; |
There was a problem hiding this comment.
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 browserRequestContextbag is empty. React therefore reports a hydration mismatch and can discard the SSR tree; readwindow.__DECO_CSEGon the client and render the same script node, asDraftPreviewIndicatordoes.Prompt for AI agents