@decocms/nextjs 7.28.0-beta.1 → 7.28.0-beta.3

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.1",
3
+ "version": "7.28.0-beta.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -29,8 +29,8 @@
29
29
  "lint:unused": "knip"
30
30
  },
31
31
  "dependencies": {
32
- "@decocms/blocks": "7.28.0-beta.1",
33
- "@decocms/blocks-admin": "7.28.0-beta.1"
32
+ "@decocms/blocks": "7.28.0-beta.3",
33
+ "@decocms/blocks-admin": "7.28.0-beta.3"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "next": ">=15.0.0",
@@ -1,6 +1,6 @@
1
+ import { registerSectionsSync } from "@decocms/blocks/cms";
1
2
  import { renderToString } from "react-dom/server";
2
3
  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,8 +16,18 @@ describe("DecoPageRenderer (next)", () => {
16
16
 
17
17
  const element = await DecoPageRenderer({
18
18
  sections: [
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,
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,
21
31
  ],
22
32
  pagePath: "/",
23
33
  });
@@ -1,8 +1,8 @@
1
1
  import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
2
2
  import { resolveDeferredSection } from "@decocms/blocks/cms";
3
- import { SectionRenderer } from "./SectionRenderer";
4
- import { DeferredSectionBoundary } from "./DeferredSection";
5
3
  import { cloneElement, type ReactElement, type ReactNode } from "react";
4
+ import { DeferredSectionBoundary } from "./DeferredSection";
5
+ import { SectionRenderer } from "./SectionRenderer";
6
6
 
7
7
  interface DecoPageRendererProps {
8
8
  sections: ResolvedSection[];
@@ -19,7 +19,9 @@ type PageItem =
19
19
 
20
20
  function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] {
21
21
  const items: PageItem[] = [];
22
- resolved.forEach((section, i) => items.push({ type: "eager", section, sort: section.index ?? i }));
22
+ resolved.forEach((section, i) =>
23
+ items.push({ type: "eager", section, sort: section.index ?? i }),
24
+ );
23
25
  for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index });
24
26
  items.sort((a, b) => a.sort - b.sort);
25
27
  return items;
@@ -54,10 +56,12 @@ export async function DecoPageRenderer({
54
56
  for (const d of deferredSections) {
55
57
  deferredPromises.set(
56
58
  d.index,
57
- resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then((resolved) => {
58
- if (!resolved) return null;
59
- return { ...resolved, index: d.index };
60
- }),
59
+ resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then(
60
+ (resolved) => {
61
+ if (!resolved) return null;
62
+ return { ...resolved, index: d.index };
63
+ },
64
+ ),
61
65
  );
62
66
  }
63
67
 
@@ -1,6 +1,6 @@
1
- import type { ReactNode } from "react";
2
1
  import { LiveControls } from "@decocms/blocks/hooks";
3
2
  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";
1
2
  import { renderToString } from "react-dom/server";
2
3
  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";
2
1
  import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms";
3
2
  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";
1
2
  import { renderToString } from "react-dom/server";
2
3
  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,11 +1,7 @@
1
- import { createElement, type ReactNode } from "react";
2
- import {
3
- getSection,
4
- getSectionOptions,
5
- getSyncComponent,
6
- } from "@decocms/blocks/cms";
7
1
  import type { ResolvedSection } from "@decocms/blocks/cms";
2
+ import { getSection, getSectionOptions, getSyncComponent } from "@decocms/blocks/cms";
8
3
  import { SectionErrorBoundary } from "@decocms/blocks/hooks";
4
+ import { createElement, type ReactNode } from "react";
9
5
  import { ClientOnlySection } from "./ClientOnlySection";
10
6
 
11
7
  interface SectionRendererProps {
@@ -13,7 +13,10 @@ import { afterEach, describe, expect, it, vi } from "vitest";
13
13
 
14
14
  // The gate must stop us reaching cookies() at all when the feature is off.
15
15
  const cookiesMock = vi.fn(async () => ({ get: () => undefined }));
16
- vi.mock("next/headers", () => ({ cookies: () => cookiesMock() }));
16
+ vi.mock("next/headers", () => ({
17
+ cookies: () => cookiesMock(),
18
+ headers: async () => new Headers(),
19
+ }));
17
20
 
18
21
  // `connection()` is Next's runtime opt-out from static rendering; outside a
19
22
  // request scope it throws, so it is stubbed to a no-op here.
@@ -39,7 +42,7 @@ function seedPublishedHome() {
39
42
  afterEach(() => {
40
43
  cookiesMock.mockClear();
41
44
  connectionMock.mockClear();
42
- delete process.env.DECO_DRAFT_PREVIEW;
45
+ delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
43
46
  delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES;
44
47
  });
45
48
 
@@ -72,8 +75,7 @@ describe("createDecoPage — draft preview gate", () => {
72
75
  });
73
76
 
74
77
  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";
78
+ process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example";
77
79
  seedPublishedHome();
78
80
 
79
81
  const { default: Page } = createDecoPage({ siteName: "test-site" });
@@ -1,6 +1,6 @@
1
+ import { registerSections, setBlocks } from "@decocms/blocks/cms";
1
2
  import { renderToString } from "react-dom/server";
2
3
  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,8 +58,6 @@ describe("createDecoPage (next)", () => {
58
58
 
59
59
  const { default: Page } = createDecoPage({ siteName: "test-site" });
60
60
 
61
- await expect(
62
- Page({ params: Promise.resolve({ slug: ["missing"] }) }),
63
- ).rejects.toThrow();
61
+ await expect(Page({ params: Promise.resolve({ slug: ["missing"] }) })).rejects.toThrow();
64
62
  });
65
63
  });
@@ -90,7 +90,8 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) {
90
90
  * first wins for both. So this must be awaited at the top of BOTH, not
91
91
  * just the page. `cache()` here makes the second call free.
92
92
  *
93
- * Gated on `isDraftPreviewEnabled()` — a plain env read, not a dynamic API —
93
+ * Gated on `isDraftPreviewEnabled()` (DECO_ALLOWED_PREVIEW_HOSTS non-empty) — a
94
+ * plain env read, not a dynamic API —
94
95
  * so sites that never opt in behave exactly as before. That gate is doing
95
96
  * real work: `cookies()` and `searchParams` are both dynamic, and touching
96
97
  * them unconditionally would opt EVERY page out of static/ISR rendering.
package/src/draft.ts CHANGED
@@ -22,8 +22,12 @@
22
22
  * i.e. the page component. `ensureDraft()` exists so each site makes one call
23
23
  * instead of re-deriving that ordering rule.
24
24
  */
25
- import { resolveDraftDecofile, setDraftOverrideGetter } from "@decocms/blocks/cms";
26
- import { cookies } from "next/headers";
25
+ import {
26
+ isDraftHostAllowed,
27
+ resolveDraftDecofile,
28
+ setDraftOverrideGetter,
29
+ } from "@decocms/blocks/cms";
30
+ import { cookies, headers } from "next/headers";
27
31
  import { cache } from "react";
28
32
 
29
33
  /** Cookie that carries the pointer across in-preview navigation. */
@@ -102,7 +106,7 @@ export function selectDraftPointer(
102
106
  * `cookies()`. There is deliberately no request header in the path: the page
103
107
  * owns the decision, so a draft keeps working on routes the middleware matcher
104
108
  * 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
109
+ * pointer was never a secret — the draft id (the token's authority) is the capability — so a
106
110
  * client supplying one directly is equivalent to typing the query param.)
107
111
  *
108
112
  * Returns whether a draft was bound, so callers can surface an explicit
@@ -116,6 +120,14 @@ export async function ensureDraft(searchParams?: DraftSearchParams): Promise<boo
116
120
  const pointer = selectDraftPointer(searchParams, cookieStore.get(DRAFT_COOKIE)?.value);
117
121
  if (!pointer) return false;
118
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
+
119
131
  const blocks = await resolveDraftDecofile({ pointer });
120
132
  if (!blocks) return false;
121
133
 
@@ -1,9 +1,17 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { describe, expect, it } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
3
 
4
4
  import { DRAFT_COOKIE } from "./draft";
5
5
  import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware";
6
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
+
7
15
  function request(url: string, cookie?: string): NextRequest {
8
16
  const req = new NextRequest(new URL(url), {
9
17
  headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {},
@@ -13,18 +21,30 @@ function request(url: string, cookie?: string): NextRequest {
13
21
 
14
22
  describe("prepareDraft", () => {
15
23
  it("reads the pointer from the param", () => {
16
- expect(prepareDraft(request("https://site.example/p?__draft=abc@v1")).pointer).toBe("abc@v1");
24
+ expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe(
25
+ "abc.localhost@v1",
26
+ );
17
27
  });
18
28
 
19
29
  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");
30
+ expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe(
31
+ "abc.localhost@v1",
32
+ );
33
+ });
34
+
35
+ it("is inert on a host outside the allowlist — the production domain", () => {
36
+ // Same build, different Host: no cookie, no rewrite, nothing touched.
37
+ const req = request("https://fila.com.br/p?__draft=abc@v1");
38
+ process.env.DECO_ALLOWED_PREVIEW_HOSTS = "fila.vtex.app";
39
+ expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false });
40
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
21
41
  });
22
42
 
23
43
  it("ignores a client-supplied draft header — the page owns the decision", () => {
24
44
  // There is no request-header path any more; the page reads searchParams
25
45
  // and cookies itself, so a forged header is simply not an input.
26
46
  const req = new NextRequest(new URL("https://site.example/p"), {
27
- headers: { "x-deco-draft": "attacker@v1" },
47
+ headers: { "x-deco-draft": "attacker.localhost@v1" },
28
48
  });
29
49
  expect(prepareDraft(req).pointer).toBeNull();
30
50
  });
@@ -32,11 +52,11 @@ describe("prepareDraft", () => {
32
52
 
33
53
  describe("applyDraft", () => {
34
54
  it("sets a partitioned cross-site cookie when entering draft mode", () => {
35
- const decision = prepareDraft(request("https://site.example/p?__draft=abc@v1"));
55
+ const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1"));
36
56
  const res = applyDraft(NextResponse.next(), decision);
37
57
 
38
58
  const setCookie = res.headers.get("set-cookie") ?? "";
39
- expect(setCookie).toContain(`${DRAFT_COOKIE}=abc%40v1`);
59
+ expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`);
40
60
  // The preview iframe is cross-site, so these attributes are what make the
41
61
  // cookie survive at all — not hardening extras.
42
62
  expect(setCookie).toMatch(/SameSite=None/i);
@@ -48,7 +68,7 @@ describe("applyDraft", () => {
48
68
  it("marks a draft response uncacheable and unindexable", () => {
49
69
  // With the pointer in a cookie, draft and published share a URL — a CDN
50
70
  // keyed on URL alone would serve unpublished content to a real visitor.
51
- const decision = prepareDraft(request("https://site.example/p", "abc@v1"));
71
+ const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1"));
52
72
  const res = applyDraft(NextResponse.next(), decision);
53
73
 
54
74
  expect(res.headers.get("cache-control")).toBe("no-store, private");
@@ -67,7 +87,9 @@ describe("applyDraft", () => {
67
87
  });
68
88
 
69
89
  it("clears the cookie on ?__draft=off", () => {
70
- const decision = prepareDraft(request("https://site.example/p?__draft=off", "abc@v1"));
90
+ const decision = prepareDraft(
91
+ request("https://site.example/p?__draft=off", "abc.localhost@v1"),
92
+ );
71
93
  expect(decision.clearCookie).toBe(true);
72
94
 
73
95
  const res = applyDraft(NextResponse.next(), decision);
@@ -82,16 +104,16 @@ describe("applyDraft", () => {
82
104
 
83
105
  describe("rewriteToDraftRoute", () => {
84
106
  it("rewrites a drafted request onto the dynamic draft route", () => {
85
- const req = request("https://site.example/blog/hello?__draft=abc@v1");
107
+ const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1");
86
108
  const res = rewriteToDraftRoute(req, prepareDraft(req));
87
109
  const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
88
110
  expect(target.pathname).toBe("/_draft/blog/hello");
89
111
  // The pointer must survive: the draft page reads it from searchParams.
90
- expect(target.searchParams.get("__draft")).toBe("abc@v1");
112
+ expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1");
91
113
  });
92
114
 
93
115
  it("rewrites a cookie-only navigation too", () => {
94
- const req = request("https://site.example/blog/hello", "abc@v1");
116
+ const req = request("https://site.example/blog/hello", "abc.localhost@v1");
95
117
  const res = rewriteToDraftRoute(req, prepareDraft(req));
96
118
  expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
97
119
  "/_draft/blog/hello",
@@ -106,12 +128,12 @@ describe("rewriteToDraftRoute", () => {
106
128
  });
107
129
 
108
130
  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");
131
+ const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1");
110
132
  expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
111
133
  });
112
134
 
113
135
  it("does not rewrite when leaving draft mode", () => {
114
- const req = request("https://site.example/blog/hello?__draft=off", "abc@v1");
136
+ const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1");
115
137
  expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
116
138
  });
117
139
  });
@@ -19,8 +19,9 @@
19
19
  * Lives on its own subpath so middleware (edge runtime) never imports the root
20
20
  * barrel, which pulls in the client component graph.
21
21
  */
22
- import { type NextRequest, NextResponse } from "next/server";
23
22
 
23
+ import { isDraftHostAllowed } from "@decocms/blocks/cms";
24
+ import { type NextRequest, NextResponse } from "next/server";
24
25
  import {
25
26
  DRAFT_COOKIE,
26
27
  DRAFT_COOKIE_OPTIONS,
@@ -36,16 +37,26 @@ import {
36
37
  * nothing has to be forwarded through the request. That also means a draft
37
38
  * still works on routes this middleware never matches.
38
39
  */
40
+ const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false };
41
+
39
42
  export function prepareDraft(request: NextRequest): DraftMiddlewareDecision {
43
+ // Host gate first: on a host not named in DECO_ALLOWED_PREVIEW_HOSTS the
44
+ // decision is inert — no cookie written, no rewrite, no headers touched —
45
+ // so the production domain behaves as if the feature didn't exist.
46
+ const host =
47
+ request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host;
48
+ if (!isDraftHostAllowed(host)) return INERT;
40
49
  return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null);
41
50
  }
42
51
 
43
52
  /**
44
- * Route prefix a drafted request is rewritten onto.
53
+ * URL prefix a drafted request is rewritten onto.
45
54
  *
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.
55
+ * The site must mount the matching route as `app/%5Fdraft/...` — URL-encoded.
56
+ * A literal `_draft/` directory is a Next "private folder", excluded from
57
+ * routing entirely, so the rewrite would fall through to whatever catch-all
58
+ * follows and 404. The encoded directory serves the same `/_draft` URL while
59
+ * staying routable.
49
60
  */
50
61
  export const DRAFT_ROUTE_PREFIX = "/_draft";
51
62