@decocms/nextjs 7.28.0-beta.0 → 7.28.0-beta.2

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.0",
3
+ "version": "7.28.0-beta.2",
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.0",
33
- "@decocms/blocks-admin": "7.28.0-beta.0"
32
+ "@decocms/blocks": "7.28.0-beta.2",
33
+ "@decocms/blocks-admin": "7.28.0-beta.2"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "next": ">=15.0.0",
@@ -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_DRAFT_PREVIEW_HOST;
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_DRAFT_PREVIEW_HOST = "preview.example";
77
79
  seedPublishedHome();
78
80
 
79
81
  const { default: Page } = createDecoPage({ siteName: "test-site" });
@@ -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_DRAFT_PREVIEW_HOST 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. */
@@ -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_DRAFT_PREVIEW_HOST 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,8 +1,16 @@
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
- import { applyDraft, prepareDraft } from "./draftMiddleware";
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_DRAFT_PREVIEW_HOST = "site.example";
10
+ });
11
+ afterEach(() => {
12
+ delete process.env.DECO_DRAFT_PREVIEW_HOST;
13
+ });
6
14
 
7
15
  function request(url: string, cookie?: string): NextRequest {
8
16
  const req = new NextRequest(new URL(url), {
@@ -20,6 +28,14 @@ describe("prepareDraft", () => {
20
28
  expect(prepareDraft(request("https://site.example/p", "abc@v1")).pointer).toBe("abc@v1");
21
29
  });
22
30
 
31
+ it("is inert on a host outside the allowlist — the production domain", () => {
32
+ // Same build, different Host: no cookie, no rewrite, nothing touched.
33
+ const req = request("https://fila.com.br/p?__draft=abc@v1");
34
+ process.env.DECO_DRAFT_PREVIEW_HOST = "fila.vtex.app";
35
+ expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false });
36
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
37
+ });
38
+
23
39
  it("ignores a client-supplied draft header — the page owns the decision", () => {
24
40
  // There is no request-header path any more; the page reads searchParams
25
41
  // and cookies itself, so a forged header is simply not an input.
@@ -79,3 +95,39 @@ describe("applyDraft", () => {
79
95
  expect(res.headers.get("cache-control")).toBeNull();
80
96
  });
81
97
  });
98
+
99
+ describe("rewriteToDraftRoute", () => {
100
+ it("rewrites a drafted request onto the dynamic draft route", () => {
101
+ const req = request("https://site.example/blog/hello?__draft=abc@v1");
102
+ const res = rewriteToDraftRoute(req, prepareDraft(req));
103
+ const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
104
+ expect(target.pathname).toBe("/_draft/blog/hello");
105
+ // The pointer must survive: the draft page reads it from searchParams.
106
+ expect(target.searchParams.get("__draft")).toBe("abc@v1");
107
+ });
108
+
109
+ it("rewrites a cookie-only navigation too", () => {
110
+ const req = request("https://site.example/blog/hello", "abc@v1");
111
+ const res = rewriteToDraftRoute(req, prepareDraft(req));
112
+ expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
113
+ "/_draft/blog/hello",
114
+ );
115
+ });
116
+
117
+ it("leaves an ordinary request alone — ISR must not be disturbed", () => {
118
+ // The whole point: only drafted requests go dynamic. A shopper's request
119
+ // must reach the real, statically rendered route untouched.
120
+ const req = request("https://site.example/blog/hello");
121
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
122
+ });
123
+
124
+ it("never nests the prefix when middleware re-runs on the rewritten URL", () => {
125
+ const req = request("https://site.example/_draft/blog/hello?__draft=abc@v1");
126
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
127
+ });
128
+
129
+ it("does not rewrite when leaving draft mode", () => {
130
+ const req = request("https://site.example/blog/hello?__draft=off", "abc@v1");
131
+ expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
132
+ });
133
+ });
@@ -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,10 +37,60 @@ 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_DRAFT_PREVIEW_HOST 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") ??
48
+ request.headers.get("host") ??
49
+ request.nextUrl.host;
50
+ if (!isDraftHostAllowed(host)) return INERT;
40
51
  return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null);
41
52
  }
42
53
 
54
+ /**
55
+ * URL prefix a drafted request is rewritten onto.
56
+ *
57
+ * The site must mount the matching route as `app/%5Fdraft/...` — URL-encoded.
58
+ * A literal `_draft/` directory is a Next "private folder", excluded from
59
+ * routing entirely, so the rewrite would fall through to whatever catch-all
60
+ * follows and 404. The encoded directory serves the same `/_draft` URL while
61
+ * staying routable.
62
+ */
63
+ export const DRAFT_ROUTE_PREFIX = "/_draft";
64
+
65
+ /**
66
+ * Rewrite a drafted request onto the dynamic draft route.
67
+ *
68
+ * This exists because `dynamic` / `revalidate` are STATIC route exports: a
69
+ * statically rendered page cannot become dynamic for one request. Making the
70
+ * real route dynamic to support drafts would cost every shopper their cached
71
+ * page, and — worse — a statically rendered draft would be cached and served to
72
+ * them. Rewriting sends only drafted requests to a route that is dynamic by
73
+ * construction, leaving ordinary traffic's ISR completely untouched.
74
+ *
75
+ * The original path is preserved in the rewritten pathname, so the draft route
76
+ * can resolve exactly the page the visitor asked for.
77
+ *
78
+ * Returns null when this request is not drafted, so callers fall through to
79
+ * their normal response.
80
+ */
81
+ export function rewriteToDraftRoute(
82
+ request: NextRequest,
83
+ decision: DraftMiddlewareDecision,
84
+ ): NextResponse | null {
85
+ if (!decision.pointer) return null;
86
+ const url = request.nextUrl.clone();
87
+ // Already rewritten (Next re-runs middleware on the rewritten URL in some
88
+ // configurations) — never nest the prefix.
89
+ if (url.pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`)) return null;
90
+ url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
91
+ return NextResponse.rewrite(url);
92
+ }
93
+
43
94
  /**
44
95
  * Apply the cookie and the cache/indexing headers a draft response requires.
45
96
  *
package/src/index.ts CHANGED
@@ -19,6 +19,12 @@ export {
19
19
  registerDraftOverride,
20
20
  selectDraftPointer,
21
21
  } from "./draft";
22
+ export {
23
+ applyDraft,
24
+ DRAFT_ROUTE_PREFIX,
25
+ prepareDraft,
26
+ rewriteToDraftRoute,
27
+ } from "./draftMiddleware";
22
28
  export {
23
29
  decofileGET,
24
30
  decofilePOST,