@decocms/nextjs 7.27.0 → 7.28.0-beta.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.27.0",
3
+ "version": "7.28.0-beta.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -19,7 +19,8 @@
19
19
  "./config": {
20
20
  "types": "./src/config.d.cts",
21
21
  "default": "./src/config.cjs"
22
- }
22
+ },
23
+ "./middleware": "./src/draftMiddleware.ts"
23
24
  },
24
25
  "scripts": {
25
26
  "build": "tsc",
@@ -28,8 +29,8 @@
28
29
  "lint:unused": "knip"
29
30
  },
30
31
  "dependencies": {
31
- "@decocms/blocks": "7.27.0",
32
- "@decocms/blocks-admin": "7.27.0"
32
+ "@decocms/blocks": "7.28.0-beta.1",
33
+ "@decocms/blocks-admin": "7.28.0-beta.1"
33
34
  },
34
35
  "peerDependencies": {
35
36
  "next": ">=15.0.0",
package/src/config.cjs CHANGED
@@ -12,6 +12,7 @@
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.
15
16
  */
16
17
  const DECO_REWRITES = [
17
18
  { source: "/.decofile", destination: "/deco/decofile" },
@@ -21,8 +22,56 @@ const DECO_REWRITES = [
21
22
 
22
23
  const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"];
23
24
 
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
+
24
72
  function withDeco(nextConfig = {}) {
25
73
  const userRewrites = nextConfig.rewrites;
74
+ const userHeaders = nextConfig.headers;
26
75
  return {
27
76
  ...nextConfig,
28
77
  transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])],
@@ -31,7 +80,13 @@ function withDeco(nextConfig = {}) {
31
80
  if (Array.isArray(user)) return [...DECO_REWRITES, ...user];
32
81
  return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] };
33
82
  },
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
+ },
34
89
  };
35
90
  }
36
91
 
37
- module.exports = { withDeco, DECO_REWRITES };
92
+ module.exports = { withDeco, DECO_REWRITES, DECO_DRAFT_HEADERS };
@@ -45,3 +45,42 @@ 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
+ });
@@ -0,0 +1,89 @@
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", () => ({ cookies: () => cookiesMock() }));
17
+
18
+ // `connection()` is Next's runtime opt-out from static rendering; outside a
19
+ // request scope it throws, so it is stubbed to a no-op here.
20
+ const connectionMock = vi.fn(async () => {});
21
+ vi.mock("next/server", () => ({ connection: () => connectionMock() }));
22
+
23
+ const { createDecoPage } = await import("./createDecoPage");
24
+
25
+ function Hero({ label }: { label?: string }) {
26
+ return <h1>{`hero-${label ?? "none"}`}</h1>;
27
+ }
28
+
29
+ function seedPublishedHome() {
30
+ registerSections({ "site/sections/DraftHero.tsx": async () => ({ default: Hero }) });
31
+ setBlocks({
32
+ "pages-home": {
33
+ path: "/",
34
+ sections: [{ __resolveType: "site/sections/DraftHero.tsx", label: "published" }],
35
+ },
36
+ });
37
+ }
38
+
39
+ afterEach(() => {
40
+ cookiesMock.mockClear();
41
+ connectionMock.mockClear();
42
+ delete process.env.DECO_DRAFT_PREVIEW;
43
+ delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES;
44
+ });
45
+
46
+ describe("createDecoPage — draft preview gate", () => {
47
+ it("never reads cookies() when the feature is off", async () => {
48
+ // Load-bearing, not a micro-optimisation: cookies() is a dynamic API, so
49
+ // calling it unconditionally would opt EVERY page of EVERY site out of
50
+ // static/ISR rendering the moment this package is upgraded.
51
+ seedPublishedHome();
52
+
53
+ const { default: Page } = createDecoPage({ siteName: "test-site" });
54
+ const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
55
+
56
+ expect(html).toContain("hero-published");
57
+ expect(cookiesMock).not.toHaveBeenCalled();
58
+ expect(connectionMock).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it("stays off when the flag is set but no origin suffix is configured", async () => {
62
+ // Both halves are required — a bare flag with nowhere to fetch from is
63
+ // inert, mirroring Fast Deploy's opt-in.
64
+ process.env.DECO_DRAFT_PREVIEW = "1";
65
+ seedPublishedHome();
66
+
67
+ const { default: Page } = createDecoPage({ siteName: "test-site" });
68
+ const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
69
+
70
+ expect(html).toContain("hero-published");
71
+ expect(cookiesMock).not.toHaveBeenCalled();
72
+ });
73
+
74
+ it("reads cookies() once the feature is fully configured", async () => {
75
+ process.env.DECO_DRAFT_PREVIEW = "1";
76
+ process.env.DECO_SANDBOX_ORIGIN_SUFFIXES = ".preview-studio.decocms.com";
77
+ seedPublishedHome();
78
+
79
+ const { default: Page } = createDecoPage({ siteName: "test-site" });
80
+ const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) }));
81
+
82
+ // No draft pointer on the request, so the page still renders published —
83
+ // enabling the feature must not change what an ordinary visitor sees.
84
+ expect(html).toContain("hero-published");
85
+ expect(cookiesMock).toHaveBeenCalled();
86
+ // Nothing was bound, so no need to force the page dynamic.
87
+ expect(connectionMock).not.toHaveBeenCalled();
88
+ });
89
+ });
@@ -1,14 +1,17 @@
1
- import { cache } from "react";
2
- import { notFound } from "next/navigation";
3
- import type { Metadata } from "next";
4
1
  import {
2
+ type DecoPageResult,
5
3
  extractSeoFromProps,
6
4
  extractSeoFromSections,
7
- resolveDecoPage,
8
- type DecoPageResult,
5
+ isDraftPreviewEnabled,
9
6
  type PageSeo,
7
+ resolveDecoPage,
10
8
  } 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";
11
13
  import { DecoPageRenderer } from "./DecoPageRenderer";
14
+ import { type DraftSearchParams, ensureDraft } from "./draft";
12
15
 
13
16
  interface CreateDecoPageOptions {
14
17
  siteName: string;
@@ -16,6 +19,12 @@ interface CreateDecoPageOptions {
16
19
 
17
20
  interface PageProps {
18
21
  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>;
19
28
  }
20
29
 
21
30
  function pathFromSlug(slug: string[] | undefined): string {
@@ -67,8 +76,47 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
67
76
 
68
77
  const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {}));
69
78
 
70
- async function generateMetadata({ params }: PageProps): Promise<Metadata> {
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()` — a plain env read, not a dynamic API —
94
+ * so sites that never opt in behave exactly as before. That gate is doing
95
+ * real work: `cookies()` and `searchParams` are both dynamic, and touching
96
+ * them unconditionally would opt EVERY page out of static/ISR rendering.
97
+ *
98
+ * KNOWN COST: with the flag on, every page served by this route becomes
99
+ * dynamic, because reading the pointer requires `cookies()`. Acceptable
100
+ * while the feature is opt-in; the fix, when it needs to run on a
101
+ * statically-rendered production site, is for middleware to rewrite draft
102
+ * requests onto a separate dynamic route so ordinary traffic keeps its cache.
103
+ */
104
+ const bindDraftOnce = cache(async (searchParams?: DraftSearchParams): Promise<boolean> => {
105
+ if (!isDraftPreviewEnabled()) return false;
106
+
107
+ const bound = await ensureDraft(searchParams);
108
+
109
+ // A draft render must never be cached — ISR or the Full Route Cache would
110
+ // hand unpublished content to a real visitor. `revalidate` is a static
111
+ // export and cannot vary per request, so opt out at runtime instead.
112
+ if (bound) await connection();
113
+
114
+ return bound;
115
+ });
116
+
117
+ async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
71
118
  const { slug } = await params;
119
+ await bindDraftOnce(await searchParams);
72
120
  const page = await resolveForPath(pathFromSlug(slug));
73
121
  if (!page) return {};
74
122
 
@@ -81,9 +129,11 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
81
129
  };
82
130
  }
83
131
 
84
- async function Page({ params }: PageProps) {
132
+ async function Page({ params, searchParams }: PageProps) {
85
133
  const { slug } = await params;
86
134
  const pathname = pathFromSlug(slug);
135
+ // Before resolveForPath — see bindDraftOnce.
136
+ await bindDraftOnce(await searchParams);
87
137
  const page = await resolveForPath(pathname);
88
138
  if (!page) notFound();
89
139
 
@@ -0,0 +1,73 @@
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 ADDED
@@ -0,0 +1,188 @@
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 { resolveDraftDecofile, setDraftOverrideGetter } from "@decocms/blocks/cms";
26
+ import { cookies } from "next/headers";
27
+ import { cache } from "react";
28
+
29
+ /** Cookie that carries the pointer across in-preview navigation. */
30
+ export const DRAFT_COOKIE = "__deco_draft";
31
+ /** Query param that enters draft mode (and, with `off`, leaves it). */
32
+ export const DRAFT_PARAM = "__draft";
33
+
34
+ /**
35
+ * Request-scoped slot.
36
+ *
37
+ * `cache()` memoizes per-request, so every call within one request gets the
38
+ * same object and a different one per request. Mutable by design: the page
39
+ * fills it before returning its subtree, and nested sections read it
40
+ * synchronously through `loadBlocks()`.
41
+ */
42
+ const draftSlot = cache((): { blocks: Record<string, unknown> | null } => ({
43
+ blocks: null,
44
+ }));
45
+
46
+ /**
47
+ * Register the request-scoped getter with the runtime.
48
+ *
49
+ * Idempotent and safe to call at module scope: outside a request `cache()`
50
+ * still returns an object, whose `blocks` is null, so `loadBlocks()` sees no
51
+ * override and behaves exactly as before.
52
+ */
53
+ let registered = false;
54
+ export function registerDraftOverride(): void {
55
+ if (registered) return;
56
+ registered = true;
57
+ setDraftOverrideGetter(() => draftSlot().blocks);
58
+ }
59
+
60
+ /** A page's `searchParams` prop, before it is narrowed. */
61
+ export type DraftSearchParams = Record<string, string | string[] | undefined>;
62
+
63
+ /** First value of a search param that may legitimately repeat. */
64
+ function firstParam(searchParams: DraftSearchParams | undefined, key: string): string | null {
65
+ const raw = searchParams?.[key];
66
+ if (Array.isArray(raw)) return raw[0] ?? null;
67
+ return raw ?? null;
68
+ }
69
+
70
+ /**
71
+ * The pointer for this request: the query param wins, the cookie carries
72
+ * navigation, `off` exits.
73
+ *
74
+ * Read directly from the page rather than trusted from a request header. The
75
+ * param has to take precedence or a save would never surface — Studio
76
+ * navigating to a new version would keep rendering whatever older pointer is
77
+ * still sitting in the cookie.
78
+ */
79
+ export function selectDraftPointer(
80
+ searchParams: DraftSearchParams | undefined,
81
+ cookieValue: string | null | undefined,
82
+ ): string | null {
83
+ const param = firstParam(searchParams, DRAFT_PARAM);
84
+ if (param === "off") return null;
85
+ if (param) return param;
86
+ return cookieValue ?? null;
87
+ }
88
+
89
+ /**
90
+ * Resolve the request's draft, if any, and bind it for the rest of the render.
91
+ *
92
+ * Call this from the PAGE, awaited, before returning the subtree:
93
+ *
94
+ * ```tsx
95
+ * export default async function Page({ searchParams }) {
96
+ * await ensureDraft(await searchParams);
97
+ * return <DecoPageRenderer />;
98
+ * }
99
+ * ```
100
+ *
101
+ * Reads the param from the page's own `searchParams` and the cookie via
102
+ * `cookies()`. There is deliberately no request header in the path: the page
103
+ * owns the decision, so a draft keeps working on routes the middleware matcher
104
+ * never sees, and there is one less forgeable input to reason about. (The
105
+ * pointer was never a secret — the sandbox handle is the capability — so a
106
+ * client supplying one directly is equivalent to typing the query param.)
107
+ *
108
+ * Returns whether a draft was bound, so callers can surface an explicit
109
+ * "draft unavailable" state instead of silently showing published content —
110
+ * the failure mode most likely to mislead someone reviewing their own edits.
111
+ */
112
+ export async function ensureDraft(searchParams?: DraftSearchParams): Promise<boolean> {
113
+ registerDraftOverride();
114
+
115
+ const cookieStore = await cookies();
116
+ const pointer = selectDraftPointer(searchParams, cookieStore.get(DRAFT_COOKIE)?.value);
117
+ if (!pointer) return false;
118
+
119
+ const blocks = await resolveDraftDecofile({ pointer });
120
+ if (!blocks) return false;
121
+
122
+ draftSlot().blocks = blocks;
123
+ return true;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Middleware helper
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /** What middleware should do with this request. */
131
+ export interface DraftMiddlewareDecision {
132
+ /** The active pointer, or null. Drives the cache/indexing headers. */
133
+ pointer: string | null;
134
+ /** Set the cookie to this value (entering draft mode). */
135
+ setCookie: string | null;
136
+ /** Clear the cookie (leaving draft mode via `?__draft=off`). */
137
+ clearCookie: boolean;
138
+ }
139
+
140
+ /**
141
+ * Decide the draft state for a request, from `?__draft=` and the cookie.
142
+ *
143
+ * The param is authoritative on entry and the cookie carries subsequent
144
+ * in-preview navigation — a param alone dies on the first link click, and a
145
+ * cookie alone cannot be relied on: the preview iframe is cross-site, so the
146
+ * cookie is third-party and may be blocked outright (Safari ITP) or
147
+ * partitioned (Chrome CHIPS). Entry therefore never depends on cookie support.
148
+ *
149
+ * `?__draft=off` leaves draft mode, so a session can be ended deliberately
150
+ * rather than waiting for a cookie to expire.
151
+ *
152
+ * Pure and framework-free so it can be unit-tested without a Next request; the
153
+ * caller applies the decision to its own `NextResponse`.
154
+ */
155
+ export function decideDraft(
156
+ url: URL,
157
+ cookieValue: string | null | undefined,
158
+ ): DraftMiddlewareDecision {
159
+ const param = url.searchParams.get(DRAFT_PARAM);
160
+
161
+ if (param === "off") {
162
+ return { pointer: null, setCookie: null, clearCookie: true };
163
+ }
164
+ if (param) {
165
+ return { pointer: param, setCookie: param, clearCookie: false };
166
+ }
167
+ if (cookieValue) {
168
+ return { pointer: cookieValue, setCookie: null, clearCookie: false };
169
+ }
170
+ return { pointer: null, setCookie: null, clearCookie: false };
171
+ }
172
+
173
+ /**
174
+ * Cookie attributes for the draft pointer.
175
+ *
176
+ * `SameSite=None; Secure` is mandatory for a cross-site iframe, and
177
+ * `Partitioned` (CHIPS) is what keeps it working as browsers wind down
178
+ * unpartitioned third-party cookies. Short-lived: a draft session is minutes,
179
+ * and a stale pointer would keep pinning an old version.
180
+ */
181
+ export const DRAFT_COOKIE_OPTIONS = {
182
+ httpOnly: true,
183
+ secure: true,
184
+ sameSite: "none",
185
+ partitioned: true,
186
+ path: "/",
187
+ maxAge: 60 * 30,
188
+ } as const;
@@ -0,0 +1,117 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { DRAFT_COOKIE } from "./draft";
5
+ import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware";
6
+
7
+ function request(url: string, cookie?: string): NextRequest {
8
+ const req = new NextRequest(new URL(url), {
9
+ headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {},
10
+ });
11
+ return req;
12
+ }
13
+
14
+ describe("prepareDraft", () => {
15
+ it("reads the pointer from the param", () => {
16
+ expect(prepareDraft(request("https://site.example/p?__draft=abc@v1")).pointer).toBe("abc@v1");
17
+ });
18
+
19
+ it("reads the pointer from the cookie on a plain navigation", () => {
20
+ expect(prepareDraft(request("https://site.example/p", "abc@v1")).pointer).toBe("abc@v1");
21
+ });
22
+
23
+ it("ignores a client-supplied draft header — the page owns the decision", () => {
24
+ // There is no request-header path any more; the page reads searchParams
25
+ // and cookies itself, so a forged header is simply not an input.
26
+ const req = new NextRequest(new URL("https://site.example/p"), {
27
+ headers: { "x-deco-draft": "attacker@v1" },
28
+ });
29
+ expect(prepareDraft(req).pointer).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("applyDraft", () => {
34
+ it("sets a partitioned cross-site cookie when entering draft mode", () => {
35
+ const decision = prepareDraft(request("https://site.example/p?__draft=abc@v1"));
36
+ const res = applyDraft(NextResponse.next(), decision);
37
+
38
+ const setCookie = res.headers.get("set-cookie") ?? "";
39
+ expect(setCookie).toContain(`${DRAFT_COOKIE}=abc%40v1`);
40
+ // The preview iframe is cross-site, so these attributes are what make the
41
+ // cookie survive at all — not hardening extras.
42
+ expect(setCookie).toMatch(/SameSite=None/i);
43
+ expect(setCookie).toMatch(/Secure/i);
44
+ expect(setCookie).toMatch(/Partitioned/i);
45
+ expect(setCookie).toMatch(/HttpOnly/i);
46
+ });
47
+
48
+ it("marks a draft response uncacheable and unindexable", () => {
49
+ // With the pointer in a cookie, draft and published share a URL — a CDN
50
+ // keyed on URL alone would serve unpublished content to a real visitor.
51
+ const decision = prepareDraft(request("https://site.example/p", "abc@v1"));
52
+ const res = applyDraft(NextResponse.next(), decision);
53
+
54
+ expect(res.headers.get("cache-control")).toBe("no-store, private");
55
+ expect(res.headers.get("vary")).toBe("Cookie");
56
+ expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
57
+ });
58
+
59
+ it("leaves an ordinary response completely untouched", () => {
60
+ const decision = prepareDraft(request("https://site.example/p"));
61
+ const res = applyDraft(NextResponse.next(), decision);
62
+
63
+ expect(res.headers.get("cache-control")).toBeNull();
64
+ expect(res.headers.get("vary")).toBeNull();
65
+ expect(res.headers.get("x-robots-tag")).toBeNull();
66
+ expect(res.headers.get("set-cookie")).toBeNull();
67
+ });
68
+
69
+ it("clears the cookie on ?__draft=off", () => {
70
+ const decision = prepareDraft(request("https://site.example/p?__draft=off", "abc@v1"));
71
+ expect(decision.clearCookie).toBe(true);
72
+
73
+ const res = applyDraft(NextResponse.next(), decision);
74
+ const setCookie = res.headers.get("set-cookie") ?? "";
75
+ // Deletion is a set with an immediate expiry.
76
+ expect(setCookie).toContain(DRAFT_COOKIE);
77
+ expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/i);
78
+ // And the response is not treated as a draft render.
79
+ expect(res.headers.get("cache-control")).toBeNull();
80
+ });
81
+ });
82
+
83
+ describe("rewriteToDraftRoute", () => {
84
+ it("rewrites a drafted request onto the dynamic draft route", () => {
85
+ const req = request("https://site.example/blog/hello?__draft=abc@v1");
86
+ const res = rewriteToDraftRoute(req, prepareDraft(req));
87
+ const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
88
+ expect(target.pathname).toBe("/_draft/blog/hello");
89
+ // The pointer must survive: the draft page reads it from searchParams.
90
+ expect(target.searchParams.get("__draft")).toBe("abc@v1");
91
+ });
92
+
93
+ it("rewrites a cookie-only navigation too", () => {
94
+ const req = request("https://site.example/blog/hello", "abc@v1");
95
+ const res = rewriteToDraftRoute(req, prepareDraft(req));
96
+ expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
97
+ "/_draft/blog/hello",
98
+ );
99
+ });
100
+
101
+ it("leaves an ordinary request alone — ISR must not be disturbed", () => {
102
+ // The whole point: only drafted requests go dynamic. A shopper's request
103
+ // must reach the real, statically rendered route untouched.
104
+ const req = request("https://site.example/blog/hello");
105
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
106
+ });
107
+
108
+ it("never nests the prefix when middleware re-runs on the rewritten URL", () => {
109
+ const req = request("https://site.example/_draft/blog/hello?__draft=abc@v1");
110
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
111
+ });
112
+
113
+ it("does not rewrite when leaving draft mode", () => {
114
+ const req = request("https://site.example/blog/hello?__draft=off", "abc@v1");
115
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
116
+ });
117
+ });
@@ -0,0 +1,117 @@
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
+ import { type NextRequest, NextResponse } from "next/server";
23
+
24
+ import {
25
+ DRAFT_COOKIE,
26
+ DRAFT_COOKIE_OPTIONS,
27
+ type DraftMiddlewareDecision,
28
+ decideDraft,
29
+ } from "./draft";
30
+
31
+ /**
32
+ * Compute the draft decision for a request.
33
+ *
34
+ * Only the cookie and the response headers are the middleware's business now —
35
+ * the page reads the pointer itself from `searchParams` + `cookies()`, so
36
+ * nothing has to be forwarded through the request. That also means a draft
37
+ * still works on routes this middleware never matches.
38
+ */
39
+ export function prepareDraft(request: NextRequest): DraftMiddlewareDecision {
40
+ return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null);
41
+ }
42
+
43
+ /**
44
+ * Route prefix a drafted request is rewritten onto.
45
+ *
46
+ * Deliberately a `_`-prefixed segment: Next treats those as private folders and
47
+ * excludes them from routing, so a site cannot accidentally expose it as a real
48
+ * page — it is reachable only through the middleware rewrite below.
49
+ */
50
+ export const DRAFT_ROUTE_PREFIX = "/_draft";
51
+
52
+ /**
53
+ * Rewrite a drafted request onto the dynamic draft route.
54
+ *
55
+ * This exists because `dynamic` / `revalidate` are STATIC route exports: a
56
+ * statically rendered page cannot become dynamic for one request. Making the
57
+ * real route dynamic to support drafts would cost every shopper their cached
58
+ * page, and — worse — a statically rendered draft would be cached and served to
59
+ * them. Rewriting sends only drafted requests to a route that is dynamic by
60
+ * construction, leaving ordinary traffic's ISR completely untouched.
61
+ *
62
+ * The original path is preserved in the rewritten pathname, so the draft route
63
+ * can resolve exactly the page the visitor asked for.
64
+ *
65
+ * Returns null when this request is not drafted, so callers fall through to
66
+ * their normal response.
67
+ */
68
+ export function rewriteToDraftRoute(
69
+ request: NextRequest,
70
+ decision: DraftMiddlewareDecision,
71
+ ): NextResponse | null {
72
+ if (!decision.pointer) return null;
73
+ const url = request.nextUrl.clone();
74
+ // Already rewritten (Next re-runs middleware on the rewritten URL in some
75
+ // configurations) — never nest the prefix.
76
+ if (url.pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`)) return null;
77
+ url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
78
+ return NextResponse.rewrite(url);
79
+ }
80
+
81
+ /**
82
+ * Apply the cookie and the cache/indexing headers a draft response requires.
83
+ *
84
+ * The caching headers are the difference between a preview and a **leak**.
85
+ * With the pointer in a cookie, a draft response and a published one share an
86
+ * identical URL, so a CDN keyed on URL alone would happily serve unpublished
87
+ * content to a real visitor. `no-store` is what actually prevents that;
88
+ * `Vary: Cookie` keeps any intermediary that *does* respect it from mixing the
89
+ * two; `X-Robots-Tag` keeps a leaked draft out of search results.
90
+ */
91
+ export function applyDraft(
92
+ response: NextResponse,
93
+ decision: DraftMiddlewareDecision,
94
+ ): NextResponse {
95
+ if (decision.clearCookie) {
96
+ response.cookies.delete(DRAFT_COOKIE);
97
+ } else if (decision.setCookie) {
98
+ response.cookies.set(DRAFT_COOKIE, decision.setCookie, DRAFT_COOKIE_OPTIONS);
99
+ }
100
+
101
+ if (decision.pointer) {
102
+ response.headers.set("cache-control", "no-store, private");
103
+ response.headers.set("vary", "Cookie");
104
+ response.headers.set("x-robots-tag", "noindex, nofollow");
105
+ }
106
+
107
+ return response;
108
+ }
109
+
110
+ /**
111
+ * Convenience for sites with no middleware of their own: prepare, continue,
112
+ * apply. Sites that already have middleware should call the two halves
113
+ * directly so their own logic sits in between.
114
+ */
115
+ export function draftMiddleware(request: NextRequest): NextResponse {
116
+ return applyDraft(NextResponse.next(), prepareDraft(request));
117
+ }
package/src/index.ts CHANGED
@@ -8,6 +8,23 @@ 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";
11
28
  export {
12
29
  decofileGET,
13
30
  decofilePOST,