@decocms/nextjs 7.27.0 → 7.28.0-beta.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/package.json +5 -4
- package/src/config.cjs +56 -1
- package/src/config.test.ts +39 -0
- package/src/createDecoPage.draft.test.tsx +89 -0
- package/src/createDecoPage.tsx +57 -7
- package/src/draft.test.ts +73 -0
- package/src/draft.ts +188 -0
- package/src/draftMiddleware.test.ts +81 -0
- package/src/draftMiddleware.ts +79 -0
- package/src/index.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/nextjs",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.28.0-beta.0",
|
|
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.
|
|
32
|
-
"@decocms/blocks-admin": "7.
|
|
32
|
+
"@decocms/blocks": "7.28.0-beta.0",
|
|
33
|
+
"@decocms/blocks-admin": "7.28.0-beta.0"
|
|
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 };
|
package/src/config.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/createDecoPage.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,81 @@
|
|
|
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 } 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
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
* Apply the cookie and the cache/indexing headers a draft response requires.
|
|
45
|
+
*
|
|
46
|
+
* The caching headers are the difference between a preview and a **leak**.
|
|
47
|
+
* With the pointer in a cookie, a draft response and a published one share an
|
|
48
|
+
* identical URL, so a CDN keyed on URL alone would happily serve unpublished
|
|
49
|
+
* content to a real visitor. `no-store` is what actually prevents that;
|
|
50
|
+
* `Vary: Cookie` keeps any intermediary that *does* respect it from mixing the
|
|
51
|
+
* two; `X-Robots-Tag` keeps a leaked draft out of search results.
|
|
52
|
+
*/
|
|
53
|
+
export function applyDraft(
|
|
54
|
+
response: NextResponse,
|
|
55
|
+
decision: DraftMiddlewareDecision,
|
|
56
|
+
): NextResponse {
|
|
57
|
+
if (decision.clearCookie) {
|
|
58
|
+
response.cookies.delete(DRAFT_COOKIE);
|
|
59
|
+
} else if (decision.setCookie) {
|
|
60
|
+
response.cookies.set(DRAFT_COOKIE, decision.setCookie, DRAFT_COOKIE_OPTIONS);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (decision.pointer) {
|
|
64
|
+
response.headers.set("cache-control", "no-store, private");
|
|
65
|
+
response.headers.set("vary", "Cookie");
|
|
66
|
+
response.headers.set("x-robots-tag", "noindex, nofollow");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Convenience for sites with no middleware of their own: prepare, continue,
|
|
74
|
+
* apply. Sites that already have middleware should call the two halves
|
|
75
|
+
* directly so their own logic sits in between.
|
|
76
|
+
*/
|
|
77
|
+
export function draftMiddleware(request: NextRequest): NextResponse {
|
|
78
|
+
return applyDraft(NextResponse.next(), prepareDraft(request));
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,17 @@ 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";
|
|
11
22
|
export {
|
|
12
23
|
decofileGET,
|
|
13
24
|
decofilePOST,
|