@decocms/nextjs 8.5.2 → 8.7.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 +82 -15
- package/dist/DraftPreviewIndicator.d.ts.map +1 -1
- package/dist/DraftPreviewIndicator.js +4 -3
- package/dist/DraftPreviewIndicator.js.map +1 -1
- package/dist/assignments.d.ts +95 -0
- package/dist/assignments.d.ts.map +1 -0
- package/dist/assignments.js +229 -0
- package/dist/assignments.js.map +1 -0
- package/dist/draft.d.ts +36 -10
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +38 -12
- package/dist/draft.js.map +1 -1
- package/dist/drafts.d.ts +39 -60
- package/dist/drafts.d.ts.map +1 -1
- package/dist/drafts.js +58 -117
- package/dist/drafts.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/instrumentation/index.d.ts +1 -1
- package/dist/instrumentation/index.js +3 -3
- package/dist/instrumentation/index.js.map +1 -1
- package/dist/proxy/index.d.ts +175 -35
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +242 -41
- package/dist/proxy/index.js.map +1 -1
- package/dist/server/index.d.ts +59 -20
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +143 -25
- package/dist/server/index.js.map +1 -1
- package/dist/server/slot.d.ts +25 -4
- package/dist/server/slot.d.ts.map +1 -1
- package/dist/server/slot.js +13 -9
- package/dist/server/slot.js.map +1 -1
- package/dist/visitor.d.ts +16 -0
- package/dist/visitor.d.ts.map +1 -0
- package/dist/visitor.js +16 -0
- package/dist/visitor.js.map +1 -0
- package/package.json +3 -3
- package/src/DraftPreviewIndicator.tsx +4 -3
- package/src/assignments.ts +278 -0
- package/src/config/index.cjs +1 -1
- package/src/draft.ts +43 -12
- package/src/drafts.ts +71 -126
- package/src/index.ts +5 -2
- package/src/instrumentation/index.ts +3 -3
- package/src/proxy/index.ts +371 -59
- package/src/server/index.ts +173 -33
- package/src/server/slot.ts +33 -5
- package/src/visitor.ts +16 -0
package/src/server/index.ts
CHANGED
|
@@ -2,76 +2,216 @@
|
|
|
2
2
|
* `@decocms/nextjs/server`: draft previews in Server Components (pages,
|
|
3
3
|
* layouts, route handlers). Server only: it reads `next/headers`.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The proxy decides what a request reads (`@decocms/nextjs/proxy`): the
|
|
6
|
+
* version, the visitor, and each test and campaign on or off, forwarded on
|
|
7
|
+
* `x-deco-version`, `x-deco-visitor` and `x-deco-assignments`. A page (and,
|
|
8
|
+
* through those headers, the root layout) binds them before it reads
|
|
9
|
+
* content, then reads through `contentClient(cms)`, which builds
|
|
10
|
+
* `cms.client({ version, experiments, campaigns })` and decides nothing
|
|
11
|
+
* again:
|
|
8
12
|
*
|
|
9
13
|
* ```tsx
|
|
10
14
|
* export default async function Page({ searchParams }) {
|
|
11
|
-
* await bindDraft(cms, { searchParams: await searchParams
|
|
15
|
+
* await bindDraft(cms, { searchParams: await searchParams });
|
|
12
16
|
* const client = contentClient(cms);
|
|
13
17
|
* …
|
|
14
18
|
* return <>{sections}<DraftPreviewIndicator /></>;
|
|
15
19
|
* }
|
|
16
20
|
* ```
|
|
17
21
|
*
|
|
18
|
-
* The host check is the proxy's (`decideDraft`), repeated here
|
|
19
|
-
*
|
|
20
|
-
* still gets the release on a host that may not preview.
|
|
22
|
+
* The host check is the proxy's (`decideDraft`), repeated here against the
|
|
23
|
+
* same CMS, so a route the proxy doesn't match, or a forged `x-deco-version`,
|
|
24
|
+
* still gets the release on a host that may not preview. On the release, a
|
|
25
|
+
* test at 0% stays off whatever `x-deco-assignments` says.
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
|
-
import type { Client, CMS } from "@decocms/blocks";
|
|
28
|
+
import type { Client, ClientOptions, CMS } from "@decocms/blocks";
|
|
29
|
+
import { parseVisitor } from "@decocms/blocks/experiments";
|
|
24
30
|
import { headers } from "next/headers.js";
|
|
25
|
-
import {
|
|
26
|
-
|
|
31
|
+
import {
|
|
32
|
+
ASSIGNMENTS_HEADER,
|
|
33
|
+
type PathAssignments,
|
|
34
|
+
parseAssignmentsSegment,
|
|
35
|
+
withoutDarkTests,
|
|
36
|
+
} from "../assignments.ts";
|
|
37
|
+
import {
|
|
38
|
+
DRAFT_PARAM,
|
|
39
|
+
DRAFT_UNAVAILABLE_HEADER,
|
|
40
|
+
RELEASE_CHECK_HEADER,
|
|
41
|
+
VERSION_HEADER,
|
|
42
|
+
} from "../draft.ts";
|
|
43
|
+
import { allowedDraft, draftReleaseCheck, requestLike } from "../drafts.ts";
|
|
44
|
+
import { VISITOR_HEADER } from "../visitor.ts";
|
|
27
45
|
import { activeDraftPointer, draftSlot } from "./slot.ts";
|
|
28
46
|
|
|
29
|
-
export {
|
|
47
|
+
export {
|
|
48
|
+
ASSIGNMENTS_ROUTE_PREFIX,
|
|
49
|
+
assignmentsSegment,
|
|
50
|
+
type PathAssignments,
|
|
51
|
+
parseAssignmentsSegment,
|
|
52
|
+
} from "../assignments.ts";
|
|
53
|
+
export { requestLike } from "../drafts.ts";
|
|
30
54
|
export { activeDraftPointer };
|
|
31
55
|
|
|
32
56
|
/** A page's `searchParams` prop, awaited. */
|
|
33
57
|
export type SearchParams = Record<string, string | string[] | undefined>;
|
|
34
58
|
|
|
35
|
-
export interface BindDraftOptions
|
|
36
|
-
/** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-
|
|
59
|
+
export interface BindDraftOptions {
|
|
60
|
+
/** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-version`). */
|
|
37
61
|
searchParams?: SearchParams;
|
|
38
62
|
}
|
|
39
63
|
|
|
64
|
+
type BindCMS = Pick<CMS, "readVersion" | "forRelease" | "checkRelease">;
|
|
65
|
+
|
|
40
66
|
function firstParam(searchParams: SearchParams | undefined, key: string): string | null {
|
|
41
67
|
const raw = searchParams?.[key];
|
|
42
68
|
return (Array.isArray(raw) ? raw[0] : raw) ?? null;
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
/**
|
|
46
|
-
* Binds the request
|
|
47
|
-
*
|
|
48
|
-
* then the draft cookie, each on a host the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
72
|
+
* Binds what the request reads: the version, if any (the page's own
|
|
73
|
+
* `?__draft=` first, `off` leaving preview, then the version the proxy
|
|
74
|
+
* forwarded on `x-deco-version`, then the draft cookie, each on a host the
|
|
75
|
+
* CMS allows), and what the proxy decided (`x-deco-assignments`) and the
|
|
76
|
+
* visitor it read (`x-deco-visitor`). Returns whether a version was bound:
|
|
77
|
+
* a draft, or a published release's commit SHA (Studio's "Preview this
|
|
78
|
+
* version"). It reads the request's headers, so it belongs on the draft
|
|
79
|
+
* route; a cached (ISR) page reads what its path names (`assignedClient`).
|
|
51
80
|
*/
|
|
52
|
-
export async function bindDraft(
|
|
53
|
-
cms
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
81
|
+
export async function bindDraft(cms: BindCMS, options: BindDraftOptions = {}): Promise<boolean> {
|
|
82
|
+
if (await bindVersion(cms, options)) {
|
|
83
|
+
// A preview renders per request anyway, and may switch on a test at 0%.
|
|
84
|
+
await bindForwarded(cms, false);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
if (await variesByVisitor(cms)) await bindForwarded(cms, true);
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The visitor and the assignments the proxy forwarded, if any. */
|
|
92
|
+
async function bindForwarded(cms: BindCMS, release: boolean): Promise<void> {
|
|
93
|
+
const requestHeaders = await headers();
|
|
94
|
+
const slot = draftSlot();
|
|
95
|
+
slot.visitor = parseVisitor(requestHeaders.get(VISITOR_HEADER));
|
|
96
|
+
const decided = parseAssignmentsSegment(requestHeaders.get(ASSIGNMENTS_HEADER) ?? "");
|
|
97
|
+
slot.assignments = decided && release ? await withoutDarkTests(cms, decided) : decided;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Whether the release saves an `experiments` block (the built-in
|
|
102
|
+
* `split-traffic`) or a `campaigns` block (the built-in `calendar`) with a
|
|
103
|
+
* name in it: only then does a page vary by what the proxy decided. Read from
|
|
104
|
+
* memory, unresolved; `false` when both are missing.
|
|
105
|
+
*/
|
|
106
|
+
async function variesByVisitor(cms: Pick<CMS, "forRelease">): Promise<boolean> {
|
|
107
|
+
const saves = async (name: string, type: string): Promise<boolean> => {
|
|
108
|
+
try {
|
|
109
|
+
const [block] = await cms.forRelease().resolve<unknown>(name, { run: false });
|
|
110
|
+
if (typeof block !== "object" || block === null) return false;
|
|
111
|
+
const record = block as Record<string, unknown>;
|
|
112
|
+
return (
|
|
113
|
+
record.__resolveType === type && Object.keys(record).some((key) => key !== "__resolveType")
|
|
114
|
+
);
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
const [tests, campaigns] = await Promise.all([
|
|
120
|
+
saves("experiments", "split-traffic"),
|
|
121
|
+
saves("campaigns", "calendar"),
|
|
122
|
+
]);
|
|
123
|
+
return tests || campaigns;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function bindVersion(cms: BindCMS, options: BindDraftOptions): Promise<boolean> {
|
|
127
|
+
const { searchParams } = options;
|
|
58
128
|
const param = firstParam(searchParams, DRAFT_PARAM);
|
|
59
129
|
if (param === "off") return false;
|
|
60
130
|
const requestHeaders = await headers();
|
|
61
|
-
|
|
131
|
+
// The proxy read the link's draft and it's gone: the release, with the notice.
|
|
132
|
+
if (requestHeaders.get(DRAFT_UNAVAILABLE_HEADER) === "1") {
|
|
133
|
+
draftSlot().unavailable = true;
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const forwarded = param ?? requestHeaders.get(VERSION_HEADER);
|
|
62
137
|
const query = forwarded ? `?${DRAFT_PARAM}=${encodeURIComponent(forwarded)}` : "";
|
|
63
138
|
const request = requestLike(requestHeaders as Headers, `/${query}`);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
139
|
+
if (request === null) return false;
|
|
140
|
+
// The proxy's release check for this request (`x-deco-release-check`), or one of its own:
|
|
141
|
+
// the host check here and the page's draft reads share it.
|
|
142
|
+
const releaseCheck = await draftReleaseCheck(
|
|
143
|
+
cms,
|
|
144
|
+
request,
|
|
145
|
+
requestHeaders.get(RELEASE_CHECK_HEADER),
|
|
146
|
+
);
|
|
147
|
+
const version = await allowedDraft(cms, request, releaseCheck);
|
|
148
|
+
if (version === null) return false;
|
|
149
|
+
const slot = draftSlot();
|
|
150
|
+
slot.version = version;
|
|
151
|
+
slot.releaseCheck = releaseCheck ?? null;
|
|
67
152
|
return true;
|
|
68
153
|
}
|
|
69
154
|
|
|
70
155
|
/**
|
|
71
|
-
* The content client for this request
|
|
72
|
-
*
|
|
156
|
+
* The content client for this request, from what `bindDraft` bound:
|
|
157
|
+
* `cms.client({ version, experiments, campaigns })` with the tests and
|
|
158
|
+
* campaigns the proxy decided, deciding nothing again. On a route the proxy
|
|
159
|
+
* didn't decide for, the visitor and the preview's moment and switched tests
|
|
160
|
+
* and campaigns decide instead. Create one per request.
|
|
161
|
+
*/
|
|
162
|
+
export function contentClient(cms: Pick<CMS, "client">): Client {
|
|
163
|
+
const { version, visitor, assignments, releaseCheck } = draftSlot();
|
|
164
|
+
const options: ClientOptions = { visitor, version: version?.version ?? null };
|
|
165
|
+
if (version !== null && releaseCheck !== null) options.releaseCheck = releaseCheck;
|
|
166
|
+
if (version?.date !== undefined) options.date = version.date;
|
|
167
|
+
const experiments = over(version?.experiments, assignments?.experiments);
|
|
168
|
+
const campaigns = over(version?.campaigns, assignments?.campaigns);
|
|
169
|
+
if (experiments !== undefined) options.experiments = experiments;
|
|
170
|
+
if (campaigns !== undefined) options.campaigns = campaigns;
|
|
171
|
+
return cms.client(options);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `decided` over `preview`, name by name; `undefined` when neither has any. */
|
|
175
|
+
function over(
|
|
176
|
+
preview: Readonly<Record<string, boolean>> | undefined,
|
|
177
|
+
decided: Readonly<Record<string, boolean>> | undefined,
|
|
178
|
+
): Record<string, boolean> | undefined {
|
|
179
|
+
if (preview === undefined && decided === undefined) return undefined;
|
|
180
|
+
return { ...preview, ...decided };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The content client of a page on the assignments route
|
|
185
|
+
* (`app/%5Fassignments/[assignments]/[[...path]]`, which the proxy's
|
|
186
|
+
* `rewriteToAssignmentsRoute` rewrites the CMS's pages to): the current
|
|
187
|
+
* release, read as `cms.client({ experiments, campaigns })` with every test
|
|
188
|
+
* and campaign the path's segment names on or off as the proxy decided, and
|
|
189
|
+
* no visitor. The proxy decides, the page renders that, reading no request,
|
|
190
|
+
* so Next can cache it (ISR, `revalidate = 600`). A test at 0% stays off
|
|
191
|
+
* whatever the path names, and a test or a campaign the content doesn't have
|
|
192
|
+
* stays out, never an error: a 404 here would be cached under the path by
|
|
193
|
+
* Next. `null` only when the segment isn't one the proxy writes: the page
|
|
194
|
+
* answers with `notFound()`.
|
|
195
|
+
*
|
|
196
|
+
* ```tsx
|
|
197
|
+
* export const revalidate = 600;
|
|
198
|
+
* export async function generateStaticParams() {
|
|
199
|
+
* return [];
|
|
200
|
+
* }
|
|
201
|
+
* export default async function Page({ params }) {
|
|
202
|
+
* const { assignments, path } = await params;
|
|
203
|
+
* const client = await assignedClient(cms, assignments);
|
|
204
|
+
* if (!client) notFound();
|
|
205
|
+
* …
|
|
206
|
+
* }
|
|
207
|
+
* ```
|
|
73
208
|
*/
|
|
74
|
-
export function
|
|
75
|
-
|
|
76
|
-
|
|
209
|
+
export async function assignedClient(
|
|
210
|
+
cms: Pick<CMS, "client" | "forRelease">,
|
|
211
|
+
segment: string,
|
|
212
|
+
): Promise<Client | null> {
|
|
213
|
+
const parsed = parseAssignmentsSegment(segment);
|
|
214
|
+
if (parsed === null) return null;
|
|
215
|
+
const assignments: PathAssignments = await withoutDarkTests(cms, parsed);
|
|
216
|
+
return cms.client({ experiments: assignments.experiments, campaigns: assignments.campaigns });
|
|
77
217
|
}
|
package/src/server/slot.ts
CHANGED
|
@@ -1,14 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* What a request renders, held for the request's Server Components: the
|
|
3
|
+
* version (a draft, or an old release, with its preview's moment and switched
|
|
4
|
+
* tests and campaigns), the visitor and the tests and campaigns the proxy
|
|
5
|
+
* decided. React's `cache()` gives one slot per request in the React Server
|
|
4
6
|
* Components graph, the scope a draft needs; no `next/headers` here, so the
|
|
5
7
|
* root components can read it without binding anything.
|
|
6
8
|
*/
|
|
9
|
+
import type { RequestVersion } from "@decocms/blocks";
|
|
10
|
+
import type { Visitor } from "@decocms/blocks/experiments";
|
|
7
11
|
import { cache } from "react";
|
|
12
|
+
import type { PathAssignments } from "../assignments.ts";
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
interface DraftSlot {
|
|
15
|
+
version: RequestVersion | null;
|
|
16
|
+
visitor: Visitor | null;
|
|
17
|
+
assignments: PathAssignments | null;
|
|
18
|
+
/** The link named a draft that can't be read anymore: the page renders the release. */
|
|
19
|
+
unavailable: boolean;
|
|
20
|
+
/** The request's release check (`cms.checkRelease()`), which its draft reads wait for. */
|
|
21
|
+
releaseCheck: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const draftSlot = cache(
|
|
25
|
+
(): DraftSlot => ({
|
|
26
|
+
version: null,
|
|
27
|
+
visitor: null,
|
|
28
|
+
assignments: null,
|
|
29
|
+
unavailable: false,
|
|
30
|
+
releaseCheck: null,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
10
33
|
|
|
11
|
-
/** The
|
|
34
|
+
/** The version bound to this request (`bindDraft`), or `null` when it renders the current release. */
|
|
12
35
|
export function activeDraftPointer(): string | null {
|
|
13
|
-
return draftSlot().
|
|
36
|
+
return draftSlot().version?.version ?? null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Whether the request's link named a draft that can't be read anymore (`bindDraft`). */
|
|
40
|
+
export function draftUnavailable(): boolean {
|
|
41
|
+
return draftSlot().unavailable;
|
|
14
42
|
}
|
package/src/visitor.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the proxy hands the pages the visitor: the request header it forwards
|
|
3
|
+
* once it read the visitor (`readVisitor` from `@decocms/blocks/experiments`,
|
|
4
|
+
* a new one on a first visit). The proxy writes it (`visitorRequestHeaders`
|
|
5
|
+
* from `@decocms/nextjs/proxy`); the pages read it (`bindDraft`, then
|
|
6
|
+
* `contentClient`, from `@decocms/nextjs/server`). The package's own copy: it
|
|
7
|
+
* never imports `@decocms/tanstack`. No imports, no state: safe in every
|
|
8
|
+
* bundle.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The request header carrying the visitor, in its stored form (the
|
|
13
|
+
* `deco_segment` cookie's value, `serializeVisitor`). The proxy removes any
|
|
14
|
+
* copy that arrives from outside (`draftRequestHeaders`).
|
|
15
|
+
*/
|
|
16
|
+
export const VISITOR_HEADER = "x-deco-visitor";
|