From dea8d5fd828c58e1cbc9eae808b976518d99274e Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 10 Nov 2026 05:35:38 -0300 Subject: [PATCH 1/3] fix(blocks): read draft files past shared caches Every draft GET (the index and each file) now carries a fresh ?r=. delivery.decocms.com answers a missing object with a 404 that has no Cache-Control, and Cloudflare keeps it (cf-cache-status: HIT, age up to ~180 s). A draft file read once before its save landed then read as missing for minutes, and the preview silently showed production for that block. R2 ignores the query; Cloudflare keys on it, so the read always reaches storage (the draft objects are no-cache anyway). Co-Authored-By: Claude Opus 5.5 --- .../src/v8/__conformance__/campaigns.test.ts | 5 +++- .../src/v8/__conformance__/delivery.test.ts | 11 +++++---- .../blocks/src/v8/__conformance__/sdk.test.ts | 5 +++- packages/blocks/src/v8/cms.test.ts | 20 ++++++++++++++-- packages/blocks/src/v8/draftChanges.ts | 23 +++++++++++++++---- 5 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/blocks/src/v8/__conformance__/campaigns.test.ts b/packages/blocks/src/v8/__conformance__/campaigns.test.ts index 9e9e03b7..84bc8963 100644 --- a/packages/blocks/src/v8/__conformance__/campaigns.test.ts +++ b/packages/blocks/src/v8/__conformance__/campaigns.test.ts @@ -16,6 +16,9 @@ import { newVisitor, type Visitor } from "../experiments"; import { docsBlocks, docsSnapshot, fakeStudio } from "../testFixtures"; import type { Blocks, Client, Snapshot } from "../types"; +/** A draft GET's URL without its cache-busting `?r=` (see draftChanges.ts). */ +const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, ""); + beforeEach(() => resetForTests()); afterEach(() => { vi.restoreAllMocks(); @@ -489,7 +492,7 @@ describe("the preview link: the version, the moment and the tests and campaigns cms.client({ version }).resolve("Banner"), ]); // Nothing of a draft is kept across clients: each reads its index (it names no file). - expect(new Set(studio.requests.map((r) => r.url))).toEqual( + expect(new Set(studio.requests.map((r) => bare(r.url)))).toEqual( new Set([`https://${version.slice(0, version.lastIndexOf("@"))}`]), ); expect(studio.requests).toHaveLength(4); diff --git a/packages/blocks/src/v8/__conformance__/delivery.test.ts b/packages/blocks/src/v8/__conformance__/delivery.test.ts index 5257b611..bc19f573 100644 --- a/packages/blocks/src/v8/__conformance__/delivery.test.ts +++ b/packages/blocks/src/v8/__conformance__/delivery.test.ts @@ -61,6 +61,9 @@ const POINTER = `${DRAFT_HOST}/sites/acme/drafts/summer-sale/index.json@9f3c1a`; /** Where the fake CDN answers drafts. */ const DRAFTS = `https://${DRAFT_HOST}/sites/acme/drafts/`; +/** A draft GET's URL without its cache-busting `?r=` (see draftChanges.ts). */ +const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, ""); + /** Content without a site: the docs' snapshot. */ const docsSnapshot = (revision?: string): Snapshot => unhashedSnapshot(revision); /** The revision the fake CDN serves until a test publishes another: the docs' content. */ @@ -315,7 +318,7 @@ describe("content-delivery", () => { const cms = createCMS({ blocks: docsBlocks(), site: SITE }); await cms.update(); expect(await titleOf(cms.forDraft(pointer))).toBe("Draft"); - const urls = api.requests.map((r) => r.url); + const urls = api.requests.map((r) => bare(r.url)); // The draft's folder: its index, then the one file it names. expect(urls.filter((url) => url.startsWith(DRAFTS))).toEqual([ `${DRAFTS}summer-sale/index.json`, @@ -508,7 +511,7 @@ describe("draft previews", () => { const cms = cmsOf({ site: SITE, token: TOKEN }); expect(await titleOf(cms.forDraft(withVariant))).toBe("Draft"); const requests = api.draftFetches(); - expect(requests.map((r) => r.url)).toEqual([ + expect(requests.map((r) => bare(r.url))).toEqual([ `${DRAFTS}summer-sale/index.json`, `${DRAFTS}summer-sale/SummerSEO.json`, ]); @@ -648,7 +651,7 @@ describe("draft previews", () => { vi.stubGlobal( "fetch", vi.fn(async (input: string | URL | Request) => { - urls.push(String(input)); + urls.push(bare(String(input))); return Response.json({ names: [] }); }), ); @@ -932,7 +935,7 @@ describe("draft previews", () => { expect(await cms.forDraft(forced(1)).resolve("Banner")).toEqual(["draft summer", null]); expect(await cms.forDraft(forced(0)).resolve("Banner")).toEqual(["draft fallback", null]); expect(await cms.forDraft(pointer).resolve("Banner")).toEqual([undefined, null]); // the rules - const urls = api.draftFetches().map((r) => r.url); + const urls = api.draftFetches().map((r) => bare(r.url)); expect(new Set(urls)).toEqual( new Set([`${DRAFTS}summer-sale/index.json`, `${DRAFTS}summer-sale/Banner.json`]), ); diff --git a/packages/blocks/src/v8/__conformance__/sdk.test.ts b/packages/blocks/src/v8/__conformance__/sdk.test.ts index 41e79af3..356d2c27 100644 --- a/packages/blocks/src/v8/__conformance__/sdk.test.ts +++ b/packages/blocks/src/v8/__conformance__/sdk.test.ts @@ -30,6 +30,9 @@ import { docsBlocks, docsSnapshot, fakeDelivery, fakeStudio, hero, seo } from ". import type { Blocks, CMS, Redirect, RequestLike, Snapshot } from "../types"; import { typecheck } from "./typecheck"; +/** A draft GET's URL without its cache-busting `?r=` (see draftChanges.ts). */ +const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, ""); + /** cms.draftPointer and cms.draftCookie on a CMS whose code lets every host preview. */ const helpers = () => createCMS({ @@ -577,7 +580,7 @@ describe("AR-66 a draft pointer's forced variants (releases-and-drafts#preview-a expect((await cms.forDraft(draft(1)).resolve("Banner"))[0]).toBe("summer"); expect((await cms.forDraft(draft(0)).resolve("Banner"))[0]).toBe("spring"); // Each read: the draft's index, then its one file (SummerSEO). - const urls = fetch.mock.calls.map(([url]) => String(url)); + const urls = fetch.mock.calls.map(([url]) => bare(String(url))); expect(urls).toHaveLength(4); expect(urls.slice(2)).toEqual(urls.slice(0, 2)); expect(urls.some((url) => url.includes("__variant"))).toBe(false); diff --git a/packages/blocks/src/v8/cms.test.ts b/packages/blocks/src/v8/cms.test.ts index b52ad668..c3aead7c 100644 --- a/packages/blocks/src/v8/cms.test.ts +++ b/packages/blocks/src/v8/cms.test.ts @@ -8,6 +8,9 @@ import { createCMS, instanceOf, resetForTests } from "./cms"; import { docsBlocks, docsSnapshot, fakeStudio } from "./testFixtures"; import type { Loader, Snapshot } from "./types"; +/** A draft GET's URL without its cache-busting `?r=` (see draftChanges.ts). */ +const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, ""); + beforeEach(() => resetForTests()); afterEach(() => { vi.useRealTimers(); @@ -209,6 +212,19 @@ describe("loaders", () => { expect(fetch).toHaveBeenCalledTimes(6); }); + it("every draft GET carries a fresh ?r= nonce, so no shared cache (a CDN-kept 404) answers it", async () => { + const { fetch, pointer } = studioDraft(); + const cms = createCMS({ blocks: docsBlocks(), content: docsSnapshot() }); + await cms.forDraft(pointer).resolve("SummerSEO"); + await cms.forDraft(pointer).resolve("SummerSEO"); + const urls = fetch.mock.calls.map(([url]) => String(url)); + expect(urls).toHaveLength(6); + for (const url of urls) expect(url).toMatch(/\.json\?r=[0-9a-z]+$/); + expect(new Set(urls).size).toBe(urls.length); + // The nonce is the only query: the object's address is unchanged. + expect(new Set(urls.map(bare)).size).toBe(3); + }); + it("every draft request reads the draft index and its files again, with no If-None-Match", async () => { const { studio, fetch, pointer } = studioDraft(); const cms = createCMS({ blocks: docsBlocks(), content: docsSnapshot() }); @@ -217,7 +233,7 @@ describe("loaders", () => { const second = cms.forDraft(pointer); expect((await second.resolve("SummerSEO"))[0]).toMatchObject({ title: "Draft title" }); expect(await second.revision()).toBe(await first.revision()); - expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([ + expect(fetch.mock.calls.map(([url]) => bare(String(url)))).toEqual([ "https://delivery.decocms.com/sites/acme/drafts/summer-sale/index.json", "https://delivery.decocms.com/sites/acme/drafts/summer-sale/HelloWorld.json", "https://delivery.decocms.com/sites/acme/drafts/summer-sale/SummerSEO.json", @@ -694,7 +710,7 @@ describe("preview.draftHosts", () => { preview: { draftHosts: [" Drafts.Example.com "] }, }); expect((await own.forDraft(pointer).resolve("SummerSEO"))[1]).toBeNull(); - expect(String(fetch.mock.calls[0]?.[0])).toBe( + expect(bare(String(fetch.mock.calls[0]?.[0]))).toBe( "https://drafts.example.com/sites/acme/drafts/x/index.json", ); const studio = "delivery.decocms.com/sites/acme/drafts/x/index.json@1"; diff --git a/packages/blocks/src/v8/draftChanges.ts b/packages/blocks/src/v8/draftChanges.ts index 2e9d59c5..d66a11ce 100644 --- a/packages/blocks/src/v8/draftChanges.ts +++ b/packages/blocks/src/v8/draftChanges.ts @@ -14,8 +14,12 @@ * - A draft read is one wave: the draft index, then every file it names * (``, the file being the name's `encodeURIComponent` plus * `.json`, `index` written `%69ndex.json`), in parallel, plain GETs with no - * `If-None-Match` (nothing read from `drafts/` is kept across requests; the - * CDN revalidates every read with storage). The scheme comes from the domain + * `If-None-Match` (nothing read from `drafts/` is kept across requests). + * Every GET carries a fresh `?r=` the bucket ignores, so no shared + * cache can answer it: a CDN keeps a 404 (it has no `Cache-Control`) for + * minutes, and a file read once before its save landed would otherwise + * read as missing long after it exists (the draft silently showing + * production for that block). The scheme comes from the domain * that admitted the host, never from the pointer: plain `http` only for * loopback hosts. No cookies or credentials are sent, and redirects aren't * followed (a response that was redirected anyway is refused). Each read has @@ -196,8 +200,19 @@ export function parseDraftFile(body: unknown, name: string): DraftState | null { } } -/** GETs a draft object: its JSON, or `undefined` for a 404. */ -async function getJson(url: string, label: string): Promise { +/** + * `url` with a fresh `r` query parameter: a cache key no shared cache has + * seen, so the read always reaches storage (which ignores the query). Draft + * folder URLs carry no query of their own (see {@link draftFolderUrl}). + */ +function uncachedUrl(url: string): string { + const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; + return `${url}${url.includes("?") ? "&" : "?"}r=${nonce}`; +} + +/** GETs a draft object, past any shared cache: its JSON, or `undefined` for a 404. */ +async function getJson(objectUrl: string, label: string): Promise { + const url = uncachedUrl(objectUrl); const response = await fetch(url, { headers: { accept: "application/json" }, redirect: "manual", From 34a4a1dfd01b52fec11a2a0db838624bf8ab6ae6 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 10 Nov 2026 05:39:06 -0300 Subject: [PATCH 2/3] fix(blocks): read loopback draft hosts at their plain URL A loopback draft host (a local Studio or test server over plain http) has no CDN in front, and some match the request URL exactly (the nextjs-binding and tanstack-worker smoke draft servers did): the cache-busting nonce goes on https reads only. Co-Authored-By: Claude Opus 5.5 --- packages/blocks/src/v8/cms.test.ts | 10 ++++++++++ packages/blocks/src/v8/draftChanges.ts | 6 +++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/blocks/src/v8/cms.test.ts b/packages/blocks/src/v8/cms.test.ts index c3aead7c..e02aa10b 100644 --- a/packages/blocks/src/v8/cms.test.ts +++ b/packages/blocks/src/v8/cms.test.ts @@ -225,6 +225,16 @@ describe("loaders", () => { expect(new Set(urls.map(bare)).size).toBe(3); }); + it("a loopback draft host (no CDN in front) is read at its plain URL, with no nonce", async () => { + const fetch = vi.fn(async (_input: string | URL | Request) => Response.json({ names: [] })); + vi.stubGlobal("fetch", fetch); + const cms = createCMS({ blocks: docsBlocks(), content: docsSnapshot() }); + await cms.forDraft("localhost:4000/sites/acme/drafts/x/index.json@1").resolve("SummerSEO"); + expect(fetch.mock.calls.map(([url]) => String(url))).toEqual([ + "http://localhost:4000/sites/acme/drafts/x/index.json", + ]); + }); + it("every draft request reads the draft index and its files again, with no If-None-Match", async () => { const { studio, fetch, pointer } = studioDraft(); const cms = createCMS({ blocks: docsBlocks(), content: docsSnapshot() }); diff --git a/packages/blocks/src/v8/draftChanges.ts b/packages/blocks/src/v8/draftChanges.ts index d66a11ce..6d09d87a 100644 --- a/packages/blocks/src/v8/draftChanges.ts +++ b/packages/blocks/src/v8/draftChanges.ts @@ -15,7 +15,8 @@ * (``, the file being the name's `encodeURIComponent` plus * `.json`, `index` written `%69ndex.json`), in parallel, plain GETs with no * `If-None-Match` (nothing read from `drafts/` is kept across requests). - * Every GET carries a fresh `?r=` the bucket ignores, so no shared + * Every `https` GET carries a fresh `?r=` the bucket ignores (a + * loopback host has no CDN in front and is read as is), so no shared * cache can answer it: a CDN keeps a 404 (it has no `Cache-Control`) for * minutes, and a file read once before its save landed would otherwise * read as missing long after it exists (the draft silently showing @@ -204,8 +205,11 @@ export function parseDraftFile(body: unknown, name: string): DraftState | null { * `url` with a fresh `r` query parameter: a cache key no shared cache has * seen, so the read always reaches storage (which ignores the query). Draft * folder URLs carry no query of their own (see {@link draftFolderUrl}). + * Plain-`http` URLs are loopback hosts (a local Studio or test server, never + * behind a CDN): they're read as they are. */ function uncachedUrl(url: string): string { + if (url.startsWith("http:")) return url; const nonce = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`; return `${url}${url.includes("?") ? "&" : "?"}r=${nonce}`; } From 97aefba9814403d596a837b15ef9e149c271a9d4 Mon Sep 17 00:00:00 2001 From: gimenes Date: Tue, 10 Nov 2026 15:47:54 -0300 Subject: [PATCH 3/3] test(blocks): the hosted worker test reads draft URLs past their nonce Co-Authored-By: Claude Opus 5.5 --- tests/worker-hosted.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/worker-hosted.test.ts b/tests/worker-hosted.test.ts index 15b980ca..16e62865 100644 --- a/tests/worker-hosted.test.ts +++ b/tests/worker-hosted.test.ts @@ -21,6 +21,9 @@ import { MemoryCache, TestContext } from "../packages/tanstack/src/worker/testin const SITE = "acme"; const ORIGIN = "https://store.example.com"; +/** A draft GET's URL without its cache-busting `?r=` (see draftChanges.ts). */ +const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, ""); + beforeEach(() => resetForTests()); afterEach(() => { vi.unstubAllGlobals(); @@ -166,7 +169,7 @@ describe("the worker on hosted releases", () => { const response = await worker.fetch(new Request(url), {}, new TestContext()); expect(await response.text()).toContain("Draft,footer"); const folder = (file: string) => - studio.requests.filter((r) => r.url.endsWith(`/drafts/summer-sale/${file}`)).length; + studio.requests.filter((r) => bare(r.url).endsWith(`/drafts/summer-sale/${file}`)).length; expect(folder("index.json")).toBe(1); expect(folder("header.json")).toBe(1);