@decocms/tanstack 8.0.0 → 8.1.0-next.0

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.
Files changed (49) hide show
  1. package/package.json +12 -5
  2. package/src/__conformance__/observability.test.ts +221 -0
  3. package/src/hooks/CdnSegmentMarker.tsx +38 -0
  4. package/src/hooks/DecoPageRenderer.tsx +91 -29
  5. package/src/hooks/DecoRootLayout.tsx +139 -58
  6. package/src/hooks/DraftPreviewIndicator.test.tsx +60 -0
  7. package/src/hooks/DraftPreviewIndicator.tsx +64 -0
  8. package/src/hooks/NavigationProgress.test.tsx +58 -0
  9. package/src/hooks/NavigationProgress.tsx +45 -3
  10. package/src/hooks/PreviewProviders.test.tsx +42 -0
  11. package/src/hooks/PreviewProviders.tsx +21 -2
  12. package/src/hooks/deferredObserverTiming.test.tsx +91 -0
  13. package/src/hooks/index.ts +1 -0
  14. package/src/index.ts +30 -13
  15. package/src/routes/cmsRoute.test.ts +168 -1
  16. package/src/routes/cmsRoute.ts +148 -39
  17. package/src/routes/pageUrl.test.ts +6 -6
  18. package/src/routes/pageUrl.ts +5 -4
  19. package/src/routes/withSiteGlobals.test.ts +37 -0
  20. package/src/routes/withSiteGlobals.ts +45 -24
  21. package/src/sdk/backgroundTasks.test.ts +68 -0
  22. package/src/sdk/backgroundTasks.ts +38 -0
  23. package/src/sdk/cdnSegment.test.ts +67 -0
  24. package/src/sdk/cdnSegment.ts +71 -0
  25. package/src/sdk/deferredSectionLoader.test.ts +1 -1
  26. package/src/sdk/draft.test.ts +341 -0
  27. package/src/sdk/draft.ts +277 -0
  28. package/src/sdk/draftShared.ts +16 -0
  29. package/src/sdk/kvHydration.test.ts +156 -0
  30. package/src/sdk/kvHydration.ts +82 -12
  31. package/src/sdk/kvRedirects.test.ts +132 -0
  32. package/src/sdk/kvRedirects.ts +125 -0
  33. package/src/sdk/router.ts +70 -61
  34. package/src/sdk/serverFnFetch.ts +59 -0
  35. package/src/sdk/speculationRules.test.ts +82 -0
  36. package/src/sdk/speculationRules.ts +136 -0
  37. package/src/sdk/startEntry.ts +31 -0
  38. package/src/sdk/workerEntry.cacheStorage.test.ts +147 -0
  39. package/src/sdk/workerEntry.test.ts +554 -16
  40. package/src/sdk/workerEntry.trackingCache.test.ts +328 -0
  41. package/src/sdk/workerEntry.ts +915 -68
  42. package/src/v8/__conformance__/delivery.test.ts +100 -0
  43. package/src/v8/binding.ts +70 -0
  44. package/src/v8/kvLoader.bundle.test.ts +42 -0
  45. package/src/v8/kvLoader.test.ts +74 -0
  46. package/src/v8/kvLoader.ts +43 -0
  47. package/src/vite/plugin.js +180 -8
  48. package/src/vite/plugin.test.js +126 -1
  49. package/src/vite/plugin.test.ts +125 -1
@@ -0,0 +1,341 @@
1
+ import {
2
+ clearDraftCache,
3
+ getRequestDraftOverride,
4
+ isDraftPreviewEnabled,
5
+ setDecoSiteHost,
6
+ setDraftPreviewHosts,
7
+ } from "@decocms/blocks/cms";
8
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+
11
+ import {
12
+ applyDraftCookieAndHeaders,
13
+ bindRequestDraft,
14
+ type DraftDecision,
15
+ installDecoSiteHostFromEnv,
16
+ installPreviewHostsFromBlocks,
17
+ registerDraftOverride,
18
+ requestCarriesDraft,
19
+ } from "./draft";
20
+ import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
21
+
22
+ const HOST = "preview.example";
23
+ const DRAFT_BLOCKS = { "pages-home": { title: "draft" } };
24
+
25
+ function req(
26
+ opts: { url?: string; host?: string; cookie?: string } = {},
27
+ ): Request {
28
+ const headers = new Headers();
29
+ headers.set("host", opts.host ?? HOST);
30
+ if (opts.cookie) headers.set("cookie", opts.cookie);
31
+ return new Request(opts.url ?? `https://${opts.host ?? HOST}/some/page`, {
32
+ headers,
33
+ });
34
+ }
35
+
36
+ /** A fetch stub serving the sandbox decofile; throws if `fail`. */
37
+ function sandboxFetch(fail = false): typeof fetch {
38
+ return (async () => {
39
+ if (fail) throw new Error("unreachable");
40
+ return new Response(JSON.stringify(DRAFT_BLOCKS), {
41
+ status: 200,
42
+ headers: { "content-type": "application/json" },
43
+ });
44
+ }) as unknown as typeof fetch;
45
+ }
46
+
47
+ beforeEach(() => {
48
+ clearDraftCache();
49
+ // Allowlist is globalThis-backed (the site block's previewHosts), so no env
50
+ // needed — this makes the feature "enabled" for the tests below.
51
+ setDraftPreviewHosts([HOST, "localhost:3100"]);
52
+ });
53
+
54
+ afterEach(() => {
55
+ setDraftPreviewHosts([]);
56
+ });
57
+
58
+ describe("requestCarriesDraft", () => {
59
+ it("is inert when no allowlist is configured", () => {
60
+ setDraftPreviewHosts([]);
61
+ const url = new URL(
62
+ `https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
63
+ );
64
+ expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(false);
65
+ });
66
+
67
+ it("detects the ?__draft= entry param", () => {
68
+ const url = new URL(
69
+ `https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
70
+ );
71
+ expect(requestCarriesDraft(req({ url: url.toString() }), url)).toBe(true);
72
+ });
73
+
74
+ it("detects the navigation cookie (SPA nav after entry)", () => {
75
+ const url = new URL(`https://${HOST}/p`);
76
+ expect(
77
+ requestCarriesDraft(
78
+ req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }),
79
+ url,
80
+ ),
81
+ ).toBe(true);
82
+ });
83
+
84
+ it("is false on ?__draft=off (leaving preview)", () => {
85
+ const url = new URL(`https://${HOST}/p?__draft=off`);
86
+ expect(
87
+ requestCarriesDraft(
88
+ req({ url: url.toString(), cookie: "__deco_draft=abc.x@v1" }),
89
+ url,
90
+ ),
91
+ ).toBe(false);
92
+ });
93
+
94
+ it("is structurally inert on a non-preview host, param or not", () => {
95
+ const url = new URL("https://prod.example/p?__draft=abc.x@v1");
96
+ expect(
97
+ requestCarriesDraft(
98
+ req({ url: url.toString(), host: "prod.example" }),
99
+ url,
100
+ ),
101
+ ).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe("bindRequestDraft", () => {
106
+ it("binds the resolved draft to the request bag on entry", async () => {
107
+ registerDraftOverride();
108
+ const pointer = "studio.decocms.com/api/o/decofile/m/main?token=t@v1";
109
+ const request = req({ url: `https://${HOST}/p?__draft=${pointer}` });
110
+
111
+ const decision = await RequestContext.run(request, async () => {
112
+ const d = await bindRequestDraft(request, sandboxFetch());
113
+ // loadBlocks() reads this back via the DI getter registered above.
114
+ expect(getRequestDraftOverride()).toEqual(DRAFT_BLOCKS);
115
+ return d;
116
+ });
117
+
118
+ expect(decision).toEqual({
119
+ previewing: true,
120
+ setCookie: pointer,
121
+ clearCookie: false,
122
+ });
123
+ });
124
+
125
+ it("carries the draft across navigation via the cookie (no re-set)", async () => {
126
+ registerDraftOverride();
127
+ const pointer = "abc.preview-studio.decocms.com@v1";
128
+ const request = req({
129
+ url: `https://${HOST}/other`,
130
+ cookie: `__deco_draft=${pointer}`,
131
+ });
132
+
133
+ const decision = await RequestContext.run(request, () =>
134
+ bindRequestDraft(request, sandboxFetch()),
135
+ );
136
+
137
+ // previewing, but setCookie is null — the cookie is already present.
138
+ expect(decision).toEqual({
139
+ previewing: true,
140
+ setCookie: null,
141
+ clearCookie: false,
142
+ });
143
+ });
144
+
145
+ it("decodes a URL-encoded cookie so the badge pointer round-trips (no double-encode)", async () => {
146
+ // The cookie is written with encodeURIComponent (`:`→%3A, `@`→%40); the
147
+ // pointer stashed for the badge must be the DECODED value, or the badge's
148
+ // share link would re-encode an already-encoded string.
149
+ registerDraftOverride();
150
+ const decoded = "abc.localhost:60534/api/o/decofile/m/main?token=t@v7";
151
+ const request = req({
152
+ url: `https://${HOST}/other`,
153
+ cookie: `__deco_draft=${encodeURIComponent(decoded)}`,
154
+ });
155
+
156
+ const stored = await RequestContext.run(request, async () => {
157
+ await bindRequestDraft(request, sandboxFetch());
158
+ return RequestContext.getBag<string>(DRAFT_POINTER_BAG_KEY);
159
+ });
160
+
161
+ expect(stored).toBe(decoded);
162
+ });
163
+
164
+ it("stays previewing (uncacheable) even when the draft fails to resolve", async () => {
165
+ const request = req({
166
+ url: `https://${HOST}/p?__draft=abc.preview-studio.decocms.com@v1`,
167
+ });
168
+ const decision = await RequestContext.run(request, () =>
169
+ bindRequestDraft(request, sandboxFetch(true)),
170
+ );
171
+ expect(decision.previewing).toBe(true);
172
+ expect(decision.setCookie).toBe("abc.preview-studio.decocms.com@v1");
173
+ });
174
+
175
+ it("clears the cookie on ?__draft=off", async () => {
176
+ const request = req({
177
+ url: `https://${HOST}/p?__draft=off`,
178
+ cookie: "__deco_draft=x@v1",
179
+ });
180
+ const decision = await RequestContext.run(request, () =>
181
+ bindRequestDraft(request),
182
+ );
183
+ expect(decision).toEqual({
184
+ previewing: false,
185
+ setCookie: null,
186
+ clearCookie: true,
187
+ });
188
+ });
189
+
190
+ it("is inert on a non-preview host", async () => {
191
+ const request = req({
192
+ url: "https://prod.example/p?__draft=abc.x@v1",
193
+ host: "prod.example",
194
+ });
195
+ const decision = await RequestContext.run(request, () =>
196
+ bindRequestDraft(request),
197
+ );
198
+ expect(decision).toEqual({
199
+ previewing: false,
200
+ setCookie: null,
201
+ clearCookie: false,
202
+ });
203
+ });
204
+ });
205
+
206
+ describe("applyDraftCookieAndHeaders", () => {
207
+ const entry: DraftDecision = {
208
+ previewing: true,
209
+ setCookie: "abc@v1",
210
+ clearCookie: false,
211
+ };
212
+
213
+ it("sets a cross-site-iframe-safe cookie and the anti-leak headers on entry", () => {
214
+ const res = new Response("ok");
215
+ applyDraftCookieAndHeaders(res, entry);
216
+
217
+ const cookie = res.headers.get("set-cookie") ?? "";
218
+ expect(cookie).toContain("__deco_draft=abc%40v1"); // pointer is URL-encoded
219
+ expect(cookie).toContain("SameSite=None");
220
+ expect(cookie).toContain("Secure");
221
+ expect(cookie).toContain("Partitioned");
222
+ expect(cookie).toContain("HttpOnly");
223
+
224
+ expect(res.headers.get("cache-control")).toBe("no-store, private");
225
+ expect(res.headers.get("vary")).toBe("Cookie");
226
+ expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
227
+ });
228
+
229
+ it("expires the cookie when leaving preview", () => {
230
+ const res = new Response("ok");
231
+ applyDraftCookieAndHeaders(res, {
232
+ previewing: false,
233
+ setCookie: null,
234
+ clearCookie: true,
235
+ });
236
+ expect(res.headers.get("set-cookie") ?? "").toContain("Max-Age=0");
237
+ // Not previewing → no cache/index headers.
238
+ expect(res.headers.get("cache-control")).toBeNull();
239
+ });
240
+
241
+ it("merges Cookie into an existing Vary instead of clobbering it", () => {
242
+ const res = new Response("ok", { headers: { Vary: "Accept-Encoding" } });
243
+ applyDraftCookieAndHeaders(res, {
244
+ previewing: true,
245
+ setCookie: null,
246
+ clearCookie: false,
247
+ });
248
+ expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
249
+ });
250
+
251
+ it("leaves a Vary that already lists Cookie untouched (no token dropped)", () => {
252
+ const res = new Response("ok", {
253
+ headers: { Vary: "Accept-Encoding, Cookie" },
254
+ });
255
+ applyDraftCookieAndHeaders(res, {
256
+ previewing: true,
257
+ setCookie: null,
258
+ clearCookie: false,
259
+ });
260
+ expect(res.headers.get("vary")).toBe("Accept-Encoding, Cookie");
261
+ });
262
+
263
+ it("does nothing for an ordinary (non-draft) response", () => {
264
+ const res = new Response("ok");
265
+ applyDraftCookieAndHeaders(res, {
266
+ previewing: false,
267
+ setCookie: null,
268
+ clearCookie: false,
269
+ });
270
+ expect(res.headers.get("set-cookie")).toBeNull();
271
+ expect(res.headers.get("cache-control")).toBeNull();
272
+ });
273
+ });
274
+
275
+ describe("installPreviewHostsFromBlocks", () => {
276
+ it("installs previewHosts from the site block (either casing)", async () => {
277
+ setDraftPreviewHosts([]);
278
+ installPreviewHostsFromBlocks({
279
+ Site: { previewHosts: ["acme.vtex.app"] },
280
+ });
281
+
282
+ // Verified through the wire: an allowed host now previews.
283
+ const request = req({
284
+ url: "https://acme.vtex.app/p?__draft=abc.preview-studio.decocms.com@v1",
285
+ host: "acme.vtex.app",
286
+ });
287
+ const url = new URL(request.url);
288
+ expect(requestCarriesDraft(request, url)).toBe(true);
289
+ });
290
+
291
+ it("ignores blocks without previewHosts", () => {
292
+ setDraftPreviewHosts([]);
293
+ installPreviewHostsFromBlocks({ site: {} });
294
+ const url = new URL("https://acme.vtex.app/p?__draft=x@v1");
295
+ expect(
296
+ requestCarriesDraft(
297
+ req({ url: url.toString(), host: "acme.vtex.app" }),
298
+ url,
299
+ ),
300
+ ).toBe(false);
301
+ });
302
+ });
303
+
304
+ describe("installDecoSiteHostFromEnv", () => {
305
+ afterEach(() => {
306
+ setDecoSiteHost(null);
307
+ });
308
+
309
+ it("arms the deco-hosted domains from the DECO_SITE_NAME binding", () => {
310
+ setDraftPreviewHosts([]);
311
+ installDecoSiteHostFromEnv({ DECO_SITE_NAME: "acme-tanstack" });
312
+
313
+ // Verified through the wire: both deco-operated hosts now preview, with no
314
+ // site-block/env config at all.
315
+ for (const host of [
316
+ "acme-tanstack.deco.site",
317
+ "acme-tanstack.deco-cx.workers.dev",
318
+ ]) {
319
+ const url = new URL(
320
+ `https://${host}/p?__draft=abc.preview-studio.decocms.com@v1`,
321
+ );
322
+ expect(requestCarriesDraft(req({ url: url.toString(), host }), url)).toBe(true);
323
+ }
324
+ // A custom production domain is never inferred.
325
+ const url = new URL("https://www.acme.com.br/p?__draft=x@v1");
326
+ expect(
327
+ requestCarriesDraft(
328
+ req({ url: url.toString(), host: "www.acme.com.br" }),
329
+ url,
330
+ ),
331
+ ).toBe(false);
332
+ });
333
+
334
+ it("an unset or non-string binding registers nothing", () => {
335
+ setDraftPreviewHosts([]);
336
+ installDecoSiteHostFromEnv({});
337
+ expect(isDraftPreviewEnabled({})).toBe(false);
338
+ installDecoSiteHostFromEnv({ DECO_SITE_NAME: 42 });
339
+ expect(isDraftPreviewEnabled({})).toBe(false);
340
+ });
341
+ });
@@ -0,0 +1,277 @@
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
+ setDecoSiteHost,
41
+ setDraftOverrideGetter,
42
+ setDraftPreviewHosts,
43
+ } from "@decocms/blocks/cms";
44
+ import { RequestContext } from "@decocms/blocks/sdk/requestContext";
45
+ import { DRAFT_POINTER_BAG_KEY } from "./draftShared";
46
+
47
+ /** RequestContext bag key the resolved draft decofile is stashed under. */
48
+ const DRAFT_BAG_KEY = "deco:draftBlocks";
49
+
50
+ /**
51
+ * Cookie lifetime. Short on purpose: a preview session is minutes, and a stale
52
+ * pointer would keep pinning an old content version. Matches the Next binding.
53
+ */
54
+ const DRAFT_COOKIE_MAX_AGE = 60 * 30;
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Runtime binding (dependency injection into @decocms/blocks)
58
+ // ---------------------------------------------------------------------------
59
+
60
+ let registered = false;
61
+
62
+ /**
63
+ * Register the request-scoped draft getter with the runtime, once.
64
+ *
65
+ * `loadBlocks()` consults `getRequestDraftOverride()` as a fallback after its
66
+ * own `withBlocksOverride` scope; this points that fallback at the
67
+ * RequestContext bag {@link bindRequestDraft} fills. Idempotent and safe to
68
+ * call at setup: outside a request scope `getBag` returns undefined, so
69
+ * `loadBlocks()` behaves exactly as before.
70
+ */
71
+ export function registerDraftOverride(): void {
72
+ if (registered) return;
73
+ registered = true;
74
+ setDraftOverrideGetter(
75
+ () => RequestContext.getBag<Record<string, unknown>>(DRAFT_BAG_KEY) ?? null,
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Install the site block's `previewHosts` as the draft-preview allowlist.
81
+ *
82
+ * Reads the BASE blocks (the `setBlocks()` argument / `loadBlocks()` at setup
83
+ * time), never the request-time merged blocks: an allowlist readable through
84
+ * the draft override could be rewritten by the very draft it gates.
85
+ * `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces
86
+ * this list when set. Handles both decofile shapes (`site` / `Site`), same as
87
+ * `withSiteGlobals`.
88
+ */
89
+ export function installPreviewHostsFromBlocks(blocks: Record<string, unknown> | undefined): void {
90
+ const site = (blocks?.site ?? blocks?.Site) as { previewHosts?: unknown } | undefined;
91
+ if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts);
92
+ }
93
+
94
+ /**
95
+ * Register the deco-operated preview hosts inferred from the site name —
96
+ * `<site>.deco.site` and `<site>.deco-cx.workers.dev` (the workers.dev deploy
97
+ * URL, e.g. `acme-tanstack.deco-cx.workers.dev`) — so a signed
98
+ * `?__draft=` grant previews on deco-hosted infra with zero per-site config.
99
+ *
100
+ * Fed from the Workers env binding `DECO_SITE_NAME`: deploy-time configuration
101
+ * set by deco's hosting, trusted the same way `DECO_ALLOWED_PREVIEW_HOSTS` is —
102
+ * never derived from the request. Merged ON TOP of the site block/env list
103
+ * (see `setDecoSiteHost` in `@decocms/blocks`); an unset binding registers
104
+ * nothing, and `DECO_ALLOWED_PREVIEW_HOSTS=none` kills the inferred hosts too.
105
+ *
106
+ * This inference is deliberately tanstack-only (like Fast Deploy): the
107
+ * deco-operated domains only ever serve Workers deployments, and in the Next
108
+ * binding flipping `isDraftPreviewEnabled()` on costs every page its
109
+ * static/ISR rendering — Next sites keep the explicit opt-in.
110
+ */
111
+ export function installDecoSiteHostFromEnv(env: Record<string, unknown>): void {
112
+ const site = env.DECO_SITE_NAME;
113
+ setDecoSiteHost(typeof site === "string" ? site : undefined);
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Per-request resolution + response decoration
118
+ // ---------------------------------------------------------------------------
119
+
120
+ /**
121
+ * Read one cookie value out of a raw `Cookie:` header.
122
+ *
123
+ * Decodes the value — the cookie is written with `encodeURIComponent` (see
124
+ * `draftCookie`), and `@decocms/blocks`'s own cookie reader decodes too, so a
125
+ * cookie-carried pointer must round-trip identically or the badge's share link
126
+ * would double-encode it. Decode defensively: a malformed `%` sequence
127
+ * (a crafted cookie) falls back to the raw value rather than throwing and
128
+ * 500-ing the request.
129
+ */
130
+ function readCookie(cookieHeader: string | null, name: string): string | null {
131
+ if (!cookieHeader) return null;
132
+ for (const part of cookieHeader.split(";")) {
133
+ const eq = part.indexOf("=");
134
+ if (eq === -1) continue;
135
+ if (part.slice(0, eq).trim() === name) {
136
+ const raw = part.slice(eq + 1).trim();
137
+ try {
138
+ return decodeURIComponent(raw);
139
+ } catch {
140
+ return raw;
141
+ }
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * Whether a request is in draft mode — carries a live `?__draft=` param or the
149
+ * navigation cookie — on a host allowed to preview.
150
+ *
151
+ * A pure, synchronous, network-free check (no fetch, no resolve): the worker
152
+ * uses it to keep drafted requests out of the shared edge cache *before* doing
153
+ * any work. Gated on `isDraftPreviewEnabled()` first, so a build without the
154
+ * allowlist configured is completely unaffected — a stray `?__draft=` on a
155
+ * production domain neither renders a draft nor even bypasses the cache.
156
+ */
157
+ export function requestCarriesDraft(request: Request, url: URL): boolean {
158
+ if (!isDraftPreviewEnabled()) return false;
159
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
160
+ if (!isDraftHostAllowed(host)) return false;
161
+ const param = url.searchParams.get(DRAFT_QUERY_PARAM);
162
+ if (param === "off") return false;
163
+ if (param) return true;
164
+ return readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME) !== null;
165
+ }
166
+
167
+ /** The cookie/header actions a drafted request implies for its response. */
168
+ export interface DraftDecision {
169
+ /**
170
+ * The request is previewing (a pointer is present on an allowed host), so its
171
+ * response must never be cached under the shared (cookie-only) URL — even if
172
+ * the draft failed to resolve and the page fell back to published content.
173
+ */
174
+ previewing: boolean;
175
+ /** Persist this pointer in the navigation cookie (set only on `?__draft=` entry). */
176
+ setCookie: string | null;
177
+ /** Clear the navigation cookie (`?__draft=off`). */
178
+ clearCookie: boolean;
179
+ }
180
+
181
+ const INERT: DraftDecision = { previewing: false, setCookie: null, clearCookie: false };
182
+
183
+ /**
184
+ * Resolve this request's draft, if any, and bind it to the RequestContext so
185
+ * every `loadBlocks()` in the render sees the merged decofile.
186
+ *
187
+ * Call inside `RequestContext.run`, before the page render. Returns the
188
+ * cookie/header decision for the worker to apply to the final response. A
189
+ * missing allowlist, disallowed host, or `?__draft=off` all short-circuit
190
+ * without touching the network; a resolve failure degrades to published but
191
+ * still reports `previewing: true` so the response stays uncacheable.
192
+ */
193
+ export async function bindRequestDraft(
194
+ request: Request,
195
+ fetchImpl?: typeof fetch,
196
+ ): Promise<DraftDecision> {
197
+ if (!isDraftPreviewEnabled()) return INERT;
198
+
199
+ const url = new URL(request.url);
200
+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
201
+ // Authoritative host gate — unlike Next, there is no separate middleware
202
+ // module graph, so this single check bounds the whole feature.
203
+ if (!isDraftHostAllowed(host)) return INERT;
204
+
205
+ const param = url.searchParams.get(DRAFT_QUERY_PARAM);
206
+ if (param === "off") return { previewing: false, setCookie: null, clearCookie: true };
207
+
208
+ const cookie = readCookie(request.headers.get("cookie"), DRAFT_COOKIE_NAME);
209
+ const pointer = param ?? cookie ?? null;
210
+ if (!pointer) return INERT;
211
+
212
+ const blocks = await resolveDraftForRequest(request, { fetchImpl });
213
+ if (blocks) {
214
+ RequestContext.setBag(DRAFT_BAG_KEY, blocks);
215
+ // Expose the pointer to the SSR React tree so the preview badge can render
216
+ // and build its share/exit links. Only when the draft actually bound —
217
+ // the badge means "you're looking at draft content", matching Next.
218
+ RequestContext.setBag(DRAFT_POINTER_BAG_KEY, pointer);
219
+ }
220
+
221
+ // Persist only on entry (`?__draft=`); in-preview navigation rides the cookie
222
+ // already present.
223
+ return { previewing: true, setCookie: param ?? null, clearCookie: false };
224
+ }
225
+
226
+ /** Build the `Set-Cookie` value for the draft navigation cookie. */
227
+ function draftCookie(value: string, maxAge: number): string {
228
+ // SameSite=None; Secure is mandatory for the cross-site preview iframe, and
229
+ // Partitioned (CHIPS) keeps it working as browsers wind down unpartitioned
230
+ // third-party cookies. HttpOnly: the pointer is read server-side only.
231
+ return `${DRAFT_COOKIE_NAME}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=None; Partitioned`;
232
+ }
233
+
234
+ /** Set a header defensively — some replayed responses seal their headers. */
235
+ function trySet(response: Response, key: string, value: string): void {
236
+ try {
237
+ response.headers.set(key, value);
238
+ } catch {
239
+ /* sealed headers (cached replay) — best-effort */
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Apply the cookie lifecycle and the cache/indexing headers a draft response
245
+ * requires.
246
+ *
247
+ * The caching headers are the difference between a preview and a leak: with the
248
+ * pointer in a cookie, a drafted response and a published one share an
249
+ * identical URL, so `no-store` is what stops a CDN keyed on URL alone from
250
+ * serving unpublished content to a real visitor. `Vary: Cookie` keeps any
251
+ * intermediary that does respect it from mixing the two; `X-Robots-Tag` keeps a
252
+ * leaked draft out of search results.
253
+ */
254
+ export function applyDraftCookieAndHeaders(response: Response, decision: DraftDecision): void {
255
+ if (decision.clearCookie) {
256
+ response.headers.append("set-cookie", draftCookie("", 0));
257
+ } else if (decision.setCookie) {
258
+ response.headers.append(
259
+ "set-cookie",
260
+ draftCookie(encodeURIComponent(decision.setCookie), DRAFT_COOKIE_MAX_AGE),
261
+ );
262
+ }
263
+
264
+ if (decision.previewing) {
265
+ trySet(response, "cache-control", "no-store, private");
266
+ // Add Cookie to Vary without dropping tokens already there (e.g.
267
+ // Accept-Encoding). Moot under no-store, but correct for any intermediary
268
+ // that honours Vary anyway.
269
+ const existingVary = response.headers.get("vary");
270
+ if (!existingVary) {
271
+ trySet(response, "vary", "Cookie");
272
+ } else if (!/\bcookie\b/i.test(existingVary)) {
273
+ trySet(response, "vary", `${existingVary}, Cookie`);
274
+ }
275
+ trySet(response, "x-robots-tag", "noindex, nofollow");
276
+ }
277
+ }
@@ -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__";