@decocms/nextjs 8.1.0-next.0 → 8.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +77 -330
  2. package/dist/DraftPreviewIndicator.d.ts +7 -0
  3. package/dist/DraftPreviewIndicator.d.ts.map +1 -0
  4. package/dist/DraftPreviewIndicator.js +19 -0
  5. package/dist/DraftPreviewIndicator.js.map +1 -0
  6. package/dist/StudioBridge.d.ts +16 -0
  7. package/dist/StudioBridge.d.ts.map +1 -0
  8. package/dist/StudioBridge.js +17 -0
  9. package/dist/StudioBridge.js.map +1 -0
  10. package/dist/draft.d.ts +35 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +43 -0
  13. package/dist/draft.js.map +1 -0
  14. package/dist/drafts.d.ts +94 -0
  15. package/dist/drafts.d.ts.map +1 -0
  16. package/dist/drafts.js +210 -0
  17. package/dist/drafts.js.map +1 -0
  18. package/dist/image-loader/index.d.ts +36 -0
  19. package/dist/image-loader/index.d.ts.map +1 -0
  20. package/dist/image-loader/index.js +27 -0
  21. package/dist/image-loader/index.js.map +1 -0
  22. package/dist/index.d.ts +29 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/proxy/index.d.ts +79 -0
  27. package/dist/proxy/index.d.ts.map +1 -0
  28. package/dist/proxy/index.js +92 -0
  29. package/dist/proxy/index.js.map +1 -0
  30. package/dist/server/index.d.ts +46 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +64 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/slot.d.ts +6 -0
  35. package/dist/server/slot.d.ts.map +1 -0
  36. package/dist/server/slot.js +13 -0
  37. package/dist/server/slot.js.map +1 -0
  38. package/dist/studio.d.ts +30 -0
  39. package/dist/studio.d.ts.map +1 -0
  40. package/dist/studio.js +55 -0
  41. package/dist/studio.js.map +1 -0
  42. package/package.json +41 -22
  43. package/src/DraftPreviewIndicator.tsx +17 -16
  44. package/src/StudioBridge.ts +23 -0
  45. package/src/config/index.cjs +78 -0
  46. package/src/config/index.d.cts +27 -0
  47. package/src/draft.ts +30 -216
  48. package/src/drafts.ts +229 -0
  49. package/src/image-loader/index.ts +62 -0
  50. package/src/index.ts +31 -45
  51. package/src/proxy/index.ts +161 -0
  52. package/src/server/index.ts +77 -0
  53. package/src/server/slot.ts +14 -0
  54. package/src/studio.ts +54 -0
  55. package/src/ClientOnlySection.tsx +0 -20
  56. package/src/DecoPageRenderer.test.tsx +0 -37
  57. package/src/DecoPageRenderer.tsx +0 -107
  58. package/src/DecoRootLayout.test.tsx +0 -22
  59. package/src/DecoRootLayout.tsx +0 -82
  60. package/src/DeferredSection.test.tsx +0 -27
  61. package/src/DeferredSection.tsx +0 -59
  62. package/src/DraftPreviewBadge.tsx +0 -16
  63. package/src/SectionRenderer.test.tsx +0 -97
  64. package/src/SectionRenderer.tsx +0 -58
  65. package/src/__conformance__/observability.test.ts +0 -66
  66. package/src/config.cjs +0 -92
  67. package/src/config.d.cts +0 -4
  68. package/src/config.test.ts +0 -86
  69. package/src/createDecoPage.draft.test.tsx +0 -91
  70. package/src/createDecoPage.test.tsx +0 -63
  71. package/src/createDecoPage.tsx +0 -173
  72. package/src/createDecoPreviewPage.test.tsx +0 -71
  73. package/src/createDecoPreviewPage.tsx +0 -108
  74. package/src/draft.test.ts +0 -73
  75. package/src/draftConstants.test.ts +0 -21
  76. package/src/draftConstants.ts +0 -30
  77. package/src/draftMiddleware.test.ts +0 -179
  78. package/src/draftMiddleware.ts +0 -163
  79. package/src/draftShell.test.ts +0 -76
  80. package/src/routeHandlers.test.ts +0 -243
  81. package/src/routeHandlers.ts +0 -227
  82. package/src/setup.test.ts +0 -253
  83. package/src/setup.ts +0 -180
  84. package/tsconfig.json +0 -7
@@ -1,173 +0,0 @@
1
- import {
2
- type DecoPageResult,
3
- extractSeoFromProps,
4
- extractSeoFromSections,
5
- isDraftPreviewEnabled,
6
- type PageSeo,
7
- resolveDecoPage,
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";
13
- import { DecoPageRenderer } from "./DecoPageRenderer";
14
- import { DraftPreviewIndicator } from "./DraftPreviewIndicator";
15
- import { type DraftSearchParams, ensureDraft } from "./draft";
16
-
17
- interface CreateDecoPageOptions {
18
- siteName: string;
19
- }
20
-
21
- interface PageProps {
22
- params: Promise<{ slug?: string[] }>;
23
- /**
24
- * Next always supplies this; optional here so existing callers (and unit
25
- * tests) that construct props by hand keep compiling. Draft preview reads
26
- * `?__draft=` from it — see bindDraftOnce.
27
- */
28
- searchParams?: Promise<DraftSearchParams>;
29
- }
30
-
31
- function pathFromSlug(slug: string[] | undefined): string {
32
- return `/${(slug ?? []).join("/")}`;
33
- }
34
-
35
- /**
36
- * Merge page-level SEO (the `seo` block resolved into `DecoPageResult.seoSection`)
37
- * with section-contributed SEO (sections registered via `registerSeoSections`
38
- * whose resolved props also carry SEO fields — e.g. a PDP SEO section). Page
39
- * level fields win on conflict, mirroring `buildPageSeo` in the TanStack
40
- * binding (packages/tanstack/src/routes/cmsRoute.ts:408-484).
41
- *
42
- * Deliberately narrower than that binding's version: it does NOT run
43
- * `seoSection` through its own section loader (so commerce-loader-backed
44
- * jsonLD on the seo block won't resolve here) and does NOT fall back to
45
- * site-wide SEO defaults or apply title/description templates. Those are
46
- * out of scope for this minimal page.tsx wiring.
47
- */
48
- function buildSeo(page: DecoPageResult): PageSeo {
49
- const sectionSeo = extractSeoFromSections(page.resolvedSections);
50
- const pageSeo = page.seoSection ? extractSeoFromProps(page.seoSection.props) : {};
51
- return { ...sectionSeo, ...pageSeo };
52
- }
53
-
54
- /**
55
- * Creates the { generateMetadata, default } pair a site spreads into
56
- * app/[[...slug]]/page.tsx. Mirrors `@decocms/tanstack`'s `cmsRouteConfig`.
57
- *
58
- * `resolveForPath` is wrapped in React's `cache()` so `generateMetadata` and
59
- * the page body share one `resolveDecoPage` call per request instead of
60
- * resolving twice — the same pattern a production Next.js site's own
61
- * `resolveCmsPageByPath` already used against the old /next tier. This relies
62
- * on Next's RSC renderer establishing a per-request cache boundary that
63
- * `cache()` memoizes against: calling the returned functions directly outside
64
- * that renderer (e.g. in a plain unit test) will NOT dedupe, since there is
65
- * no active cache boundary for `cache()` to key off — verified empirically
66
- * against this repo's `react` version. The functions still return correct,
67
- * independent results either way; only the single-request sharing is
68
- * untestable outside Next's own pipeline.
69
- */
70
- export function createDecoPage({ siteName }: CreateDecoPageOptions) {
71
- // `siteName` is unused here. `MatcherContext` (packages/blocks/src/cms/resolve.ts)
72
- // has no siteName field, so there's nothing to thread it into `resolveDecoPage`.
73
- // It's kept in the options shape to mirror `cmsRouteConfig({ siteName })`'s call
74
- // signature and as the extension point for Task 7's root layout
75
- // (LiveControls/analytics wiring) — genuinely unused in this file.
76
- void siteName;
77
-
78
- const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {}));
79
-
80
- /**
81
- * Bind this request's draft decofile, at most once, BEFORE anything resolves
82
- * a page.
83
- *
84
- * Ordering is load-bearing twice over:
85
- *
86
- * 1. `resolveDecoPage` calls `loadBlocks()`, so a draft bound afterwards
87
- * would resolve the page against published content and silently render
88
- * the wrong thing.
89
- * 2. `resolveForPath` is `cache()`d per request and Next may run
90
- * `generateMetadata` and the page body concurrently — whichever resolves
91
- * first wins for both. So this must be awaited at the top of BOTH, not
92
- * just the page. `cache()` here makes the second call free.
93
- *
94
- * Gated on `isDraftPreviewEnabled()` (DECO_ALLOWED_PREVIEW_HOSTS non-empty) — a
95
- * plain env read, not a dynamic API —
96
- * so sites that never opt in behave exactly as before. That gate is doing
97
- * real work: `cookies()` and `searchParams` are both dynamic, and touching
98
- * them unconditionally would opt EVERY page out of static/ISR rendering.
99
- *
100
- * KNOWN COST: with the flag on, every page served by this route becomes
101
- * dynamic, because reading the pointer requires `cookies()`. Acceptable
102
- * while the feature is opt-in; the fix, when it needs to run on a
103
- * statically-rendered production site, is for middleware to rewrite draft
104
- * requests onto a separate dynamic route so ordinary traffic keeps its cache.
105
- */
106
- const bindDraftOnce = cache(async (searchParams?: DraftSearchParams): Promise<boolean> => {
107
- if (!isDraftPreviewEnabled()) return false;
108
-
109
- const bound = await ensureDraft(searchParams);
110
-
111
- // A draft render must never be cached — ISR or the Full Route Cache would
112
- // hand unpublished content to a real visitor. `revalidate` is a static
113
- // export and cannot vary per request, so opt out at runtime instead.
114
- if (bound) await connection();
115
-
116
- return bound;
117
- });
118
-
119
- async function generateMetadata({ params, searchParams }: PageProps): Promise<Metadata> {
120
- const { slug } = await params;
121
- await bindDraftOnce(await searchParams);
122
- const page = await resolveForPath(pathFromSlug(slug));
123
- if (!page) return {};
124
-
125
- const seo = buildSeo(page);
126
- return {
127
- title: seo.title,
128
- description: seo.description,
129
- alternates: seo.canonical ? { canonical: seo.canonical } : undefined,
130
- robots: seo.noIndexing ? { index: false, follow: false } : undefined,
131
- };
132
- }
133
-
134
- async function Page({ params, searchParams }: PageProps) {
135
- const { slug } = await params;
136
- const pathname = pathFromSlug(slug);
137
- // Before resolveForPath — see bindDraftOnce.
138
- const isDraft = await bindDraftOnce(await searchParams);
139
- const page = await resolveForPath(pathname);
140
- if (!page) notFound();
141
-
142
- // Call DecoPageRenderer directly (await its result) rather than nesting
143
- // it as `<DecoPageRenderer .../>` JSX. DecoPageRenderer.tsx documents why:
144
- // it's an async function, and Next's real RSC renderer awaits async
145
- // components anywhere in the tree — but react-dom/server's synchronous
146
- // renderer (used by this package's unit tests, and by any consumer not
147
- // going through Next's RSC pipeline) throws when an async component
148
- // suspends outside a <Suspense> boundary. Awaiting it here directly
149
- // keeps both paths working, matching the same convention
150
- // DecoPageRenderer itself uses for SectionRenderer.
151
- const content = await DecoPageRenderer({
152
- sections: page.resolvedSections,
153
- deferredSections: page.deferredSections,
154
- pagePath: pathname,
155
- });
156
-
157
- // In draft mode, float the preview-mode badge over the page so the
158
- // reviewer can never mistake unpublished content for what is live.
159
- // `DraftPreviewIndicator` self-gates on the request-scoped pointer; the
160
- // `isDraft` short-circuit keeps the extra element out of every published
161
- // render. Rendered here (page subtree, after bindDraftOnce) — not a layout,
162
- // whose children render concurrently — so the pointer slot is already set.
163
- if (!isDraft) return content;
164
- return (
165
- <>
166
- {content}
167
- <DraftPreviewIndicator />
168
- </>
169
- );
170
- }
171
-
172
- return { generateMetadata, default: Page };
173
- }
@@ -1,71 +0,0 @@
1
- // @vitest-environment node
2
-
3
- import { registerSection, setBlocks } from "@decocms/blocks/cms";
4
- import { setRenderShell } from "@decocms/blocks-admin";
5
- import { renderToString } from "react-dom/server";
6
- import { beforeEach, describe, expect, it, vi } from "vitest";
7
- import { createDecoPreviewPage } from "./createDecoPreviewPage";
8
- import * as nextjs from "./index";
9
-
10
- vi.mock("next/headers", () => ({
11
- headers: vi.fn(
12
- async () =>
13
- new Headers({
14
- host: "store.test",
15
- "x-forwarded-proto": "https",
16
- "user-agent": "preview-test",
17
- }),
18
- ),
19
- }));
20
-
21
- const HERO = "site/sections/PreviewHero.tsx";
22
-
23
- beforeEach(() => {
24
- setBlocks({
25
- "Preview Hero": { __resolveType: HERO, label: "rsc" },
26
- });
27
- registerSection(HERO, async () => ({
28
- default: ({ label }: { label?: string }) => <h1>{`hero-${label}`}</h1>,
29
- }));
30
- setRenderShell({
31
- css: "/preview.css",
32
- fonts: ["/font.css"],
33
- theme: "light",
34
- bodyClass: "preview-body",
35
- });
36
- });
37
-
38
- describe("createDecoPreviewPage", () => {
39
- it("is exposed by the Next.js binding", () => {
40
- expect((nextjs as Record<string, unknown>).createDecoPreviewPage).toBeTypeOf("function");
41
- });
42
-
43
- it("resolves and renders a section through the RSC-aware SectionRenderer", async () => {
44
- const setup = vi.fn(async () => {});
45
- const Page = createDecoPreviewPage({ setup });
46
- const element = await Page({
47
- params: Promise.resolve({ path: ["Preview Hero"] }),
48
- searchParams: Promise.resolve({ deviceHint: "desktop" }),
49
- });
50
- const html = renderToString(element);
51
-
52
- expect(setup).toHaveBeenCalledOnce();
53
- expect(html).toContain("hero-rsc");
54
- expect(html).toContain('data-manifest-key="site/sections/PreviewHero.tsx"');
55
- expect(html).toContain('href="/preview.css"');
56
- expect(html).toContain('href="/font.css"');
57
- expect(html).toContain('data-theme="light"');
58
- expect(html).toContain("preview-body");
59
- expect(html).toContain("editor::inject");
60
- });
61
-
62
- it("renders a visible diagnostic for an unknown section", async () => {
63
- const Page = createDecoPreviewPage();
64
- const element = await Page({
65
- params: Promise.resolve({ path: ["Missing"] }),
66
- searchParams: Promise.resolve({}),
67
- });
68
-
69
- expect(renderToString(element)).toContain("Unknown section: Missing");
70
- });
71
- });
@@ -1,108 +0,0 @@
1
- import { LIVE_CONTROLS_SCRIPT, resolvePreviewRequest } from "@decocms/blocks-admin";
2
- import { getPreviewWrapper, getRenderShellConfig } from "@decocms/blocks-admin/admin/setup";
3
- import { headers } from "next/headers";
4
- import { cloneElement, createElement, type ReactElement, type ReactNode } from "react";
5
- import { SectionRenderer } from "./SectionRenderer";
6
-
7
- export interface CreateDecoPreviewPageOptions {
8
- setup?: () => Promise<void>;
9
- }
10
-
11
- export interface DecoPreviewPageProps {
12
- params: Promise<{ path?: string[] }>;
13
- searchParams: Promise<Record<string, string | string[] | undefined>>;
14
- }
15
-
16
- function appendSearchParams(
17
- target: URLSearchParams,
18
- source: Record<string, string | string[] | undefined>,
19
- ) {
20
- for (const [key, value] of Object.entries(source)) {
21
- if (Array.isArray(value)) {
22
- for (const item of value) target.append(key, item);
23
- } else if (value !== undefined) {
24
- target.set(key, value);
25
- }
26
- }
27
- }
28
-
29
- async function buildPreviewRequest(props: DecoPreviewPageProps): Promise<Request> {
30
- const [params, searchParams, requestHeaders] = await Promise.all([
31
- props.params,
32
- props.searchParams,
33
- headers(),
34
- ]);
35
- const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost";
36
- const protocol = requestHeaders.get("x-forwarded-proto") ?? "http";
37
- const encodedPath = (params.path ?? []).map(encodeURIComponent).join("/");
38
- const url = new URL(`/live/previews/${encodedPath}`, `${protocol}://${host}`);
39
- appendSearchParams(url.searchParams, searchParams);
40
- return new Request(url, { headers: new Headers(requestHeaders) });
41
- }
42
-
43
- function PreviewResources() {
44
- const { cssHref, fontHrefs } = getRenderShellConfig();
45
- return (
46
- <>
47
- {fontHrefs.map((href) => (
48
- <link key={href} rel="stylesheet" href={href} />
49
- ))}
50
- {cssHref ? <link rel="stylesheet" href={cssHref} /> : null}
51
- </>
52
- );
53
- }
54
-
55
- function PreviewFrame({ children }: { children: ReactNode }) {
56
- const { themeName, bodyClass } = getRenderShellConfig();
57
- const Wrapper = getPreviewWrapper();
58
- const content = Wrapper ? createElement(Wrapper, null, children) : children;
59
-
60
- return (
61
- <>
62
- <PreviewResources />
63
- <div data-theme={themeName || "light"} className={bodyClass || undefined}>
64
- {content}
65
- </div>
66
- <script dangerouslySetInnerHTML={{ __html: LIVE_CONTROLS_SCRIPT }} />
67
- </>
68
- );
69
- }
70
-
71
- function PreviewDiagnostic({ children }: { children: ReactNode }) {
72
- return <div style={{ padding: 20, color: "red" }}>{children}</div>;
73
- }
74
-
75
- export function createDecoPreviewPage(options: CreateDecoPreviewPageOptions = {}) {
76
- return async function DecoPreviewPage(props: DecoPreviewPageProps) {
77
- await options.setup?.();
78
-
79
- try {
80
- const request = await buildPreviewRequest(props);
81
- const resolution = await resolvePreviewRequest(request);
82
- if (resolution.type === "unknown") {
83
- return (
84
- <PreviewFrame>
85
- <PreviewDiagnostic>{`Unknown section: ${resolution.component}`}</PreviewDiagnostic>
86
- </PreviewFrame>
87
- );
88
- }
89
-
90
- const rendered = await Promise.all(
91
- resolution.sections.map(async (section, index): Promise<ReactElement | null> => {
92
- const element = await SectionRenderer({ resolved: section });
93
- return element
94
- ? cloneElement(element, { key: `${section.key}-${section.index ?? index}` })
95
- : null;
96
- }),
97
- );
98
-
99
- return <PreviewFrame>{rendered}</PreviewFrame>;
100
- } catch (error) {
101
- return (
102
- <PreviewFrame>
103
- <PreviewDiagnostic>{`Render error: ${(error as Error).message}`}</PreviewDiagnostic>
104
- </PreviewFrame>
105
- );
106
- }
107
- };
108
- }
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
- });
@@ -1,21 +0,0 @@
1
- import { DRAFT_COOKIE_NAME, DRAFT_QUERY_PARAM } from "@decocms/blocks/cms";
2
- import { describe, expect, it } from "vitest";
3
-
4
- import { DRAFT_COOKIE, DRAFT_PARAM } from "./draftConstants";
5
-
6
- /**
7
- * The draft cookie/param names are wire protocol shared across a package
8
- * boundary: the client-safe copy here (the badge imports it without pulling
9
- * `next/headers` into the client bundle) MUST stay identical to the
10
- * framework-agnostic owner in `@decocms/blocks`, which `/deco/invoke` reads to
11
- * bind the same draft. If these drift, invoke silently serves published content
12
- * while the page shows the draft.
13
- */
14
- describe("draft constants parity with @decocms/blocks", () => {
15
- it("cookie name matches", () => {
16
- expect(DRAFT_COOKIE).toBe(DRAFT_COOKIE_NAME);
17
- });
18
- it("query param matches", () => {
19
- expect(DRAFT_PARAM).toBe(DRAFT_QUERY_PARAM);
20
- });
21
- });
@@ -1,30 +0,0 @@
1
- /**
2
- * Draft-preview constants with ZERO server imports.
3
- *
4
- * `draft.ts` statically imports `next/headers`, so anything reachable from a
5
- * `"use client"` file must not import from it — that would pull `next/headers`
6
- * into the client bundle and fail the build. The client-side badge
7
- * (`DraftPreviewBadge`) needs `DRAFT_PARAM` to build its links, so the shared
8
- * constants live here and `draft.ts` re-exports them for its own callers.
9
- */
10
-
11
- /** Query param that enters draft mode (and, with `off`, leaves it). */
12
- export const DRAFT_PARAM = "__draft";
13
-
14
- /** Cookie that carries the pointer across in-preview navigation. */
15
- export const DRAFT_COOKIE = "__deco_draft";
16
-
17
- /**
18
- * Request header the middleware forwards the active pointer on, so the whole
19
- * RSC tree — including the root layout / app shell — can bind the draft on the
20
- * SAME request. The page reads `?__draft=` from its own `searchParams`, but a
21
- * layout never receives `searchParams` and, on the entry request, the cookie
22
- * has only just been set on the *response* (not yet readable). Forwarding the
23
- * pointer as a request header closes both gaps so `ensureDraft()` works from a
24
- * layout, letting shell-resolved Header/Footer reflect the draft too.
25
- *
26
- * Not a new trust surface: the pointer was never a secret (the draft id is the
27
- * capability, host-scoping bounds blast radius — see `draftSource`), so a
28
- * forwarded/forged header is equivalent to typing the query param.
29
- */
30
- export const DRAFT_HEADER = "x-deco-draft";
@@ -1,179 +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 {
6
- applyDraft,
7
- draftRequestHeaders,
8
- prepareDraft,
9
- rewriteToDraftRoute,
10
- } from "./draftMiddleware";
11
-
12
- beforeEach(() => {
13
- // The middleware is host-gated; these tests run as the allowed host.
14
- process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example";
15
- });
16
- afterEach(() => {
17
- delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
18
- });
19
-
20
- function request(url: string, cookie?: string): NextRequest {
21
- const req = new NextRequest(new URL(url), {
22
- headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {},
23
- });
24
- return req;
25
- }
26
-
27
- describe("prepareDraft", () => {
28
- it("reads the pointer from the param", () => {
29
- expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe(
30
- "abc.localhost@v1",
31
- );
32
- });
33
-
34
- it("reads the pointer from the cookie on a plain navigation", () => {
35
- expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe(
36
- "abc.localhost@v1",
37
- );
38
- });
39
-
40
- it("passes through when no env override is set — the page gate is authoritative", () => {
41
- // The site-block hosts live in the server-components graph; middleware
42
- // can't see them, so without the env kill switch it must not block.
43
- delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
44
- const req = request("https://anything.example/p?__draft=abc.localhost@v1");
45
- expect(prepareDraft(req).pointer).toBe("abc.localhost@v1");
46
- });
47
-
48
- it("is inert on a host outside the allowlist — the production domain", () => {
49
- // Same build, different Host: no cookie, no rewrite, nothing touched.
50
- const req = request("https://acme.com.br/p?__draft=abc@v1");
51
- process.env.DECO_ALLOWED_PREVIEW_HOSTS = "acme.vtex.app";
52
- expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false });
53
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
54
- });
55
-
56
- it("does not read a client-supplied draft header — the decision is param/cookie only", () => {
57
- // The forwarded `x-deco-draft` header (read by `ensureDraft` in the layout)
58
- // is an OUTPUT of the middleware decision, never an input to it: prepareDraft
59
- // still decides purely from the param + cookie. A client that forges the
60
- // header directly can't manufacture a decision here (and `draftRequestHeaders`
61
- // strips it when not previewing).
62
- const req = new NextRequest(new URL("https://site.example/p"), {
63
- headers: { "x-deco-draft": "attacker.localhost@v1" },
64
- });
65
- expect(prepareDraft(req).pointer).toBeNull();
66
- });
67
- });
68
-
69
- describe("draftRequestHeaders", () => {
70
- it("forwards the active pointer on x-deco-draft so a layout can bind it", () => {
71
- const req = request("https://site.example/p?__draft=abc.localhost@v1");
72
- const headers = draftRequestHeaders(req, prepareDraft(req));
73
- expect(headers.get("x-deco-draft")).toBe("abc.localhost@v1");
74
- });
75
-
76
- it("forwards the cookie-carried pointer on plain navigation", () => {
77
- const req = request("https://site.example/p", "abc.localhost@v1");
78
- const headers = draftRequestHeaders(req, prepareDraft(req));
79
- expect(headers.get("x-deco-draft")).toBe("abc.localhost@v1");
80
- });
81
-
82
- it("strips a client-forged header when the request is not previewing", () => {
83
- // Direct-to-origin request carrying a forged header but no real pointer:
84
- // the forwarded headers must not carry it downstream.
85
- const req = new NextRequest(new URL("https://site.example/p"), {
86
- headers: { "x-deco-draft": "attacker.localhost@v1" },
87
- });
88
- const headers = draftRequestHeaders(req, prepareDraft(req));
89
- expect(headers.get("x-deco-draft")).toBeNull();
90
- });
91
- });
92
-
93
- describe("applyDraft", () => {
94
- it("sets a partitioned cross-site cookie when entering draft mode", () => {
95
- const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1"));
96
- const res = applyDraft(NextResponse.next(), decision);
97
-
98
- const setCookie = res.headers.get("set-cookie") ?? "";
99
- expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`);
100
- // The preview iframe is cross-site, so these attributes are what make the
101
- // cookie survive at all — not hardening extras.
102
- expect(setCookie).toMatch(/SameSite=None/i);
103
- expect(setCookie).toMatch(/Secure/i);
104
- expect(setCookie).toMatch(/Partitioned/i);
105
- expect(setCookie).toMatch(/HttpOnly/i);
106
- });
107
-
108
- it("marks a draft response uncacheable and unindexable", () => {
109
- // With the pointer in a cookie, draft and published share a URL — a CDN
110
- // keyed on URL alone would serve unpublished content to a real visitor.
111
- const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1"));
112
- const res = applyDraft(NextResponse.next(), decision);
113
-
114
- expect(res.headers.get("cache-control")).toBe("no-store, private");
115
- expect(res.headers.get("vary")).toBe("Cookie");
116
- expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow");
117
- });
118
-
119
- it("leaves an ordinary response completely untouched", () => {
120
- const decision = prepareDraft(request("https://site.example/p"));
121
- const res = applyDraft(NextResponse.next(), decision);
122
-
123
- expect(res.headers.get("cache-control")).toBeNull();
124
- expect(res.headers.get("vary")).toBeNull();
125
- expect(res.headers.get("x-robots-tag")).toBeNull();
126
- expect(res.headers.get("set-cookie")).toBeNull();
127
- });
128
-
129
- it("clears the cookie on ?__draft=off", () => {
130
- const decision = prepareDraft(
131
- request("https://site.example/p?__draft=off", "abc.localhost@v1"),
132
- );
133
- expect(decision.clearCookie).toBe(true);
134
-
135
- const res = applyDraft(NextResponse.next(), decision);
136
- const setCookie = res.headers.get("set-cookie") ?? "";
137
- // Deletion is a set with an immediate expiry.
138
- expect(setCookie).toContain(DRAFT_COOKIE);
139
- expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/i);
140
- // And the response is not treated as a draft render.
141
- expect(res.headers.get("cache-control")).toBeNull();
142
- });
143
- });
144
-
145
- describe("rewriteToDraftRoute", () => {
146
- it("rewrites a drafted request onto the dynamic draft route", () => {
147
- const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1");
148
- const res = rewriteToDraftRoute(req, prepareDraft(req));
149
- const target = new URL(res?.headers.get("x-middleware-rewrite") ?? "");
150
- expect(target.pathname).toBe("/_draft/blog/hello");
151
- // The pointer must survive: the draft page reads it from searchParams.
152
- expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1");
153
- });
154
-
155
- it("rewrites a cookie-only navigation too", () => {
156
- const req = request("https://site.example/blog/hello", "abc.localhost@v1");
157
- const res = rewriteToDraftRoute(req, prepareDraft(req));
158
- expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe(
159
- "/_draft/blog/hello",
160
- );
161
- });
162
-
163
- it("leaves an ordinary request alone — ISR must not be disturbed", () => {
164
- // The whole point: only drafted requests go dynamic. A shopper's request
165
- // must reach the real, statically rendered route untouched.
166
- const req = request("https://site.example/blog/hello");
167
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
168
- });
169
-
170
- it("never nests the prefix when middleware re-runs on the rewritten URL", () => {
171
- const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1");
172
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
173
- });
174
-
175
- it("does not rewrite when leaving draft mode", () => {
176
- const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1");
177
- expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull();
178
- });
179
- });