Skip to content
Closed
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
5 changes: 4 additions & 1 deletion packages/blocks/src/v8/__conformance__/campaigns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<nonce>` (see draftChanges.ts). */
const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, "");

beforeEach(() => resetForTests());
afterEach(() => {
vi.restoreAllMocks();
Expand Down Expand Up @@ -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);
Expand Down
11 changes: 7 additions & 4 deletions packages/blocks/src/v8/__conformance__/delivery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<nonce>` (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. */
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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`,
]);
Expand Down Expand Up @@ -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: [] });
}),
);
Expand Down Expand Up @@ -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`]),
);
Expand Down
5 changes: 4 additions & 1 deletion packages/blocks/src/v8/__conformance__/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<nonce>` (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({
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 28 additions & 2 deletions packages/blocks/src/v8/cms.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<nonce>` (see draftChanges.ts). */
const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, "");

beforeEach(() => resetForTests());
afterEach(() => {
vi.useRealTimers();
Expand Down Expand Up @@ -209,6 +212,29 @@ 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("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() });
Expand All @@ -217,7 +243,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",
Expand Down Expand Up @@ -694,7 +720,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";
Expand Down
27 changes: 23 additions & 4 deletions packages/blocks/src/v8/draftChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,13 @@
* - A draft read is one wave: the draft index, then every file it names
* (`<folder><file>`, 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 `https` GET carries a fresh `?r=<nonce>` 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
* 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
Expand Down Expand Up @@ -196,8 +201,22 @@ 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<unknown> {
/**
* `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}`;
}

/** GETs a draft object, past any shared cache: its JSON, or `undefined` for a 404. */
async function getJson(objectUrl: string, label: string): Promise<unknown> {
const url = uncachedUrl(objectUrl);
const response = await fetch(url, {
headers: { accept: "application/json" },
redirect: "manual",
Expand Down
5 changes: 4 additions & 1 deletion tests/worker-hosted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<nonce>` (see draftChanges.ts). */
const bare = (url: string) => url.replace(/\?r=[0-9a-z]+$/, "");

beforeEach(() => resetForTests());
afterEach(() => {
vi.unstubAllGlobals();
Expand Down Expand Up @@ -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);

Expand Down
Loading