@decocms/tanstack 7.33.1 → 7.34.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.33.1",
3
+ "version": "7.34.1",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -24,9 +24,9 @@
24
24
  "lint:unused": "knip"
25
25
  },
26
26
  "dependencies": {
27
- "@decocms/blocks": "7.33.1",
28
- "@decocms/blocks-admin": "7.33.1",
29
- "@decocms/blocks-cli": "7.33.1",
27
+ "@decocms/blocks": "7.34.1",
28
+ "@decocms/blocks-admin": "7.34.1",
29
+ "@decocms/blocks-cli": "7.34.1",
30
30
  "@deco-cx/warp-node": "^0.3.16",
31
31
  "fast-json-patch": "^3.1.0",
32
32
  "ws": "^8.18.0"
@@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react";
2
2
  import { HeadContent, Scripts, ScriptOnce } from "@tanstack/react-router";
3
3
  import { LiveControls } from "@decocms/blocks/hooks";
4
4
  import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
5
+ import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
5
6
  import { NavigationProgress } from "./NavigationProgress";
6
7
  import { StableOutlet } from "./StableOutlet";
7
8
 
@@ -102,6 +103,7 @@ export function DecoRootLayout({
102
103
  <StableOutlet />
103
104
  </main>
104
105
  {children}
106
+ <DraftPreviewIndicator />
105
107
  <LiveControls site={siteName} />
106
108
  <ScriptOnce children={ANALYTICS_SCRIPT} />
107
109
  <Scripts />
@@ -0,0 +1,60 @@
1
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { renderToString } from "react-dom/server";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { DRAFT_POINTER_BAG_KEY, DRAFT_POINTER_GLOBAL } from "../sdk/draftShared";
8
+ import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
9
+
10
+ const POINTER = "abc.preview-studio.decocms.com@v1";
11
+
12
+ function renderInRequest(pointer: string | null): string {
13
+ return RequestContext.run(new Request("https://example.com/"), () => {
14
+ if (pointer) RequestContext.setBag(DRAFT_POINTER_BAG_KEY, pointer);
15
+ return renderToString(<DraftPreviewIndicator />);
16
+ });
17
+ }
18
+
19
+ describe("DraftPreviewIndicator — SSR", () => {
20
+ it("publishes the pointer on a global so client hydration matches", () => {
21
+ const html = renderInRequest(POINTER);
22
+ expect(html).toContain(`window.${DRAFT_POINTER_GLOBAL}=`);
23
+ expect(html).toContain(JSON.stringify(POINTER));
24
+ // The chip itself fails closed — nothing visible until the client effect.
25
+ expect(html).not.toContain("Preview mode");
26
+ });
27
+
28
+ it("renders nothing when the request is not drafting", () => {
29
+ expect(renderInRequest(null)).toBe("");
30
+ });
31
+ });
32
+
33
+ describe("DraftPreviewIndicator — client", () => {
34
+ let container: HTMLDivElement;
35
+ let root: Root;
36
+ const win = window as unknown as Record<string, string | undefined>;
37
+
38
+ beforeEach(() => {
39
+ container = document.createElement("div");
40
+ document.body.appendChild(container);
41
+ root = createRoot(container);
42
+ });
43
+
44
+ afterEach(() => {
45
+ act(() => root.unmount());
46
+ container.remove();
47
+ win[DRAFT_POINTER_GLOBAL] = undefined;
48
+ });
49
+
50
+ it("mounts the badge from the published global (cookie-nav, no bag on the client)", () => {
51
+ win[DRAFT_POINTER_GLOBAL] = POINTER;
52
+ act(() => root.render(<DraftPreviewIndicator />));
53
+ expect(container.textContent).toContain("Preview mode");
54
+ });
55
+
56
+ it("renders nothing when no pointer is published", () => {
57
+ act(() => root.render(<DraftPreviewIndicator />));
58
+ expect(container.textContent).toBe("");
59
+ });
60
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Preview-mode badge gate — the TanStack binding.
3
+ *
4
+ * Mounts the shared `DraftPreviewBadge` (`@decocms/blocks/preview`) only when
5
+ * the request is rendering a draft. `DecoRootLayout` renders it automatically,
6
+ * so sites get the badge on a package bump with no code change; it renders
7
+ * nothing at all on ordinary (published) traffic.
8
+ *
9
+ * ## Isomorphic pointer, hydration-safe
10
+ *
11
+ * TanStack Start renders this component on BOTH server and client (no RSC
12
+ * boundary). The pointer only exists server-side (the request bag), so:
13
+ *
14
+ * - **Server:** read it from `RequestContext` and emit a tiny inline `<script>`
15
+ * that publishes it on `window.<GLOBAL>`. Inline scripts execute during HTML
16
+ * parse, before hydration.
17
+ * - **Client:** `RequestContext` resolves to a no-op stub in the browser
18
+ * bundle (the `browser` export condition), so the bag read returns
19
+ * undefined and we fall back to `window.<GLOBAL>` — which the just-executed
20
+ * inline script set to the server's value, so the hydration render computes
21
+ * the SAME pointer and the `<script>` node reconciles cleanly.
22
+ *
23
+ * Reading the bag first (then the global) — rather than branching on
24
+ * `typeof window` — is also what makes this testable under jsdom, where
25
+ * `window` always exists.
26
+ *
27
+ * The badge itself starts hidden and reveals via a client effect (fails
28
+ * closed), so the visible chip never participates in the hydration diff.
29
+ */
30
+ import { DraftPreviewBadge } from "@decocms/blocks/preview";
31
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
32
+ import { DRAFT_POINTER_BAG_KEY, DRAFT_POINTER_GLOBAL } from "../sdk/draftShared";
33
+
34
+ /**
35
+ * The active draft pointer. Server: the request bag (set by `bindRequestDraft`).
36
+ * Client: the bag is a stub → undefined → fall back to the published global.
37
+ */
38
+ function activePointer(): string | null {
39
+ const fromBag = RequestContext.getBag<string>(DRAFT_POINTER_BAG_KEY);
40
+ if (fromBag) return fromBag;
41
+ if (typeof window !== "undefined") {
42
+ return (window as unknown as Record<string, string | undefined>)[DRAFT_POINTER_GLOBAL] ?? null;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ export function DraftPreviewIndicator() {
48
+ const pointer = activePointer();
49
+ if (!pointer) return null;
50
+ return (
51
+ <>
52
+ {/* Publishes a JSON-encoded pointer this server resolved (not user
53
+ input) so the client's hydration render matches. See the module doc. */}
54
+ <script
55
+ dangerouslySetInnerHTML={{
56
+ __html: `window.${DRAFT_POINTER_GLOBAL}=${JSON.stringify(pointer)}`,
57
+ }}
58
+ />
59
+ <DraftPreviewBadge pointer={pointer} />
60
+ </>
61
+ );
62
+ }
@@ -4,6 +4,7 @@ export {
4
4
  SectionRenderer,
5
5
  } from "./DecoPageRenderer";
6
6
  export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
7
+ export { DraftPreviewIndicator } from "./DraftPreviewIndicator";
7
8
  export { NavigationProgress } from "./NavigationProgress";
8
9
  export { StableOutlet } from "./StableOutlet";
9
10
  export { default as PreviewProviders } from "./PreviewProviders";
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  export {
15
15
  DecoPageRenderer,
16
16
  DecoRootLayout,
17
+ DraftPreviewIndicator,
17
18
  NavigationProgress,
18
19
  PreviewProviders,
19
20
  SectionList,
@@ -0,0 +1,231 @@
1
+ import {
2
+ clearDraftCache,
3
+ getRequestDraftOverride,
4
+ setDraftPreviewHosts,
5
+ } from "@decocms/blocks/cms";
6
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
7
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
+
9
+ import {
10
+ applyDraftCookieAndHeaders,
11
+ bindRequestDraft,
12
+ type DraftDecision,
13
+ installPreviewHostsFromBlocks,
14
+ registerDraftOverride,
15
+ requestCarriesDraft,
16
+ } from "./draft";
17
+ import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
18
+
19
+ const HOST = "preview.example";
20
+ const DRAFT_BLOCKS = { "pages-home": { title: "draft" } };
21
+
22
+ function req(opts: { url?: string; host?: string; cookie?: string } = {}): Request {
23
+ const headers = new Headers();
24
+ headers.set("host", opts.host ?? HOST);
25
+ if (opts.cookie) headers.set("cookie", opts.cookie);
26
+ return new Request(opts.url ?? `https://${opts.host ?? HOST}/some/page`, { headers });
27
+ }
28
+
29
+ /** A fetch stub serving the sandbox decofile; throws if `fail`. */
30
+ function sandboxFetch(fail = false): typeof fetch {
31
+ return (async () => {
32
+ if (fail) throw new Error("unreachable");
33
+ return new Response(JSON.stringify(DRAFT_BLOCKS), {
34
+ status: 200,
35
+ headers: { "content-type": "application/json" },
36
+ });
37
+ }) as unknown as typeof fetch;
38
+ }
39
+
40
+ beforeEach(() => {
41
+ clearDraftCache();
42
+ // Allowlist is globalThis-backed (the site block's previewHosts), so no env
43
+ // needed — this makes the feature "enabled" for the tests below.
44
+ setDraftPreviewHosts([HOST, "localhost:3100"]);
45
+ });
46
+
47
+ afterEach(() => {
48
+ setDraftPreviewHosts([]);
49
+ });
50
+
51
+ describe("requestCarriesDraft", () => {
52
+ it("is inert when no allowlist is configured", () => {
53
+ setDraftPreviewHosts([]);
54
+ const url = new URL(`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`);
55
+ expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(false);
56
+ });
57
+
58
+ it("detects the ?__draft= entry param", () => {
59
+ const url = new URL(`https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`);
60
+ expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(true);
61
+ });
62
+
63
+ it("detects the navigation cookie (SPA nav after entry)", () => {
64
+ const url = new URL(`https://${HOST}/p`);
65
+ expect(
66
+ requestCarriesDraft(req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }), url),
67
+ ).toBe(true);
68
+ });
69
+
70
+ it("is false on ?__draft=off (leaving preview)", () => {
71
+ const url = new URL(`https://${HOST}/p?__draft=off`);
72
+ expect(
73
+ requestCarriesDraft(req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }), url),
74
+ ).toBe(false);
75
+ });
76
+
77
+ it("is structurally inert on a non-preview host, param or not", () => {
78
+ const url = new URL("https://prod.example/p?__draft=abc.x@v1");
79
+ expect(requestCarriesDraft(req({ url: url.toString(), host: "prod.example" }), url)).toBe(
80
+ false,
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("bindRequestDraft", () => {
86
+ it("binds the resolved draft to the request bag on entry", async () => {
87
+ registerDraftOverride();
88
+ const pointer = "abc.preview-studio.decocms.com@v1";
89
+ const request = req({ url: `https://${HOST}/p?__draft=${pointer}` });
90
+
91
+ const decision = await RequestContext.run(request, async () => {
92
+ const d = await bindRequestDraft(request, sandboxFetch());
93
+ // loadBlocks() reads this back via the DI getter registered above.
94
+ expect(getRequestDraftOverride()).toEqual(DRAFT_BLOCKS);
95
+ return d;
96
+ });
97
+
98
+ expect(decision).toEqual({ previewing: true, setCookie: pointer, clearCookie: false });
99
+ });
100
+
101
+ it("carries the draft across navigation via the cookie (no re-set)", async () => {
102
+ registerDraftOverride();
103
+ const pointer = "abc.preview-studio.decocms.com@v1";
104
+ const request = req({ url: `https://${HOST}/other`, cookie: `__deco_draft=${pointer}` });
105
+
106
+ const decision = await RequestContext.run(request, () =>
107
+ bindRequestDraft(request, sandboxFetch()),
108
+ );
109
+
110
+ // previewing, but setCookie is null — the cookie is already present.
111
+ expect(decision).toEqual({ previewing: true, setCookie: null, clearCookie: false });
112
+ });
113
+
114
+ it("decodes a URL-encoded cookie so the badge pointer round-trips (no double-encode)", async () => {
115
+ // The cookie is written with encodeURIComponent (`:`→%3A, `@`→%40); the
116
+ // pointer stashed for the badge must be the DECODED value, or the badge's
117
+ // share link would re-encode an already-encoded string.
118
+ registerDraftOverride();
119
+ const decoded = "abc.localhost:60534@v7";
120
+ const request = req({
121
+ url: `https://${HOST}/other`,
122
+ cookie: `__deco_draft=${encodeURIComponent(decoded)}`,
123
+ });
124
+
125
+ const stored = await RequestContext.run(request, async () => {
126
+ await bindRequestDraft(request, sandboxFetch());
127
+ return RequestContext.getBag<string>(DRAFT_POINTER_BAG_KEY);
128
+ });
129
+
130
+ expect(stored).toBe(decoded);
131
+ });
132
+
133
+ it("stays previewing (uncacheable) even when the draft fails to resolve", async () => {
134
+ const request = req({ url: `https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1` });
135
+ const decision = await RequestContext.run(request, () =>
136
+ bindRequestDraft(request, sandboxFetch(true)),
137
+ );
138
+ expect(decision.previewing).toBe(true);
139
+ expect(decision.setCookie).toBe("abc.preview-studio.decocms.com@v1");
140
+ });
141
+
142
+ it("clears the cookie on ?__draft=off", async () => {
143
+ const request = req({ url: `https://${HOST}/p?__draft=off`, cookie: "__deco_draft=x@v1" });
144
+ const decision = await RequestContext.run(request, () => bindRequestDraft(request));
145
+ expect(decision).toEqual({ previewing: false, setCookie: null, clearCookie: true });
146
+ });
147
+
148
+ it("is inert on a non-preview host", async () => {
149
+ const request = req({
150
+ url: "https://prod.example/p?__draft=abc.x@v1",
151
+ host: "prod.example",
152
+ });
153
+ const decision = await RequestContext.run(request, () => bindRequestDraft(request));
154
+ expect(decision).toEqual({ previewing: false, setCookie: null, clearCookie: false });
155
+ });
156
+ });
157
+
158
+ describe("applyDraftCookieAndHeaders", () => {
159
+ const entry: DraftDecision = {
160
+ previewing: true,
161
+ setCookie: "abc@v1",
162
+ clearCookie: false,
163
+ };
164
+
165
+ it("sets a cross-site-iframe-safe cookie and the anti-leak headers on entry", () => {
166
+ const res = new Response("ok");
167
+ applyDraftCookieAndHeaders(res, entry);
168
+
169
+ const cookie = res.headers.get("set-cookie") ?? "";
170
+ expect(cookie).toContain("__deco_draft=abc%40v1"); // pointer is URL-encoded
171
+ expect(cookie).toContain("SameSite=None");
172
+ expect(cookie).toContain("Secure");
173
+ expect(cookie).toContain("Partitioned");
174
+ expect(cookie).toContain("HttpOnly");
175
+
176
+ expect(res.headers.get("cache-control")).toBe("no-store, private");
177
+ expect(res.headers.get("vary")).toBe("Cookie");
178
+ expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
179
+ });
180
+
181
+ it("expires the cookie when leaving preview", () => {
182
+ const res = new Response("ok");
183
+ applyDraftCookieAndHeaders(res, { previewing: false, setCookie: null, clearCookie: true });
184
+ expect(res.headers.get("set-cookie") ?? "").toContain("Max-Age=0");
185
+ // Not previewing → no cache/index headers.
186
+ expect(res.headers.get("cache-control")).toBeNull();
187
+ });
188
+
189
+ it("merges Cookie into an existing Vary instead of clobbering it", () => {
190
+ const res = new Response("ok", { headers: { Vary: "Accept-Encoding" } });
191
+ applyDraftCookieAndHeaders(res, { previewing: true, setCookie: null, clearCookie: false });
192
+ expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
193
+ });
194
+
195
+ it("leaves a Vary that already lists Cookie untouched (no token dropped)", () => {
196
+ const res = new Response("ok", { headers: { Vary: "Accept-Encoding, Cookie" } });
197
+ applyDraftCookieAndHeaders(res, { previewing: true, setCookie: null, clearCookie: false });
198
+ expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
199
+ });
200
+
201
+ it("does nothing for an ordinary (non-draft) response", () => {
202
+ const res = new Response("ok");
203
+ applyDraftCookieAndHeaders(res, { previewing: false, setCookie: null, clearCookie: false });
204
+ expect(res.headers.get("set-cookie")).toBeNull();
205
+ expect(res.headers.get("cache-control")).toBeNull();
206
+ });
207
+ });
208
+
209
+ describe("installPreviewHostsFromBlocks", () => {
210
+ it("installs previewHosts from the site block (either casing)", async () => {
211
+ setDraftPreviewHosts([]);
212
+ installPreviewHostsFromBlocks({ Site: { previewHosts: ["fila.vtex.app"] } });
213
+
214
+ // Verified through the wire: an allowed host now previews.
215
+ const request = req({
216
+ url: "https://fila.vtex.app/p?__draft=abc.preview-studio.decocms.com@v1",
217
+ host: "fila.vtex.app",
218
+ });
219
+ const url = new URL(request.url);
220
+ expect(requestCarriesDraft(request, url)).toBe(true);
221
+ });
222
+
223
+ it("ignores blocks without previewHosts", () => {
224
+ setDraftPreviewHosts([]);
225
+ installPreviewHostsFromBlocks({ site: {} });
226
+ const url = new URL("https://fila.vtex.app/p?__draft=x@v1");
227
+ expect(requestCarriesDraft(req({ url: url.toString(), host: "fila.vtex.app" }), url)).toBe(
228
+ false,
229
+ );
230
+ });
231
+ });
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Draft preview — the TanStack Start / Cloudflare Workers binding.
3
+ *
4
+ * `@decocms/blocks`'s `draftSource` owns the framework-agnostic half (token
5
+ * parsing, origin validation, fetch, version cache, the host allowlist). This
6
+ * file binds a resolved draft to the current request and drives the cookie +
7
+ * cache/indexing headers.
8
+ *
9
+ * ## Why this is simpler than the Next.js binding
10
+ *
11
+ * The Next binding (`@decocms/nextjs`'s `draft.ts`/`draftMiddleware.ts`) fights
12
+ * two App Router constraints that do NOT exist here:
13
+ *
14
+ * 1. **Request scope.** App Router never enters an AsyncLocalStorage request
15
+ * scope, so Next backs the per-request draft slot with React `cache()` and
16
+ * has to await `ensureDraft` from the *page* (a layout's await does not gate
17
+ * its children). TanStack already wraps every request in
18
+ * `RequestContext.run(request, …)` (see `workerEntry.ts`), so the draft just
19
+ * rides the request bag — `loadBlocks()` reads it back through the
20
+ * dependency-injected getter registered by {@link registerDraftOverride}.
21
+ *
22
+ * 2. **Static-vs-dynamic rewrite.** `dynamic`/`revalidate` are static route
23
+ * exports in App Router, so Next rewrites drafted requests onto an
24
+ * encoded `app/%5Fdraft/…` route to escape ISR. The worker decides
25
+ * cacheability per request, so a drafted request simply bypasses the edge
26
+ * cache (see `requestCarriesDraft`) — no rewrite dance.
27
+ *
28
+ * Like Next, the whole feature is inert unless the site opts in: an allowlist
29
+ * (the site block's `previewHosts`, or the `DECO_ALLOWED_PREVIEW_HOSTS`
30
+ * override) must name the request's host. Upgrading the package can never be
31
+ * enough to start rendering unpublished content.
32
+ */
33
+
34
+ import {
35
+ DRAFT_COOKIE_NAME,
36
+ DRAFT_QUERY_PARAM,
37
+ isDraftHostAllowed,
38
+ isDraftPreviewEnabled,
39
+ resolveDraftForRequest,
40
+ setDraftOverrideGetter,
41
+ setDraftPreviewHosts,
42
+ } from "@decocms/blocks/cms";
43
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
44
+ import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
45
+
46
+ /** RequestContext bag key the resolved draft decofile is stashed under. */
47
+ const DRAFT_BAG_KEY = "deco:draftBlocks";
48
+
49
+ /**
50
+ * Cookie lifetime. Short on purpose: a preview session is minutes, and a stale
51
+ * pointer would keep pinning an old content version. Matches the Next binding.
52
+ */
53
+ const DRAFT_COOKIE_MAX_AGE = 60 * 30;
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Runtime binding (dependency injection into @decocms/blocks)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ let registered = false;
60
+
61
+ /**
62
+ * Register the request-scoped draft getter with the runtime, once.
63
+ *
64
+ * `loadBlocks()` consults `getRequestDraftOverride()` as a fallback after its
65
+ * own `withBlocksOverride` scope; this points that fallback at the
66
+ * RequestContext bag {@link bindRequestDraft} fills. Idempotent and safe to
67
+ * call at setup: outside a request scope `getBag` returns undefined, so
68
+ * `loadBlocks()` behaves exactly as before.
69
+ */
70
+ export function registerDraftOverride(): void {
71
+ if (registered) return;
72
+ registered = true;
73
+ setDraftOverrideGetter(
74
+ () => RequestContext.getBag<Record<string, unknown>>(DRAFT_BAG_KEY) ?? null,
75
+ );
76
+ }
77
+
78
+ /**
79
+ * Install the site block's `previewHosts` as the draft-preview allowlist.
80
+ *
81
+ * Reads the BASE blocks (the `setBlocks()` argument / `loadBlocks()` at setup
82
+ * time), never the request-time merged blocks: an allowlist readable through
83
+ * the draft override could be rewritten by the very draft it gates.
84
+ * `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces
85
+ * this list when set. Handles both decofile shapes (`site` / `Site`), same as
86
+ * `withSiteGlobals`.
87
+ */
88
+ export function installPreviewHostsFromBlocks(blocks: Record<string, unknown> | undefined): void {
89
+ const site = (blocks?.site ?? blocks?.Site) as { previewHosts?: unknown } | undefined;
90
+ if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts);
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Per-request resolution + response decoration
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Read one cookie value out of a raw `Cookie:` header.
99
+ *
100
+ * Decodes the value — the cookie is written with `encodeURIComponent` (see
101
+ * `draftCookie`), and `@decocms/blocks`'s own cookie reader decodes too, so a
102
+ * cookie-carried pointer must round-trip identically or the badge's share link
103
+ * would double-encode it. Decode defensively: a malformed `%` sequence
104
+ * (a crafted cookie) falls back to the raw value rather than throwing and
105
+ * 500-ing the request.
106
+ */
107
+ function readCookie(cookieHeader: string | null, name: string): string | null {
108
+ if (!cookieHeader) return null;
109
+ for (const part of cookieHeader.split(";")) {
110
+ const eq = part.indexOf("=");
111
+ if (eq === -1) continue;
112
+ if (part.slice(0, eq).trim() === name) {
113
+ const raw = part.slice(eq + 1).trim();
114
+ try {
115
+ return decodeURIComponent(raw);
116
+ } catch {
117
+ return raw;
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Whether a request is in draft mode — carries a live `?__draft=` param or the
126
+ * navigation cookie — on a host allowed to preview.
127
+ *
128
+ * A pure, synchronous, network-free check (no fetch, no resolve): the worker
129
+ * uses it to keep drafted requests out of the shared edge cache *before* doing
130
+ * any work. Gated on `isDraftPreviewEnabled()` first, so a build without the
131
+ * allowlist configured is completely unaffected — a stray `?__draft=` on a
132
+ * production domain neither renders a draft nor even bypasses the cache.
133
+ */
134
+ export function requestCarriesDraft(request: Request, url: URL): boolean {
135
+ if (!isDraftPreviewEnabled()) return false;
136
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
137
+ if (!isDraftHostAllowed(host)) return false;
138
+ const param = url.searchParams.get(DRAFT_QUERY_PARAM);
139
+ if (param === "off") return false;
140
+ if (param) return true;
141
+ return readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME) !== null;
142
+ }
143
+
144
+ /** The cookie/header actions a drafted request implies for its response. */
145
+ export interface DraftDecision {
146
+ /**
147
+ * The request is previewing (a pointer is present on an allowed host), so its
148
+ * response must never be cached under the shared (cookie-only) URL — even if
149
+ * the draft failed to resolve and the page fell back to published content.
150
+ */
151
+ previewing: boolean;
152
+ /** Persist this pointer in the navigation cookie (set only on `?__draft=` entry). */
153
+ setCookie: string | null;
154
+ /** Clear the navigation cookie (`?__draft=off`). */
155
+ clearCookie: boolean;
156
+ }
157
+
158
+ const INERT: DraftDecision = { previewing: false, setCookie: null, clearCookie: false };
159
+
160
+ /**
161
+ * Resolve this request's draft, if any, and bind it to the RequestContext so
162
+ * every `loadBlocks()` in the render sees the merged decofile.
163
+ *
164
+ * Call inside `RequestContext.run`, before the page render. Returns the
165
+ * cookie/header decision for the worker to apply to the final response. A
166
+ * missing allowlist, disallowed host, or `?__draft=off` all short-circuit
167
+ * without touching the network; a resolve failure degrades to published but
168
+ * still reports `previewing: true` so the response stays uncacheable.
169
+ */
170
+ export async function bindRequestDraft(
171
+ request: Request,
172
+ fetchImpl?: typeof fetch,
173
+ ): Promise<DraftDecision> {
174
+ if (!isDraftPreviewEnabled()) return INERT;
175
+
176
+ const url = new URL(request.url);
177
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
178
+ // Authoritative host gate — unlike Next, there is no separate middleware
179
+ // module graph, so this single check bounds the whole feature.
180
+ if (!isDraftHostAllowed(host)) return INERT;
181
+
182
+ const param = url.searchParams.get(DRAFT_QUERY_PARAM);
183
+ if (param === "off") return { previewing: false, setCookie: null, clearCookie: true };
184
+
185
+ const cookie = readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME);
186
+ const pointer = param ?? cookie ?? null;
187
+ if (!pointer) return INERT;
188
+
189
+ const blocks = await resolveDraftForRequest(request, { fetchImpl });
190
+ if (blocks) {
191
+ RequestContext.setBag(DRAFT_BAG_KEY, blocks);
192
+ // Expose the pointer to the SSR React tree so the preview badge can render
193
+ // and build its share/exit links. Only when the draft actually bound —
194
+ // the badge means "you're looking at draft content", matching Next.
195
+ RequestContext.setBag(DRAFT_POINTER_BAG_KEY, pointer);
196
+ }
197
+
198
+ // Persist only on entry (`?__draft=`); in-preview navigation rides the cookie
199
+ // already present.
200
+ return { previewing: true, setCookie: param ?? null, clearCookie: false };
201
+ }
202
+
203
+ /** Build the `Set-Cookie` value for the draft navigation cookie. */
204
+ function draftCookie(value: string, maxAge: number): string {
205
+ // SameSite=None; Secure is mandatory for the cross-site preview iframe, and
206
+ // Partitioned (CHIPS) keeps it working as browsers wind down unpartitioned
207
+ // third-party cookies. HttpOnly: the pointer is read server-side only.
208
+ return `${DRAFT_COOKIE_NAME}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=None; Partitioned`;
209
+ }
210
+
211
+ /** Set a header defensively — some replayed responses seal their headers. */
212
+ function trySet(response: Response, key: string, value: string): void {
213
+ try {
214
+ response.headers.set(key, value);
215
+ } catch {
216
+ /* sealed headers (cached replay) — best-effort */
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Apply the cookie lifecycle and the cache/indexing headers a draft response
222
+ * requires.
223
+ *
224
+ * The caching headers are the difference between a preview and a leak: with the
225
+ * pointer in a cookie, a drafted response and a published one share an
226
+ * identical URL, so `no-store` is what stops a CDN keyed on URL alone from
227
+ * serving unpublished content to a real visitor. `Vary: Cookie` keeps any
228
+ * intermediary that does respect it from mixing the two; `X-Robots-Tag` keeps a
229
+ * leaked draft out of search results.
230
+ */
231
+ export function applyDraftCookieAndHeaders(response: Response, decision: DraftDecision): void {
232
+ if (decision.clearCookie) {
233
+ response.headers.append("set-cookie", draftCookie("", 0));
234
+ } else if (decision.setCookie) {
235
+ response.headers.append(
236
+ "set-cookie",
237
+ draftCookie(encodeURIComponent(decision.setCookie), DRAFT_COOKIE_MAX_AGE),
238
+ );
239
+ }
240
+
241
+ if (decision.previewing) {
242
+ trySet(response, "cache-control", "no-store, private");
243
+ // Add Cookie to Vary without dropping tokens already there (e.g.
244
+ // Accept-Encoding). Moot under no-store, but correct for any intermediary
245
+ // that honours Vary anyway.
246
+ const existingVary = response.headers.get("vary");
247
+ if (!existingVary) {
248
+ trySet(response, "vary", "Cookie");
249
+ } else if (!/\bcookie\b/i.test(existingVary)) {
250
+ trySet(response, "vary", `${existingVary}, Cookie`);
251
+ }
252
+ trySet(response, "x-robots-tag", "noindex, nofollow");
253
+ }
254
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Client-bundle-safe constants shared by the server-side draft binding
3
+ * (`./draft`, which imports the server-only `@decocms/blocks/cms`) and the
4
+ * isomorphic preview-badge indicator (`../hooks/DraftPreviewIndicator`, which
5
+ * must stay client-safe). Kept in their own module so the indicator never has
6
+ * to import `./draft` and drag `node:async_hooks` into the browser bundle.
7
+ */
8
+
9
+ /** RequestContext bag key the active draft pointer is stashed under during SSR. */
10
+ export const DRAFT_POINTER_BAG_KEY = "deco:draftPointer";
11
+
12
+ /**
13
+ * Global the SSR pass publishes the pointer on, so the client's hydration
14
+ * render reads the same value (RequestContext is a no-op stub in the browser).
15
+ */
16
+ export const DRAFT_POINTER_GLOBAL = "__DECO_DRAFT_POINTER__";
@@ -493,3 +493,113 @@ describe("X-Cache-Segment — custom SegmentKey fields (#284)", () => {
493
493
  expect(res.headers.get("X-Cache-Segment")).toBe("desktop|sc=1|f=a,b");
494
494
  });
495
495
  });
496
+
497
+ describe("draft preview (pull-based)", () => {
498
+ const POINTER = "abc.localhost:1234@v1";
499
+ const SANDBOX_URL = "http://abc.localhost:1234/_sandbox/decofile";
500
+ const DRAFT_BLOCKS = { "pages-home": { name: "home", path: "/", title: "DRAFTED" } };
501
+
502
+ // A serverEntry that reflects the currently-loaded home title, so a test can
503
+ // prove the draft override actually reached the render (not just the headers).
504
+ const REFLECT_ENTRY = {
505
+ fetch: async () => {
506
+ const { loadBlocks } = await import("@decocms/blocks/cms");
507
+ const home = (loadBlocks()["pages-home"] as { title?: string } | undefined) ?? {};
508
+ return new Response(home.title ?? "PUBLISHED", { status: 200 });
509
+ },
510
+ };
511
+
512
+ let realFetch: typeof fetch;
513
+ afterEach(async () => {
514
+ globalThis.fetch = realFetch;
515
+ const { setDraftPreviewHosts, clearDraftCache } = await import("@decocms/blocks/cms");
516
+ setDraftPreviewHosts([]);
517
+ clearDraftCache();
518
+ __resetKvHydrationStateForTests();
519
+ setBlocks({});
520
+ });
521
+
522
+ async function enable() {
523
+ const { setDraftPreviewHosts, clearDraftCache } = await import("@decocms/blocks/cms");
524
+ setBlocks({ "pages-home": { name: "home", path: "/", title: "PUBLISHED" } });
525
+ setDraftPreviewHosts(["example.com"]);
526
+ clearDraftCache();
527
+ realFetch = globalThis.fetch;
528
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
529
+ if (String(input) === SANDBOX_URL) {
530
+ return new Response(JSON.stringify(DRAFT_BLOCKS), {
531
+ status: 200,
532
+ headers: { "content-type": "application/json" },
533
+ });
534
+ }
535
+ throw new Error(`unexpected fetch: ${String(input)}`);
536
+ }) as unknown as typeof fetch;
537
+ }
538
+
539
+ it("binds the draft, bypasses the edge cache, and stamps the anti-leak headers", async () => {
540
+ await enable();
541
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
542
+ const res = await worker.fetch(
543
+ new Request(`https://example.com/?__draft=${POINTER}`, {
544
+ headers: { host: "example.com" },
545
+ }),
546
+ EMPTY_ENV,
547
+ MOCK_CTX,
548
+ );
549
+
550
+ // The render saw the merged draft decofile.
551
+ expect(await res.text()).toBe("DRAFTED");
552
+ // Never cached under the shared URL.
553
+ expect(res.headers.get("X-Cache")).toBe("BYPASS");
554
+ expect(res.headers.get("Cache-Control")).toContain("no-store");
555
+ expect(res.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
556
+ // Entry persists the navigation cookie.
557
+ const cookie = res.headers.get("set-cookie") ?? "";
558
+ expect(cookie).toContain("__deco_draft=");
559
+ expect(cookie).toContain("SameSite=None");
560
+ expect(cookie).toContain("Partitioned");
561
+ });
562
+
563
+ it("carries the draft across navigation via the cookie alone", async () => {
564
+ await enable();
565
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
566
+ const res = await worker.fetch(
567
+ new Request("https://example.com/other", {
568
+ headers: { host: "example.com", cookie: `__deco_draft=${POINTER}` },
569
+ }),
570
+ EMPTY_ENV,
571
+ MOCK_CTX,
572
+ );
573
+ expect(await res.text()).toBe("DRAFTED");
574
+ expect(res.headers.get("X-Cache")).toBe("BYPASS");
575
+ });
576
+
577
+ it("is structurally inert on a non-preview host", async () => {
578
+ await enable(); // allowlist = example.com only
579
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
580
+ const res = await worker.fetch(
581
+ new Request(`https://prod.example/?__draft=${POINTER}`, {
582
+ headers: { host: "prod.example" },
583
+ }),
584
+ EMPTY_ENV,
585
+ MOCK_CTX,
586
+ );
587
+ expect(await res.text()).toBe("PUBLISHED");
588
+ expect(res.headers.get("X-Robots-Tag")).toBeNull();
589
+ expect(res.headers.get("set-cookie")).toBeNull();
590
+ });
591
+
592
+ it("stays inert on any host when no allowlist is configured", async () => {
593
+ setBlocks({ "pages-home": { name: "home", path: "/", title: "PUBLISHED" } });
594
+ const worker = createDecoWorkerEntry(REFLECT_ENTRY, { observability: false });
595
+ const res = await worker.fetch(
596
+ new Request(`https://example.com/?__draft=${POINTER}`, {
597
+ headers: { host: "example.com" },
598
+ }),
599
+ EMPTY_ENV,
600
+ MOCK_CTX,
601
+ );
602
+ expect(await res.text()).toBe("PUBLISHED");
603
+ expect(res.headers.get("X-Robots-Tag")).toBeNull();
604
+ });
605
+ });
@@ -71,6 +71,14 @@ import { type Device, isMobileUA } from "@decocms/blocks/sdk/useDevice";
71
71
  import { getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
72
72
  import { buildHtmlShell } from "@decocms/blocks-admin/sdk/htmlShell";
73
73
  import { getAppMiddleware } from "@decocms/blocks-admin/sdk/setupApps";
74
+ import {
75
+ applyDraftCookieAndHeaders,
76
+ bindRequestDraft,
77
+ type DraftDecision,
78
+ installPreviewHostsFromBlocks,
79
+ registerDraftOverride,
80
+ requestCarriesDraft,
81
+ } from "./draft";
74
82
  import { ensureBlocksHydrated, maybePollRevision } from "./kvHydration";
75
83
  import { DECO_POWERED_BY, installDefaultUserAgent } from "./outboundHeaders";
76
84
 
@@ -803,6 +811,22 @@ function installAutoGeoListener(): void {
803
811
  update(loadBlocks());
804
812
  }
805
813
 
814
+ let _draftPreviewInstalled = false;
815
+
816
+ /**
817
+ * Wire draft preview once: register the request-scoped draft getter with the
818
+ * runtime and keep the `previewHosts` allowlist in sync with the base decofile.
819
+ * Registered unconditionally (idempotent), but the feature stays fully inert
820
+ * until an allowlist names the request's host — see `./draft`.
821
+ */
822
+ function installDraftPreviewListener(): void {
823
+ if (_draftPreviewInstalled) return;
824
+ _draftPreviewInstalled = true;
825
+ registerDraftOverride();
826
+ onChange((blocks) => installPreviewHostsFromBlocks(blocks));
827
+ installPreviewHostsFromBlocks(loadBlocks());
828
+ }
829
+
806
830
  const IMMUTABLE_HEADERS: Record<string, string> = {
807
831
  "Cache-Control": `public, max-age=${ONE_YEAR}, immutable`,
808
832
  Vary: "Accept-Encoding",
@@ -883,6 +907,10 @@ export function createDecoWorkerEntry(
883
907
  installAutoGeoListener();
884
908
  }
885
909
 
910
+ // Draft preview (pull-based): getter DI + previewHosts sync. Inert unless
911
+ // the site opts in via `previewHosts` / DECO_ALLOWED_PREVIEW_HOSTS.
912
+ installDraftPreviewListener();
913
+
886
914
  /** Resolve the effective geo granularity for the current request. */
887
915
  function effectiveGeoKey(): "off" | "country" | "region" | "city" {
888
916
  return geoCacheKeyOpt === "auto" ? _autoGeoKey : geoCacheKeyOpt;
@@ -963,7 +991,10 @@ export function createDecoWorkerEntry(
963
991
  function isCacheable(request: Request, url: URL): boolean {
964
992
  if (request.method !== "GET") return false;
965
993
  if (isBypassPath(url.pathname)) return false;
966
- if (url.searchParams.has("__deco_draft")) return false;
994
+ // Draft preview: never cache a request in draft mode. Keys on the real
995
+ // `?__draft=` param OR the navigation cookie (SPA nav after entry), gated
996
+ // on an allowed preview host — see requestCarriesDraft.
997
+ if (requestCarriesDraft(request, url)) return false;
967
998
  if (url.searchParams.has("__deco_preview")) return false;
968
999
  if (url.searchParams.has("pathTemplate")) return false;
969
1000
  // Forced matcher results must never be served from (or stored in) the
@@ -1452,6 +1483,12 @@ export function createDecoWorkerEntry(
1452
1483
  // become no-ops.
1453
1484
  const identity = { requestId: "", traceId: "" };
1454
1485
 
1486
+ // Draft-preview decision — resolved inside RequestContext.run (below) so
1487
+ // the resolved decofile binds to the request bag before the render, then
1488
+ // lifted here so the cookie + no-store/noindex headers can be stamped on
1489
+ // the final response. Inert on non-preview builds/hosts.
1490
+ let draftDecision: DraftDecision = { previewing: false, setCookie: null, clearCookie: false };
1491
+
1455
1492
  // Wrap the entire request in a RequestContext so that all code
1456
1493
  // in the call stack (loaders, invoke handlers, vtexFetchWithCookies)
1457
1494
  // can access the request and write response headers.
@@ -1480,6 +1517,11 @@ export function createDecoWorkerEntry(
1480
1517
  // production request without changing global sampling rates.
1481
1518
  if (new URL(request.url).searchParams.has("__d")) _setDebugSampled();
1482
1519
 
1520
+ // Resolve + bind any draft preview BEFORE the render, so every
1521
+ // loadBlocks() in the call stack sees the merged draft decofile via
1522
+ // the request bag. Cheap no-op unless the site opts in.
1523
+ draftDecision = await bindRequestDraft(request);
1524
+
1483
1525
  // Wrap inner handler in a single root span carrying our normalized
1484
1526
  // path/method attributes. The framework span flows BOTH ways:
1485
1527
  // - via the OTLP direct-POST tracer (when DECO_OTEL_TRACES_ENDPOINT
@@ -1591,6 +1633,11 @@ export function createDecoWorkerEntry(
1591
1633
  }
1592
1634
  }
1593
1635
 
1636
+ // Draft preview: persist/clear the navigation cookie and stamp the
1637
+ // no-store/noindex headers so a drafted response is never cached under
1638
+ // its shared (cookie-only) URL. No-op when not previewing.
1639
+ applyDraftCookieAndHeaders(finalResponse, draftDecision);
1640
+
1594
1641
  // Metrics + structured request log. Done after security headers so
1595
1642
  // the recorded status reflects what the client actually receives.
1596
1643
  // Both calls are no-ops when no meter / logger is configured.
@@ -1829,6 +1876,20 @@ export function createDecoWorkerEntry(
1829
1876
  ? ((caches as unknown as { default?: Cache }).default ?? null)
1830
1877
  : null;
1831
1878
 
1879
+ // Draft preview: a drafted SPA navigation fetches its section data via
1880
+ // this POST, carrying the draft cookie. The draft is already bound to the
1881
+ // request bag (bindRequestDraft, above), so origin renders it — but the
1882
+ // body-hash cache key does not vary on the cookie, so it must bypass the
1883
+ // shared cache entirely or it would leak drafts / serve stale published.
1884
+ if (requestCarriesDraft(request, url)) {
1885
+ const origin = await serverEntry.fetch(request, env, ctx);
1886
+ const resp = new Response(origin.body, origin);
1887
+ resp.headers.set("Cache-Control", "no-store, private");
1888
+ resp.headers.set("X-Cache", "BYPASS");
1889
+ resp.headers.set("X-Cache-Reason", "draft-preview");
1890
+ return resp;
1891
+ }
1892
+
1832
1893
  // Build segment once — used for logged-in check and cache key
1833
1894
  const sfnSegment = buildSegment ? buildSegment(request) : undefined;
1834
1895