@decocms/nextjs 8.1.0-next.0 → 8.5.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.
- package/README.md +97 -328
- package/dist/DraftPreviewIndicator.d.ts +7 -0
- package/dist/DraftPreviewIndicator.d.ts.map +1 -0
- package/dist/DraftPreviewIndicator.js +19 -0
- package/dist/DraftPreviewIndicator.js.map +1 -0
- package/dist/StudioBridge.d.ts +16 -0
- package/dist/StudioBridge.d.ts.map +1 -0
- package/dist/StudioBridge.js +17 -0
- package/dist/StudioBridge.js.map +1 -0
- package/dist/draft.d.ts +37 -0
- package/dist/draft.d.ts.map +1 -0
- package/dist/draft.js +45 -0
- package/dist/draft.js.map +1 -0
- package/dist/drafts.d.ts +94 -0
- package/dist/drafts.d.ts.map +1 -0
- package/dist/drafts.js +210 -0
- package/dist/drafts.js.map +1 -0
- package/dist/image-loader/index.d.ts +36 -0
- package/dist/image-loader/index.d.ts.map +1 -0
- package/dist/image-loader/index.js +27 -0
- package/dist/image-loader/index.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/instrumentation/index.d.ts +74 -0
- package/dist/instrumentation/index.d.ts.map +1 -0
- package/dist/instrumentation/index.js +294 -0
- package/dist/instrumentation/index.js.map +1 -0
- package/dist/proxy/index.d.ts +82 -0
- package/dist/proxy/index.d.ts.map +1 -0
- package/dist/proxy/index.js +91 -0
- package/dist/proxy/index.js.map +1 -0
- package/dist/server/index.d.ts +46 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +64 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/slot.d.ts +6 -0
- package/dist/server/slot.d.ts.map +1 -0
- package/dist/server/slot.js +13 -0
- package/dist/server/slot.js.map +1 -0
- package/dist/studio.d.ts +30 -0
- package/dist/studio.d.ts.map +1 -0
- package/dist/studio.js +55 -0
- package/dist/studio.js.map +1 -0
- package/package.json +53 -22
- package/src/DraftPreviewIndicator.tsx +17 -16
- package/src/StudioBridge.ts +23 -0
- package/src/config/index.cjs +78 -0
- package/src/config/index.d.cts +27 -0
- package/src/draft.ts +32 -215
- package/src/drafts.ts +229 -0
- package/src/image-loader/index.ts +62 -0
- package/src/index.ts +31 -45
- package/src/instrumentation/index.ts +337 -0
- package/src/proxy/index.ts +164 -0
- package/src/server/index.ts +77 -0
- package/src/server/slot.ts +14 -0
- package/src/studio.ts +54 -0
- package/src/ClientOnlySection.tsx +0 -20
- package/src/DecoPageRenderer.test.tsx +0 -37
- package/src/DecoPageRenderer.tsx +0 -107
- package/src/DecoRootLayout.test.tsx +0 -22
- package/src/DecoRootLayout.tsx +0 -82
- package/src/DeferredSection.test.tsx +0 -27
- package/src/DeferredSection.tsx +0 -59
- package/src/DraftPreviewBadge.tsx +0 -16
- package/src/SectionRenderer.test.tsx +0 -97
- package/src/SectionRenderer.tsx +0 -58
- package/src/__conformance__/observability.test.ts +0 -66
- package/src/config.cjs +0 -92
- package/src/config.d.cts +0 -4
- package/src/config.test.ts +0 -86
- package/src/createDecoPage.draft.test.tsx +0 -91
- package/src/createDecoPage.test.tsx +0 -63
- package/src/createDecoPage.tsx +0 -173
- package/src/createDecoPreviewPage.test.tsx +0 -71
- package/src/createDecoPreviewPage.tsx +0 -108
- package/src/draft.test.ts +0 -73
- package/src/draftConstants.test.ts +0 -21
- package/src/draftConstants.ts +0 -30
- package/src/draftMiddleware.test.ts +0 -179
- package/src/draftMiddleware.ts +0 -163
- package/src/draftShell.test.ts +0 -76
- package/src/routeHandlers.test.ts +0 -243
- package/src/routeHandlers.ts +0 -227
- package/src/setup.test.ts +0 -253
- package/src/setup.ts +0 -180
- package/tsconfig.json +0 -7
|
@@ -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
|
-
});
|
package/src/draftConstants.ts
DELETED
|
@@ -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
|
-
});
|
package/src/draftMiddleware.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Draft preview — middleware half.
|
|
3
|
-
*
|
|
4
|
-
* Two composable calls, because sites already own their middleware and this
|
|
5
|
-
* must slot into it rather than replace it:
|
|
6
|
-
*
|
|
7
|
-
* ```ts
|
|
8
|
-
* // middleware.ts (Next <16) / proxy.ts (Next 16+)
|
|
9
|
-
* export function middleware(request: NextRequest) {
|
|
10
|
-
* const decision = prepareDraft(request);
|
|
11
|
-
* return applyDraft(NextResponse.next(), decision);
|
|
12
|
-
* }
|
|
13
|
-
* ```
|
|
14
|
-
*
|
|
15
|
-
* The middleware owns only the cookie and the cache/indexing headers — a
|
|
16
|
-
* Server Component can read cookies but cannot set them, so that part has to
|
|
17
|
-
* live here. The pointer itself is read by the page.
|
|
18
|
-
*
|
|
19
|
-
* Lives on its own subpath so middleware (edge runtime) never imports the root
|
|
20
|
-
* barrel, which pulls in the client component graph.
|
|
21
|
-
*/
|
|
22
|
-
|
|
23
|
-
import { isDraftHostAllowed } from "@decocms/blocks/cms";
|
|
24
|
-
import { type NextRequest, NextResponse } from "next/server";
|
|
25
|
-
import {
|
|
26
|
-
DRAFT_COOKIE,
|
|
27
|
-
DRAFT_COOKIE_OPTIONS,
|
|
28
|
-
DRAFT_HEADER,
|
|
29
|
-
type DraftMiddlewareDecision,
|
|
30
|
-
decideDraft,
|
|
31
|
-
} from "./draft";
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Compute the draft decision for a request.
|
|
35
|
-
*
|
|
36
|
-
* Only the cookie and the response headers are the middleware's business now —
|
|
37
|
-
* the page reads the pointer itself from `searchParams` + `cookies()`, so
|
|
38
|
-
* nothing has to be forwarded through the request. That also means a draft
|
|
39
|
-
* still works on routes this middleware never matches.
|
|
40
|
-
*/
|
|
41
|
-
const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false };
|
|
42
|
-
|
|
43
|
-
export function prepareDraft(request: NextRequest): DraftMiddlewareDecision {
|
|
44
|
-
// The AUTHORITATIVE host gate lives page-side (`ensureDraft`): the site
|
|
45
|
-
// block's `previewHosts` are installed in the server-components graph, and
|
|
46
|
-
// middleware runs in a separate module graph/runtime that never sees them.
|
|
47
|
-
// Middleware therefore hard-gates only on the env override — keeping
|
|
48
|
-
// DECO_ALLOWED_PREVIEW_HOSTS a full kill switch — and otherwise passes the
|
|
49
|
-
// decision through. Worst case on a non-preview host is a cookie the
|
|
50
|
-
// page-side gate then ignores; drafts never render there.
|
|
51
|
-
if (process.env.DECO_ALLOWED_PREVIEW_HOSTS) {
|
|
52
|
-
const host =
|
|
53
|
-
request.headers.get("x-forwarded-host") ??
|
|
54
|
-
request.headers.get("host") ??
|
|
55
|
-
request.nextUrl.host;
|
|
56
|
-
if (!isDraftHostAllowed(host)) return INERT;
|
|
57
|
-
}
|
|
58
|
-
return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* URL prefix a drafted request is rewritten onto.
|
|
63
|
-
*
|
|
64
|
-
* The site must mount the matching route as `app/%5Fdraft/...` — URL-encoded.
|
|
65
|
-
* A literal `_draft/` directory is a Next "private folder", excluded from
|
|
66
|
-
* routing entirely, so the rewrite would fall through to whatever catch-all
|
|
67
|
-
* follows and 404. The encoded directory serves the same `/_draft` URL while
|
|
68
|
-
* staying routable.
|
|
69
|
-
*/
|
|
70
|
-
export const DRAFT_ROUTE_PREFIX = "/_draft";
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Rewrite a drafted request onto the dynamic draft route.
|
|
74
|
-
*
|
|
75
|
-
* This exists because `dynamic` / `revalidate` are STATIC route exports: a
|
|
76
|
-
* statically rendered page cannot become dynamic for one request. Making the
|
|
77
|
-
* real route dynamic to support drafts would cost every shopper their cached
|
|
78
|
-
* page, and — worse — a statically rendered draft would be cached and served to
|
|
79
|
-
* them. Rewriting sends only drafted requests to a route that is dynamic by
|
|
80
|
-
* construction, leaving ordinary traffic's ISR completely untouched.
|
|
81
|
-
*
|
|
82
|
-
* The original path is preserved in the rewritten pathname, so the draft route
|
|
83
|
-
* can resolve exactly the page the visitor asked for.
|
|
84
|
-
*
|
|
85
|
-
* Returns null when this request is not drafted, so callers fall through to
|
|
86
|
-
* their normal response.
|
|
87
|
-
*/
|
|
88
|
-
export function rewriteToDraftRoute(
|
|
89
|
-
request: NextRequest,
|
|
90
|
-
decision: DraftMiddlewareDecision,
|
|
91
|
-
): NextResponse | null {
|
|
92
|
-
if (!decision.pointer) return null;
|
|
93
|
-
const url = request.nextUrl.clone();
|
|
94
|
-
// Already rewritten (Next re-runs middleware on the rewritten URL in some
|
|
95
|
-
// configurations) — never nest the prefix.
|
|
96
|
-
if (url.pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`)) return null;
|
|
97
|
-
url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
|
|
98
|
-
return NextResponse.rewrite(url);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Apply the cookie and the cache/indexing headers a draft response requires.
|
|
103
|
-
*
|
|
104
|
-
* The caching headers are the difference between a preview and a **leak**.
|
|
105
|
-
* With the pointer in a cookie, a draft response and a published one share an
|
|
106
|
-
* identical URL, so a CDN keyed on URL alone would happily serve unpublished
|
|
107
|
-
* content to a real visitor. `no-store` is what actually prevents that;
|
|
108
|
-
* `Vary: Cookie` keeps any intermediary that *does* respect it from mixing the
|
|
109
|
-
* two; `X-Robots-Tag` keeps a leaked draft out of search results.
|
|
110
|
-
*/
|
|
111
|
-
export function applyDraft(
|
|
112
|
-
response: NextResponse,
|
|
113
|
-
decision: DraftMiddlewareDecision,
|
|
114
|
-
): NextResponse {
|
|
115
|
-
if (decision.clearCookie) {
|
|
116
|
-
response.cookies.delete(DRAFT_COOKIE);
|
|
117
|
-
} else if (decision.setCookie) {
|
|
118
|
-
response.cookies.set(DRAFT_COOKIE, decision.setCookie, DRAFT_COOKIE_OPTIONS);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (decision.pointer) {
|
|
122
|
-
response.headers.set("cache-control", "no-store, private");
|
|
123
|
-
response.headers.set("vary", "Cookie");
|
|
124
|
-
response.headers.set("x-robots-tag", "noindex, nofollow");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
return response;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Request headers to forward downstream, carrying the active pointer on
|
|
132
|
-
* `x-deco-draft` (see {@link DRAFT_HEADER}) so the RSC tree — including the app
|
|
133
|
-
* shell — can bind the draft via `ensureDraft()` and reflect it in
|
|
134
|
-
* shell-resolved Header/Footer, not only page sections.
|
|
135
|
-
*
|
|
136
|
-
* Pass to `NextResponse.next({ request: { headers } })`. Strips any
|
|
137
|
-
* client-supplied `x-deco-draft` when this request is not previewing, so the
|
|
138
|
-
* header is only ever present on a legitimately-gated preview request.
|
|
139
|
-
*/
|
|
140
|
-
export function draftRequestHeaders(
|
|
141
|
-
request: NextRequest,
|
|
142
|
-
decision: DraftMiddlewareDecision,
|
|
143
|
-
): Headers {
|
|
144
|
-
const headers = new Headers(request.headers);
|
|
145
|
-
if (decision.pointer) headers.set(DRAFT_HEADER, decision.pointer);
|
|
146
|
-
else headers.delete(DRAFT_HEADER);
|
|
147
|
-
return headers;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Convenience for sites with no middleware of their own: prepare, forward the
|
|
152
|
-
* pointer header, continue, apply. Sites that already have middleware should
|
|
153
|
-
* call the halves directly (`prepareDraft` → `draftRequestHeaders` on the
|
|
154
|
-
* `NextResponse.next({ request: { headers } })` → `applyDraft`) so their own
|
|
155
|
-
* logic sits in between.
|
|
156
|
-
*/
|
|
157
|
-
export function draftMiddleware(request: NextRequest): NextResponse {
|
|
158
|
-
const decision = prepareDraft(request);
|
|
159
|
-
const response = NextResponse.next({
|
|
160
|
-
request: { headers: draftRequestHeaders(request, decision) },
|
|
161
|
-
});
|
|
162
|
-
return applyDraft(response, decision);
|
|
163
|
-
}
|
package/src/draftShell.test.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `ensureDraft()` must bind from the middleware-forwarded `x-deco-draft` header
|
|
3
|
-
* — the only pointer source a LAYOUT (app shell) can see: it receives no
|
|
4
|
-
* `searchParams`, and on the entry request the cookie has only just been set on
|
|
5
|
-
* the response. This is what lets shell-resolved Header/Footer reflect a draft,
|
|
6
|
-
* not just page sections.
|
|
7
|
-
*/
|
|
8
|
-
import { clearDraftCache } from "@decocms/blocks/cms";
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
10
|
-
|
|
11
|
-
// Mutable header/cookie state the mock reads, so each test drives its own.
|
|
12
|
-
let headerState = new Headers();
|
|
13
|
-
let cookieState: Record<string, string> = {};
|
|
14
|
-
|
|
15
|
-
vi.mock("next/headers", () => ({
|
|
16
|
-
headers: async () => headerState,
|
|
17
|
-
cookies: async () => ({
|
|
18
|
-
get: (name: string) =>
|
|
19
|
-
name in cookieState ? { name, value: cookieState[name] } : undefined,
|
|
20
|
-
}),
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
const { ensureDraft, DRAFT_HEADER, DRAFT_COOKIE } = await import("./draft");
|
|
24
|
-
|
|
25
|
-
const DRAFT_BLOCKS = { "pages-home": { title: "DRAFT" } };
|
|
26
|
-
const POINTER = "abc.localhost:60534/api/o/decofile/m/main?token=t@v1";
|
|
27
|
-
|
|
28
|
-
beforeEach(() => {
|
|
29
|
-
headerState = new Headers({ host: "site.example" });
|
|
30
|
-
cookieState = {};
|
|
31
|
-
process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example";
|
|
32
|
-
clearDraftCache();
|
|
33
|
-
vi.stubGlobal(
|
|
34
|
-
"fetch",
|
|
35
|
-
vi.fn(async (url: string) => {
|
|
36
|
-
if (
|
|
37
|
-
String(url) ===
|
|
38
|
-
"http://abc.localhost:60534/api/o/decofile/m/main?token=t"
|
|
39
|
-
) {
|
|
40
|
-
return new Response(JSON.stringify(DRAFT_BLOCKS), {
|
|
41
|
-
status: 200,
|
|
42
|
-
headers: { "content-type": "application/json" },
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
throw new Error(`unexpected fetch: ${url}`);
|
|
46
|
-
}),
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterEach(() => {
|
|
51
|
-
vi.unstubAllGlobals();
|
|
52
|
-
delete process.env.DECO_ALLOWED_PREVIEW_HOSTS;
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
describe("ensureDraft — app-shell (layout) binding via the forwarded header", () => {
|
|
56
|
-
it("binds the draft from x-deco-draft with no searchParams (the layout case)", async () => {
|
|
57
|
-
headerState.set(DRAFT_HEADER, POINTER);
|
|
58
|
-
// No searchParams (layouts get none) and no cookie (entry request).
|
|
59
|
-
expect(await ensureDraft()).toBe(true);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("still binds from the cookie when there is no forwarded header", async () => {
|
|
63
|
-
cookieState[DRAFT_COOKIE] = POINTER;
|
|
64
|
-
expect(await ensureDraft()).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("does not bind when neither header, cookie, nor param is present", async () => {
|
|
68
|
-
expect(await ensureDraft()).toBe(false);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("stays gated by the host allowlist even with a forwarded header", async () => {
|
|
72
|
-
headerState = new Headers({ host: "prod.example" });
|
|
73
|
-
headerState.set(DRAFT_HEADER, POINTER);
|
|
74
|
-
expect(await ensureDraft()).toBe(false);
|
|
75
|
-
});
|
|
76
|
-
});
|