@decocms/nextjs 7.28.0-beta.5 → 7.28.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/nextjs",
3
- "version": "7.28.0-beta.5",
3
+ "version": "7.28.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -19,8 +19,7 @@
19
19
  "./config": {
20
20
  "types": "./src/config.d.cts",
21
21
  "default": "./src/config.cjs"
22
- },
23
- "./middleware": "./src/draftMiddleware.ts"
22
+ }
24
23
  },
25
24
  "scripts": {
26
25
  "build": "tsc",
@@ -29,8 +28,8 @@
29
28
  "lint:unused": "knip"
30
29
  },
31
30
  "dependencies": {
32
- "@decocms/blocks": "7.28.0-beta.5",
33
- "@decocms/blocks-admin": "7.28.0-beta.5"
31
+ "@decocms/blocks": "7.28.1",
32
+ "@decocms/blocks-admin": "7.28.1"
34
33
  },
35
34
  "peerDependencies": {
36
35
  "next": ">=15.0.0",
@@ -1,6 +1,6 @@
1
- import { registerSectionsSync } from "@decocms/blocks/cms";
2
1
  import { renderToString } from "react-dom/server";
3
2
  import { describe, expect, it } from "vitest";
3
+ import { registerSectionsSync } from "@decocms/blocks/cms";
4
4
  import { DecoPageRenderer } from "./DecoPageRenderer";
5
5
 
6
6
  function Hero({ label }: { label?: string }) {
@@ -16,18 +16,8 @@ describe("DecoPageRenderer (next)", () => {
16
16
 
17
17
  const element = await DecoPageRenderer({
18
18
  sections: [
19
- {
20
- key: "site/sections/NextPageA.tsx",
21
- component: "site/sections/NextPageA.tsx",
22
- props: { label: "first" },
23
- index: 0,
24
- } as any,
25
- {
26
- key: "site/sections/NextPageB.tsx",
27
- component: "site/sections/NextPageB.tsx",
28
- props: { label: "second" },
29
- index: 1,
30
- } as any,
19
+ { key: "site/sections/NextPageA.tsx", component: "site/sections/NextPageA.tsx", props: { label: "first" }, index: 0 } as any,
20
+ { key: "site/sections/NextPageB.tsx", component: "site/sections/NextPageB.tsx", props: { label: "second" }, index: 1 } as any,
31
21
  ],
32
22
  pagePath: "/",
33
23
  });
@@ -1,8 +1,8 @@
1
1
  import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
2
2
  import { resolveDeferredSection } from "@decocms/blocks/cms";
3
- import { cloneElement, type ReactElement, type ReactNode } from "react";
4
- import { DeferredSectionBoundary } from "./DeferredSection";
5
3
  import { SectionRenderer } from "./SectionRenderer";
4
+ import { DeferredSectionBoundary } from "./DeferredSection";
5
+ import { cloneElement, type ReactElement, type ReactNode } from "react";
6
6
 
7
7
  interface DecoPageRendererProps {
8
8
  sections: ResolvedSection[];
@@ -19,9 +19,7 @@ type PageItem =
19
19
 
20
20
  function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] {
21
21
  const items: PageItem[] = [];
22
- resolved.forEach((section, i) =>
23
- items.push({ type: "eager", section, sort: section.index ?? i }),
24
- );
22
+ resolved.forEach((section, i) => items.push({ type: "eager", section, sort: section.index ?? i }));
25
23
  for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
26
24
  items.sort((a, b) => a.sort - b.sort);
27
25
  return items;
@@ -56,12 +54,10 @@ export async function DecoPageRenderer({
56
54
  for (const d of deferredSections) {
57
55
  deferredPromises.set(
58
56
  d.index,
59
- resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then(
60
- (resolved) => {
61
- if (!resolved) return null;
62
- return { ...resolved, index: d.index };
63
- },
64
- ),
57
+ resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then((resolved) => {
58
+ if (!resolved) return null;
59
+ return { ...resolved, index: d.index };
60
+ }),
65
61
  );
66
62
  }
67
63
 
@@ -1,6 +1,6 @@
1
+ import type { ReactNode } from "react";
1
2
  import { LiveControls } from "@decocms/blocks/hooks";
2
3
  import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics";
3
- import type { ReactNode } from "react";
4
4
 
5
5
  function buildDecoEventsBootstrap(account?: string): string {
6
6
  const accountJson = JSON.stringify(account ?? "");
@@ -1,6 +1,6 @@
1
- import { registerSectionsSync } from "@decocms/blocks/cms";
2
1
  import { renderToString } from "react-dom/server";
3
2
  import { describe, expect, it } from "vitest";
3
+ import { registerSectionsSync } from "@decocms/blocks/cms";
4
4
  import { DeferredSectionBoundary } from "./DeferredSection";
5
5
 
6
6
  function Hero({ label }: { label?: string }) {
@@ -1,6 +1,6 @@
1
+ import { Suspense, type ReactNode } from "react";
1
2
  import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
2
3
  import { SectionErrorBoundary } from "@decocms/blocks/hooks";
3
- import { type ReactNode, Suspense } from "react";
4
4
  import { SectionRenderer } from "./SectionRenderer";
5
5
 
6
6
  interface DeferredSectionBoundaryProps {
@@ -1,6 +1,6 @@
1
- import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
2
1
  import { renderToString } from "react-dom/server";
3
2
  import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms";
4
4
  import { SectionRenderer } from "./SectionRenderer";
5
5
 
6
6
  function Hero({ label }: { label?: string }) {
@@ -1,7 +1,11 @@
1
+ import { createElement, type ReactNode } from "react";
2
+ import {
3
+ getSection,
4
+ getSectionOptions,
5
+ getSyncComponent,
6
+ } from "@decocms/blocks/cms";
1
7
  import type { ResolvedSection } from "@decocms/blocks/cms";
2
- import { getSection, getSectionOptions, getSyncComponent } from "@decocms/blocks/cms";
3
8
  import { SectionErrorBoundary } from "@decocms/blocks/hooks";
4
- import { createElement, type ReactNode } from "react";
5
9
  import { ClientOnlySection } from "./ClientOnlySection";
6
10
 
7
11
  interface SectionRendererProps {
package/src/config.cjs CHANGED
@@ -12,7 +12,6 @@
12
12
  * (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves
13
13
  * the whole protocol.
14
14
  * 2. transpilePackages for the raw-TS @decocms packages.
15
- * 3. Response headers that keep a draft-preview render out of every cache.
16
15
  */
17
16
  const DECO_REWRITES = [
18
17
  { source: "/.decofile", destination: "/deco/decofile" },
@@ -22,56 +21,8 @@ const DECO_REWRITES = [
22
21
 
23
22
  const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"];
24
23
 
25
- /**
26
- * A draft render must never be cached or indexed: with the pointer in a cookie
27
- * it shares a URL with the published page, so a shared cache keyed on URL alone
28
- * would serve unpublished content to a real visitor.
29
- *
30
- * Two rules, not one: `has` entries within a rule are ANDed, and the two
31
- * signals never coincide. On ENTRY the pointer is in the query and the cookie
32
- * has not been set yet (middleware sets it in the response); on every
33
- * navigation after that it is the cookie and the query is gone.
34
- *
35
- * MEASURED LIMIT — do not assume `no-store` is in force. On a *dynamic* App
36
- * Router response Next reserves `Cache-Control` and `Vary` and overwrites
37
- * whatever we set, from here AND from middleware: the wire shows
38
- * `Cache-Control: no-cache, must-revalidate` and
39
- * `Vary: rsc, next-router-state-tree, …`. Verified that these rules do match
40
- * (a marker header on the same rules came through on both signals and was
41
- * correctly absent otherwise) — it is those two header names specifically that
42
- * Next owns. `X-Robots-Tag` does get through.
43
- *
44
- * Why that is still safe: `no-cache` is not "don't cache", it is "never reuse
45
- * without revalidating with the origin", so a compliant shared cache cannot
46
- * serve a stored draft to a different visitor — it has to ask us first, and we
47
- * answer per-request. That also covers the loss of `Vary: Cookie`. The residual
48
- * risk is a cache that ignores `no-cache`; closing that needs a CDN-level rule,
49
- * which is a deployment concern rather than something this package can set.
50
- * `Cache-Control`/`Vary` are kept here so the intent is explicit and so they
51
- * apply anywhere Next is not overriding them.
52
- */
53
- const DRAFT_NO_STORE_HEADERS = [
54
- { key: "Cache-Control", value: "no-store, private" },
55
- { key: "Vary", value: "Cookie" },
56
- { key: "X-Robots-Tag", value: "noindex, nofollow" },
57
- ];
58
-
59
- const DECO_DRAFT_HEADERS = [
60
- {
61
- source: "/:path*",
62
- has: [{ type: "query", key: "__draft" }],
63
- headers: DRAFT_NO_STORE_HEADERS,
64
- },
65
- {
66
- source: "/:path*",
67
- has: [{ type: "cookie", key: "__deco_draft" }],
68
- headers: DRAFT_NO_STORE_HEADERS,
69
- },
70
- ];
71
-
72
24
  function withDeco(nextConfig = {}) {
73
25
  const userRewrites = nextConfig.rewrites;
74
- const userHeaders = nextConfig.headers;
75
26
  return {
76
27
  ...nextConfig,
77
28
  transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])],
@@ -80,13 +31,7 @@ function withDeco(nextConfig = {}) {
80
31
  if (Array.isArray(user)) return [...DECO_REWRITES, ...user];
81
32
  return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] };
82
33
  },
83
- async headers() {
84
- const user = typeof userHeaders === "function" ? await userHeaders() : (userHeaders ?? []);
85
- // Draft rules first: a site's own catch-all Cache-Control must not win
86
- // over the one thing standing between a draft and a shared cache.
87
- return [...DECO_DRAFT_HEADERS, ...user];
88
- },
89
34
  };
90
35
  }
91
36
 
92
- module.exports = { withDeco, DECO_REWRITES, DECO_DRAFT_HEADERS };
37
+ module.exports = { withDeco, DECO_REWRITES };
@@ -45,42 +45,3 @@ describe("withDeco", () => {
45
45
  expect(cfg.transpilePackages).toContain("other");
46
46
  });
47
47
  });
48
-
49
- describe("withDeco draft headers", () => {
50
- it("adds no-store/no-index rules for both draft signals", async () => {
51
- const headers = await withDeco({}).headers!();
52
- // Two rules, not one: `has` entries within a rule are ANDed, and the query
53
- // (entry) and cookie (navigation) signals never coincide.
54
- expect(headers).toHaveLength(2);
55
- expect(headers[0].has).toEqual([{ type: "query", key: "__draft" }]);
56
- expect(headers[1].has).toEqual([{ type: "cookie", key: "__deco_draft" }]);
57
-
58
- for (const rule of headers) {
59
- const byKey = Object.fromEntries(rule.headers.map((h) => [h.key, h.value]));
60
- // `no-store` is what actually stops a shared cache serving unpublished
61
- // content to a real visitor — the pointer being a cookie means draft and
62
- // published share a URL.
63
- expect(byKey["Cache-Control"]).toBe("no-store, private");
64
- expect(byKey.Vary).toBe("Cookie");
65
- expect(byKey["X-Robots-Tag"]).toBe("noindex, nofollow");
66
- }
67
- });
68
-
69
- it("keeps a site's own headers, with the draft rules taking precedence", async () => {
70
- const cfg = withDeco({
71
- headers: async () => [
72
- { source: "/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=3600" }] },
73
- ],
74
- });
75
- const headers = await cfg.headers!();
76
- expect(headers).toHaveLength(3);
77
- // A site's catch-all cache policy must not out-rank the draft rules.
78
- expect(headers[0].has).toBeDefined();
79
- expect(headers[1].has).toBeDefined();
80
- expect(headers[2].headers[0].value).toBe("public, max-age=3600");
81
- });
82
-
83
- it("leaves a site without headers() untouched apart from the draft rules", async () => {
84
- expect(await withDeco({}).headers!()).toHaveLength(2);
85
- });
86
- });
@@ -1,6 +1,6 @@
1
- import { registerSections, setBlocks } from "@decocms/blocks/cms";
2
1
  import { renderToString } from "react-dom/server";
3
2
  import { describe, expect, it } from "vitest";
3
+ import { registerSections, setBlocks } from "@decocms/blocks/cms";
4
4
  import { createDecoPage } from "./createDecoPage";
5
5
 
6
6
  function Hero({ label }: { label?: string }) {
@@ -58,6 +58,8 @@ describe("createDecoPage (next)", () => {
58
58
 
59
59
  const { default: Page } = createDecoPage({ siteName: "test-site" });
60
60
 
61
- await expect(Page({ params: Promise.resolve({ slug: ["missing"] }) })).rejects.toThrow();
61
+ await expect(
62
+ Page({ params: Promise.resolve({ slug: ["missing"] }) }),
63
+ ).rejects.toThrow();
62
64
  });
63
65
  });
@@ -1,17 +1,14 @@
1
+ import { cache } from "react";
2
+ import { notFound } from "next/navigation";
3
+ import type { Metadata } from "next";
1
4
  import {
2
- type DecoPageResult,
3
5
  extractSeoFromProps,
4
6
  extractSeoFromSections,
5
- isDraftPreviewEnabled,
6
- type PageSeo,
7
7
  resolveDecoPage,
8
+ type DecoPageResult,
9
+ type PageSeo,
8
10
  } from "@decocms/blocks/cms";
9
- import type { Metadata } from "next";
10
- import { notFound } from "next/navigation";
11
- import { connection } from "next/server";
12
- import { cache } from "react";
13
11
  import { DecoPageRenderer } from "./DecoPageRenderer";
14
- import { type DraftSearchParams, ensureDraft } from "./draft";
15
12
 
16
13
  interface CreateDecoPageOptions {
17
14
  siteName: string;
@@ -19,12 +16,6 @@ interface CreateDecoPageOptions {
19
16
 
20
17
  interface PageProps {
21
18
  params: Promise<{ slug?: string[] }>;
22
- /**
23
- * Next always supplies this; optional here so existing callers (and unit
24
- * tests) that construct props by hand keep compiling. Draft preview reads
25
- * `?__draft=` from it — see bindDraftOnce.
26
- */
27
- searchParams?: Promise<DraftSearchParams>;
28
19
  }
29
20
 
30
21
  function pathFromSlug(slug: string[] | undefined): string {
@@ -76,48 +67,8 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
76
67
 
77
68
  const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {}));
78
69
 
79
- /**
80
- * Bind this request's draft decofile, at most once, BEFORE anything resolves
81
- * a page.
82
- *
83
- * Ordering is load-bearing twice over:
84
- *
85
- * 1. `resolveDecoPage` calls `loadBlocks()`, so a draft bound afterwards
86
- * would resolve the page against published content and silently render
87
- * the wrong thing.
88
- * 2. `resolveForPath` is `cache()`d per request and Next may run
89
- * `generateMetadata` and the page body concurrently — whichever resolves
90
- * first wins for both. So this must be awaited at the top of BOTH, not
91
- * just the page. `cache()` here makes the second call free.
92
- *
93
- * Gated on `isDraftPreviewEnabled()` (DECO_ALLOWED_PREVIEW_HOSTS non-empty) — a
94
- * plain env read, not a dynamic API —
95
- * so sites that never opt in behave exactly as before. That gate is doing
96
- * real work: `cookies()` and `searchParams` are both dynamic, and touching
97
- * them unconditionally would opt EVERY page out of static/ISR rendering.
98
- *
99
- * KNOWN COST: with the flag on, every page served by this route becomes
100
- * dynamic, because reading the pointer requires `cookies()`. Acceptable
101
- * while the feature is opt-in; the fix, when it needs to run on a
102
- * statically-rendered production site, is for middleware to rewrite draft
103
- * requests onto a separate dynamic route so ordinary traffic keeps its cache.
104
- */
105
- const bindDraftOnce = cache(async (searchParams?: DraftSearchParams): Promise<boolean> => {
106
- if (!isDraftPreviewEnabled()) return false;
107
-
108
- const bound = await ensureDraft(searchParams);
109
-
110
- // A draft render must never be cached — ISR or the Full Route Cache would
111
- // hand unpublished content to a real visitor. `revalidate` is a static
112
- // export and cannot vary per request, so opt out at runtime instead.
113
- if (bound) await connection();
114
-
115
- return bound;
116
- });
117
-
118
- async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
70
+ async function generateMetadata({ params }: PageProps): Promise<Metadata> {
119
71
  const { slug } = await params;
120
- await bindDraftOnce(await searchParams);
121
72
  const page = await resolveForPath(pathFromSlug(slug));
122
73
  if (!page) return {};
123
74
 
@@ -130,11 +81,9 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
130
81
  };
131
82
  }
132
83
 
133
- async function Page({ params, searchParams }: PageProps) {
84
+ async function Page({ params }: PageProps) {
134
85
  const { slug } = await params;
135
86
  const pathname = pathFromSlug(slug);
136
- // Before resolveForPath — see bindDraftOnce.
137
- await bindDraftOnce(await searchParams);
138
87
  const page = await resolveForPath(pathname);
139
88
  if (!page) notFound();
140
89
 
package/src/index.ts CHANGED
@@ -8,23 +8,6 @@ export {
8
8
  export { DecoPageRenderer } from "./DecoPageRenderer";
9
9
  export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout";
10
10
  export { DeferredSectionBoundary } from "./DeferredSection";
11
- export {
12
- DRAFT_COOKIE,
13
- DRAFT_COOKIE_OPTIONS,
14
- DRAFT_PARAM,
15
- type DraftMiddlewareDecision,
16
- type DraftSearchParams,
17
- decideDraft,
18
- ensureDraft,
19
- registerDraftOverride,
20
- selectDraftPointer,
21
- } from "./draft";
22
- export {
23
- applyDraft,
24
- DRAFT_ROUTE_PREFIX,
25
- prepareDraft,
26
- rewriteToDraftRoute,
27
- } from "./draftMiddleware";
28
11
  export {
29
12
  decofileGET,
30
13
  decofilePOST,
package/src/setup.ts CHANGED
@@ -38,7 +38,7 @@
38
38
  * deploys must ship the directory alongside the server bundle).
39
39
  */
40
40
  import type { ApplySectionConventionsInput } from "@decocms/blocks/cms";
41
- import { applySectionConventions, loadBlocks, setDraftPreviewHosts } from "@decocms/blocks/cms";
41
+ import { applySectionConventions, loadBlocks } from "@decocms/blocks/cms";
42
42
  import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory";
43
43
  import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup";
44
44
 
@@ -110,29 +110,9 @@ export interface NextSetupOptions {
110
110
  * instead, nothing imports the JSON, so edits invalidate nothing and dev
111
111
  * serves stale content until a full restart.
112
112
  */
113
- /**
114
- * Install the site block's `previewHosts` as the draft-preview allowlist.
115
- *
116
- * Reads the BASE blocks handed to setup — never `loadBlocks()` at request
117
- * time, where the draft override is merged in: an allowlist readable through
118
- * the override could be rewritten by the very draft it gates.
119
- * `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces
120
- * this list when set.
121
- */
122
- function installPreviewHosts(blocks: Record<string, unknown> | undefined): void {
123
- const site = blocks?.site as { previewHosts?: unknown } | undefined;
124
- if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts);
125
- }
126
-
127
113
  export function createNextSetup(options: NextSetupOptions): () => Promise<void> {
128
114
  let setupPromise: Promise<void> | null = null;
129
115
 
130
- // Draft preview opt-in from the repo: read SYNCHRONOUSLY at createNextSetup
131
- // time (module evaluation), not inside the lazy ensureSetup — pages call
132
- // `ensureDraft` BEFORE they resolve CMS content, so hosts installed lazily
133
- // would arrive after the gate already said no on the first request.
134
- installPreviewHosts(options.blocks);
135
-
136
116
  return function ensureSetup(): Promise<void> {
137
117
  setupPromise ??= (async () => {
138
118
  const dirBlocks =
@@ -140,8 +120,6 @@ export function createNextSetup(options: NextSetupOptions): () => Promise<void>
140
120
  ? {}
141
121
  : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks");
142
122
  const blocks = { ...dirBlocks, ...options.blocks };
143
- // Covers the blocksDir mode, where blocks only exist after the fs read.
144
- installPreviewHosts(blocks);
145
123
 
146
124
  createSiteSetup({
147
125
  sections: options.sections,
@@ -1,91 +0,0 @@
1
- /**
2
- * Draft-preview wiring in `createDecoPage`.
3
- *
4
- * Separate from `createDecoPage.test.tsx` because this file has to mock
5
- * `next/headers`, and that mock would otherwise apply to the plain
6
- * (non-draft) cases too — which are worth keeping honest about never
7
- * touching a dynamic API at all.
8
- */
9
-
10
- import { registerSections, setBlocks } from "@decocms/blocks/cms";
11
- import { renderToString } from "react-dom/server";
12
- import { afterEach, describe, expect, it, vi } from "vitest";
13
-
14
- // The gate must stop us reaching cookies() at all when the feature is off.
15
- const cookiesMock = vi.fn(async () => ({ get: () => undefined }));
16
- vi.mock("next/headers", () => ({
17
- cookies: () => cookiesMock(),
18
- headers: async () => new Headers(),
19
- }));
20
-
21
- // `connection()` is Next's runtime opt-out from static rendering; outside a
22
- // request scope it throws, so it is stubbed to a no-op here.
23
- const connectionMock = vi.fn(async () => {});
24
- vi.mock("next/server", () => ({ connection: () => connectionMock() }));
25
-
26
- const { createDecoPage } = await import("./createDecoPage");
27
-
28
- function Hero({ label }: { label?: string }) {
29
- return <h1>{`hero-${label ?? "none"}`}</h1>;
30
- }
31
-
32
- function seedPublishedHome() {
33
- registerSections({ "site/sections/DraftHero.tsx": async () => ({ default: Hero }) });
34
- setBlocks({
35
- "pages-home": {
36
- path: "/",
37
- sections: [{ __resolveType: "site/sections/DraftHero.tsx", label: "published" }],
38
- },
39
- });
40
- }
41
-
42
- afterEach(() => {
43
- cookiesMock.mockClear();
44
- connectionMock.mockClear();
45
- delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
46
- delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES;
47
- });
48
-
49
- describe("createDecoPage — draft preview gate", () => {
50
- it("never reads cookies() when the feature is off", async () => {
51
- // Load-bearing, not a micro-optimisation: cookies() is a dynamic API, so
52
- // calling it unconditionally would opt EVERY page of EVERY site out of
53
- // static/ISR rendering the moment this package is upgraded.
54
- seedPublishedHome();
55
-
56
- const { default: Page } = createDecoPage({ siteName: "test-site" });
57
- const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
58
-
59
- expect(html).toContain("hero-published");
60
- expect(cookiesMock).not.toHaveBeenCalled();
61
- expect(connectionMock).not.toHaveBeenCalled();
62
- });
63
-
64
- it("stays off when the flag is set but no origin suffix is configured", async () => {
65
- // Both halves are required — a bare flag with nowhere to fetch from is
66
- // inert, mirroring Fast Deploy's opt-in.
67
- process.env.DECO_DRAFT_PREVIEW = "1";
68
- seedPublishedHome();
69
-
70
- const { default: Page } = createDecoPage({ siteName: "test-site" });
71
- const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
72
-
73
- expect(html).toContain("hero-published");
74
- expect(cookiesMock).not.toHaveBeenCalled();
75
- });
76
-
77
- it("reads cookies() once the feature is fully configured", async () => {
78
- process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example";
79
- seedPublishedHome();
80
-
81
- const { default: Page } = createDecoPage({ siteName: "test-site" });
82
- const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
83
-
84
- // No draft pointer on the request, so the page still renders published —
85
- // enabling the feature must not change what an ordinary visitor sees.
86
- expect(html).toContain("hero-published");
87
- expect(cookiesMock).toHaveBeenCalled();
88
- // Nothing was bound, so no need to force the page dynamic.
89
- expect(connectionMock).not.toHaveBeenCalled();
90
- });
91
- });
package/src/draft.test.ts DELETED
@@ -1,73 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
-
3
- import { DRAFT_COOKIE_OPTIONS, DRAFT_PARAM, decideDraft } from "./draft";
4
-
5
- function urlWith(param?: string): URL {
6
- const url = new URL("https://site.example/some/page");
7
- if (param !== undefined) url.searchParams.set(DRAFT_PARAM, param);
8
- return url;
9
- }
10
-
11
- describe("decideDraft", () => {
12
- it("enters draft mode from the param and sets the cookie", () => {
13
- expect(decideDraft(urlWith("abc@v1"), null)).toEqual({
14
- pointer: "abc@v1",
15
- setCookie: "abc@v1",
16
- clearCookie: false,
17
- });
18
- });
19
-
20
- it("carries the draft across navigation via the cookie alone", () => {
21
- // The param is gone — this is the in-preview link click that a
22
- // param-only design would silently drop back to published.
23
- expect(decideDraft(urlWith(), "abc@v1")).toEqual({
24
- pointer: "abc@v1",
25
- setCookie: null,
26
- clearCookie: false,
27
- });
28
- });
29
-
30
- it("lets the param override a stale cookie", () => {
31
- // Studio navigating to a newer version must win over the older pointer
32
- // still sitting in the cookie, or a save would never be reflected.
33
- expect(decideDraft(urlWith("abc@v2"), "abc@v1")).toEqual({
34
- pointer: "abc@v2",
35
- setCookie: "abc@v2",
36
- clearCookie: false,
37
- });
38
- });
39
-
40
- it("leaves draft mode on ?__draft=off, even with a cookie set", () => {
41
- expect(decideDraft(urlWith("off"), "abc@v1")).toEqual({
42
- pointer: null,
43
- setCookie: null,
44
- clearCookie: true,
45
- });
46
- });
47
-
48
- it("is inert for an ordinary request", () => {
49
- expect(decideDraft(urlWith(), null)).toEqual({
50
- pointer: null,
51
- setCookie: null,
52
- clearCookie: false,
53
- });
54
- });
55
- });
56
-
57
- describe("DRAFT_COOKIE_OPTIONS", () => {
58
- it("is set up to survive a cross-site preview iframe", () => {
59
- // The preview iframe is cross-site (Studio embeds the production origin),
60
- // so the cookie is third-party. Without SameSite=None+Secure it is never
61
- // sent; without Partitioned (CHIPS) it is dropped as browsers wind down
62
- // unpartitioned third-party cookies. These attributes are load-bearing,
63
- // not incidental.
64
- expect(DRAFT_COOKIE_OPTIONS.sameSite).toBe("none");
65
- expect(DRAFT_COOKIE_OPTIONS.secure).toBe(true);
66
- expect(DRAFT_COOKIE_OPTIONS.partitioned).toBe(true);
67
- expect(DRAFT_COOKIE_OPTIONS.httpOnly).toBe(true);
68
- });
69
-
70
- it("is short-lived so a stale pointer can't pin an old version", () => {
71
- expect(DRAFT_COOKIE_OPTIONS.maxAge).toBeLessThanOrEqual(60 * 60);
72
- });
73
- });
package/src/draft.ts DELETED
@@ -1,200 +0,0 @@
1
- /**
2
- * Draft preview — the Next.js binding.
3
- *
4
- * `@decocms/blocks`'s `draftSource` owns the framework-agnostic half (pointer
5
- * parsing, origin construction, fetch, version cache). This file binds a
6
- * resolved draft to the current request.
7
- *
8
- * ## Why React `cache()` and not AsyncLocalStorage
9
- *
10
- * App Router never enters `RequestContext.run` (that is a TanStack/Workers
11
- * path), and ALS cannot help here anyway: you cannot wrap `ALS.run()` around a
12
- * component's children, because the children render later, outside the call.
13
- * `cache()` gives a per-request memoized value in RSC, which is exactly the
14
- * scope needed — verified concurrently, see `draft.test.ts`.
15
- *
16
- * ## Why this must be awaited by the PAGE, not a layout
17
- *
18
- * A layout's `await` does NOT gate its children: App Router renders a layout
19
- * and its children concurrently, so sections call `loadBlocks()` before a
20
- * layout-level resolve lands, and silently render published content. The
21
- * resolve has to happen somewhere that returns the subtree *after* awaiting —
22
- * i.e. the page component. `ensureDraft()` exists so each site makes one call
23
- * instead of re-deriving that ordering rule.
24
- */
25
- import {
26
- isDraftHostAllowed,
27
- resolveDraftDecofile,
28
- setDraftOverrideGetter,
29
- } from "@decocms/blocks/cms";
30
- import { cookies, headers } from "next/headers";
31
- import { cache } from "react";
32
-
33
- /** Cookie that carries the pointer across in-preview navigation. */
34
- export const DRAFT_COOKIE = "__deco_draft";
35
- /** Query param that enters draft mode (and, with `off`, leaves it). */
36
- export const DRAFT_PARAM = "__draft";
37
-
38
- /**
39
- * Request-scoped slot.
40
- *
41
- * `cache()` memoizes per-request, so every call within one request gets the
42
- * same object and a different one per request. Mutable by design: the page
43
- * fills it before returning its subtree, and nested sections read it
44
- * synchronously through `loadBlocks()`.
45
- */
46
- const draftSlot = cache((): { blocks: Record<string, unknown> | null } => ({
47
- blocks: null,
48
- }));
49
-
50
- /**
51
- * Register the request-scoped getter with the runtime.
52
- *
53
- * Idempotent and safe to call at module scope: outside a request `cache()`
54
- * still returns an object, whose `blocks` is null, so `loadBlocks()` sees no
55
- * override and behaves exactly as before.
56
- */
57
- let registered = false;
58
- export function registerDraftOverride(): void {
59
- if (registered) return;
60
- registered = true;
61
- setDraftOverrideGetter(() => draftSlot().blocks);
62
- }
63
-
64
- /** A page's `searchParams` prop, before it is narrowed. */
65
- export type DraftSearchParams = Record<string, string | string[] | undefined>;
66
-
67
- /** First value of a search param that may legitimately repeat. */
68
- function firstParam(searchParams: DraftSearchParams | undefined, key: string): string | null {
69
- const raw = searchParams?.[key];
70
- if (Array.isArray(raw)) return raw[0] ?? null;
71
- return raw ?? null;
72
- }
73
-
74
- /**
75
- * The pointer for this request: the query param wins, the cookie carries
76
- * navigation, `off` exits.
77
- *
78
- * Read directly from the page rather than trusted from a request header. The
79
- * param has to take precedence or a save would never surface — Studio
80
- * navigating to a new version would keep rendering whatever older pointer is
81
- * still sitting in the cookie.
82
- */
83
- export function selectDraftPointer(
84
- searchParams: DraftSearchParams | undefined,
85
- cookieValue: string | null | undefined,
86
- ): string | null {
87
- const param = firstParam(searchParams, DRAFT_PARAM);
88
- if (param === "off") return null;
89
- if (param) return param;
90
- return cookieValue ?? null;
91
- }
92
-
93
- /**
94
- * Resolve the request's draft, if any, and bind it for the rest of the render.
95
- *
96
- * Call this from the PAGE, awaited, before returning the subtree:
97
- *
98
- * ```tsx
99
- * export default async function Page({ searchParams }) {
100
- * await ensureDraft(await searchParams);
101
- * return <DecoPageRenderer />;
102
- * }
103
- * ```
104
- *
105
- * Reads the param from the page's own `searchParams` and the cookie via
106
- * `cookies()`. There is deliberately no request header in the path: the page
107
- * owns the decision, so a draft keeps working on routes the middleware matcher
108
- * never sees, and there is one less forgeable input to reason about. (The
109
- * pointer was never a secret — the draft id (the token's authority) is the capability — so a
110
- * client supplying one directly is equivalent to typing the query param.)
111
- *
112
- * Returns whether a draft was bound, so callers can surface an explicit
113
- * "draft unavailable" state instead of silently showing published content —
114
- * the failure mode most likely to mislead someone reviewing their own edits.
115
- */
116
- export async function ensureDraft(searchParams?: DraftSearchParams): Promise<boolean> {
117
- registerDraftOverride();
118
-
119
- const cookieStore = await cookies();
120
- const pointer = selectDraftPointer(searchParams, cookieStore.get(DRAFT_COOKIE)?.value);
121
- if (!pointer) return false;
122
-
123
- // Host gate, checked only once a pointer exists (headers() is a dynamic
124
- // API): the same build may serve the preview domain and the production
125
- // domain, and only hosts named in DECO_ALLOWED_PREVIEW_HOSTS may render
126
- // drafts — production stays published no matter what the URL carries.
127
- const requestHeaders = await headers();
128
- const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host");
129
- if (!isDraftHostAllowed(host)) return false;
130
-
131
- const blocks = await resolveDraftDecofile({ pointer });
132
- if (!blocks) return false;
133
-
134
- draftSlot().blocks = blocks;
135
- return true;
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // Middleware helper
140
- // ---------------------------------------------------------------------------
141
-
142
- /** What middleware should do with this request. */
143
- export interface DraftMiddlewareDecision {
144
- /** The active pointer, or null. Drives the cache/indexing headers. */
145
- pointer: string | null;
146
- /** Set the cookie to this value (entering draft mode). */
147
- setCookie: string | null;
148
- /** Clear the cookie (leaving draft mode via `?__draft=off`). */
149
- clearCookie: boolean;
150
- }
151
-
152
- /**
153
- * Decide the draft state for a request, from `?__draft=` and the cookie.
154
- *
155
- * The param is authoritative on entry and the cookie carries subsequent
156
- * in-preview navigation — a param alone dies on the first link click, and a
157
- * cookie alone cannot be relied on: the preview iframe is cross-site, so the
158
- * cookie is third-party and may be blocked outright (Safari ITP) or
159
- * partitioned (Chrome CHIPS). Entry therefore never depends on cookie support.
160
- *
161
- * `?__draft=off` leaves draft mode, so a session can be ended deliberately
162
- * rather than waiting for a cookie to expire.
163
- *
164
- * Pure and framework-free so it can be unit-tested without a Next request; the
165
- * caller applies the decision to its own `NextResponse`.
166
- */
167
- export function decideDraft(
168
- url: URL,
169
- cookieValue: string | null | undefined,
170
- ): DraftMiddlewareDecision {
171
- const param = url.searchParams.get(DRAFT_PARAM);
172
-
173
- if (param === "off") {
174
- return { pointer: null, setCookie: null, clearCookie: true };
175
- }
176
- if (param) {
177
- return { pointer: param, setCookie: param, clearCookie: false };
178
- }
179
- if (cookieValue) {
180
- return { pointer: cookieValue, setCookie: null, clearCookie: false };
181
- }
182
- return { pointer: null, setCookie: null, clearCookie: false };
183
- }
184
-
185
- /**
186
- * Cookie attributes for the draft pointer.
187
- *
188
- * `SameSite=None; Secure` is mandatory for a cross-site iframe, and
189
- * `Partitioned` (CHIPS) is what keeps it working as browsers wind down
190
- * unpartitioned third-party cookies. Short-lived: a draft session is minutes,
191
- * and a stale pointer would keep pinning an old version.
192
- */
193
- export const DRAFT_COOKIE_OPTIONS = {
194
- httpOnly: true,
195
- secure: true,
196
- sameSite: "none",
197
- partitioned: true,
198
- path: "/",
199
- maxAge: 60 * 30,
200
- } as const;
@@ -1,147 +0,0 @@
1
- import { NextRequest, NextResponse } from "next/server";
2
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
-
4
- import { DRAFT_COOKIE } from "./draft";
5
- import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware";
6
-
7
- beforeEach(() => {
8
- // The middleware is host-gated; these tests run as the allowed host.
9
- process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example";
10
- });
11
- afterEach(() => {
12
- delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
13
- });
14
-
15
- function request(url: string, cookie?: string): NextRequest {
16
- const req = new NextRequest(new URL(url), {
17
- headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {},
18
- });
19
- return req;
20
- }
21
-
22
- describe("prepareDraft", () => {
23
- it("reads the pointer from the param", () => {
24
- expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe(
25
- "abc.localhost@v1",
26
- );
27
- });
28
-
29
- it("reads the pointer from the cookie on a plain navigation", () => {
30
- expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe(
31
- "abc.localhost@v1",
32
- );
33
- });
34
-
35
- it("passes through when no env override is set — the page gate is authoritative", () => {
36
- // The site-block hosts live in the server-components graph; middleware
37
- // can't see them, so without the env kill switch it must not block.
38
- delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
39
- const req = request("https://anything.example/p?__draft=abc.localhost@v1");
40
- expect(prepareDraft(req).pointer).toBe("abc.localhost@v1");
41
- });
42
-
43
- it("is inert on a host outside the allowlist — the production domain", () => {
44
- // Same build, different Host: no cookie, no rewrite, nothing touched.
45
- const req = request("https://fila.com.br/p?__draft=abc@v1");
46
- process.env.DECO_ALLOWED_PREVIEW_HOSTS = "fila.vtex.app";
47
- expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false });
48
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
49
- });
50
-
51
- it("ignores a client-supplied draft header — the page owns the decision", () => {
52
- // There is no request-header path any more; the page reads searchParams
53
- // and cookies itself, so a forged header is simply not an input.
54
- const req = new NextRequest(new URL("https://site.example/p"), {
55
- headers: { "x-deco-draft": "attacker.localhost@v1" },
56
- });
57
- expect(prepareDraft(req).pointer).toBeNull();
58
- });
59
- });
60
-
61
- describe("applyDraft", () => {
62
- it("sets a partitioned cross-site cookie when entering draft mode", () => {
63
- const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1"));
64
- const res = applyDraft(NextResponse.next(), decision);
65
-
66
- const setCookie = res.headers.get("set-cookie") ?? "";
67
- expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`);
68
- // The preview iframe is cross-site, so these attributes are what make the
69
- // cookie survive at all — not hardening extras.
70
- expect(setCookie).toMatch(/SameSite=None/i);
71
- expect(setCookie).toMatch(/Secure/i);
72
- expect(setCookie).toMatch(/Partitioned/i);
73
- expect(setCookie).toMatch(/HttpOnly/i);
74
- });
75
-
76
- it("marks a draft response uncacheable and unindexable", () => {
77
- // With the pointer in a cookie, draft and published share a URL — a CDN
78
- // keyed on URL alone would serve unpublished content to a real visitor.
79
- const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1"));
80
- const res = applyDraft(NextResponse.next(), decision);
81
-
82
- expect(res.headers.get("cache-control")).toBe("no-store, private");
83
- expect(res.headers.get("vary")).toBe("Cookie");
84
- expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
85
- });
86
-
87
- it("leaves an ordinary response completely untouched", () => {
88
- const decision = prepareDraft(request("https://site.example/p"));
89
- const res = applyDraft(NextResponse.next(), decision);
90
-
91
- expect(res.headers.get("cache-control")).toBeNull();
92
- expect(res.headers.get("vary")).toBeNull();
93
- expect(res.headers.get("x-robots-tag")).toBeNull();
94
- expect(res.headers.get("set-cookie")).toBeNull();
95
- });
96
-
97
- it("clears the cookie on ?__draft=off", () => {
98
- const decision = prepareDraft(
99
- request("https://site.example/p?__draft=off", "abc.localhost@v1"),
100
- );
101
- expect(decision.clearCookie).toBe(true);
102
-
103
- const res = applyDraft(NextResponse.next(), decision);
104
- const setCookie = res.headers.get("set-cookie") ?? "";
105
- // Deletion is a set with an immediate expiry.
106
- expect(setCookie).toContain(DRAFT_COOKIE);
107
- expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/i);
108
- // And the response is not treated as a draft render.
109
- expect(res.headers.get("cache-control")).toBeNull();
110
- });
111
- });
112
-
113
- describe("rewriteToDraftRoute", () => {
114
- it("rewrites a drafted request onto the dynamic draft route", () => {
115
- const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1");
116
- const res = rewriteToDraftRoute(req, prepareDraft(req));
117
- const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
118
- expect(target.pathname).toBe("/_draft/blog/hello");
119
- // The pointer must survive: the draft page reads it from searchParams.
120
- expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1");
121
- });
122
-
123
- it("rewrites a cookie-only navigation too", () => {
124
- const req = request("https://site.example/blog/hello", "abc.localhost@v1");
125
- const res = rewriteToDraftRoute(req, prepareDraft(req));
126
- expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
127
- "/_draft/blog/hello",
128
- );
129
- });
130
-
131
- it("leaves an ordinary request alone — ISR must not be disturbed", () => {
132
- // The whole point: only drafted requests go dynamic. A shopper's request
133
- // must reach the real, statically rendered route untouched.
134
- const req = request("https://site.example/blog/hello");
135
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
136
- });
137
-
138
- it("never nests the prefix when middleware re-runs on the rewritten URL", () => {
139
- const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1");
140
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
141
- });
142
-
143
- it("does not rewrite when leaving draft mode", () => {
144
- const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1");
145
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
146
- });
147
- });
@@ -1,136 +0,0 @@
1
- /**
2
- * Draft preview — middleware half.
3
- *
4
- * Two composable calls, because sites already own their middleware and this
5
- * must slot into it rather than replace it:
6
- *
7
- * ```ts
8
- * // middleware.ts (Next <16) / proxy.ts (Next 16+)
9
- * export function middleware(request: NextRequest) {
10
- * const decision = prepareDraft(request);
11
- * return applyDraft(NextResponse.next(), decision);
12
- * }
13
- * ```
14
- *
15
- * The middleware owns only the cookie and the cache/indexing headers — a
16
- * Server Component can read cookies but cannot set them, so that part has to
17
- * live here. The pointer itself is read by the page.
18
- *
19
- * Lives on its own subpath so middleware (edge runtime) never imports the root
20
- * barrel, which pulls in the client component graph.
21
- */
22
-
23
- import { isDraftHostAllowed } from "@decocms/blocks/cms";
24
- import { type NextRequest, NextResponse } from "next/server";
25
- import {
26
- DRAFT_COOKIE,
27
- DRAFT_COOKIE_OPTIONS,
28
- type DraftMiddlewareDecision,
29
- decideDraft,
30
- } from "./draft";
31
-
32
- /**
33
- * Compute the draft decision for a request.
34
- *
35
- * Only the cookie and the response headers are the middleware's business now —
36
- * the page reads the pointer itself from `searchParams` + `cookies()`, so
37
- * nothing has to be forwarded through the request. That also means a draft
38
- * still works on routes this middleware never matches.
39
- */
40
- const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false };
41
-
42
- export function prepareDraft(request: NextRequest): DraftMiddlewareDecision {
43
- // The AUTHORITATIVE host gate lives page-side (`ensureDraft`): the site
44
- // block's `previewHosts` are installed in the server-components graph, and
45
- // middleware runs in a separate module graph/runtime that never sees them.
46
- // Middleware therefore hard-gates only on the env override — keeping
47
- // DECO_ALLOWED_PREVIEW_HOSTS a full kill switch — and otherwise passes the
48
- // decision through. Worst case on a non-preview host is a cookie the
49
- // page-side gate then ignores; drafts never render there.
50
- if (process.env.DECO_ALLOWED_PREVIEW_HOSTS) {
51
- const host =
52
- request.headers.get("x-forwarded-host") ??
53
- request.headers.get("host") ??
54
- request.nextUrl.host;
55
- if (!isDraftHostAllowed(host)) return INERT;
56
- }
57
- return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null);
58
- }
59
-
60
- /**
61
- * URL prefix a drafted request is rewritten onto.
62
- *
63
- * The site must mount the matching route as `app/%5Fdraft/...` — URL-encoded.
64
- * A literal `_draft/` directory is a Next "private folder", excluded from
65
- * routing entirely, so the rewrite would fall through to whatever catch-all
66
- * follows and 404. The encoded directory serves the same `/_draft` URL while
67
- * staying routable.
68
- */
69
- export const DRAFT_ROUTE_PREFIX = "/_draft";
70
-
71
- /**
72
- * Rewrite a drafted request onto the dynamic draft route.
73
- *
74
- * This exists because `dynamic` / `revalidate` are STATIC route exports: a
75
- * statically rendered page cannot become dynamic for one request. Making the
76
- * real route dynamic to support drafts would cost every shopper their cached
77
- * page, and — worse — a statically rendered draft would be cached and served to
78
- * them. Rewriting sends only drafted requests to a route that is dynamic by
79
- * construction, leaving ordinary traffic's ISR completely untouched.
80
- *
81
- * The original path is preserved in the rewritten pathname, so the draft route
82
- * can resolve exactly the page the visitor asked for.
83
- *
84
- * Returns null when this request is not drafted, so callers fall through to
85
- * their normal response.
86
- */
87
- export function rewriteToDraftRoute(
88
- request: NextRequest,
89
- decision: DraftMiddlewareDecision,
90
- ): NextResponse | null {
91
- if (!decision.pointer) return null;
92
- const url = request.nextUrl.clone();
93
- // Already rewritten (Next re-runs middleware on the rewritten URL in some
94
- // configurations) — never nest the prefix.
95
- if (url.pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`)) return null;
96
- url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
97
- return NextResponse.rewrite(url);
98
- }
99
-
100
- /**
101
- * Apply the cookie and the cache/indexing headers a draft response requires.
102
- *
103
- * The caching headers are the difference between a preview and a **leak**.
104
- * With the pointer in a cookie, a draft response and a published one share an
105
- * identical URL, so a CDN keyed on URL alone would happily serve unpublished
106
- * content to a real visitor. `no-store` is what actually prevents that;
107
- * `Vary: Cookie` keeps any intermediary that *does* respect it from mixing the
108
- * two; `X-Robots-Tag` keeps a leaked draft out of search results.
109
- */
110
- export function applyDraft(
111
- response: NextResponse,
112
- decision: DraftMiddlewareDecision,
113
- ): NextResponse {
114
- if (decision.clearCookie) {
115
- response.cookies.delete(DRAFT_COOKIE);
116
- } else if (decision.setCookie) {
117
- response.cookies.set(DRAFT_COOKIE, decision.setCookie, DRAFT_COOKIE_OPTIONS);
118
- }
119
-
120
- if (decision.pointer) {
121
- response.headers.set("cache-control", "no-store, private");
122
- response.headers.set("vary", "Cookie");
123
- response.headers.set("x-robots-tag", "noindex, nofollow");
124
- }
125
-
126
- return response;
127
- }
128
-
129
- /**
130
- * Convenience for sites with no middleware of their own: prepare, continue,
131
- * apply. Sites that already have middleware should call the two halves
132
- * directly so their own logic sits in between.
133
- */
134
- export function draftMiddleware(request: NextRequest): NextResponse {
135
- return applyDraft(NextResponse.next(), prepareDraft(request));
136
- }