@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.
Files changed (51) hide show
  1. package/README.md +82 -15
  2. package/dist/DraftPreviewIndicator.d.ts.map +1 -1
  3. package/dist/DraftPreviewIndicator.js +4 -3
  4. package/dist/DraftPreviewIndicator.js.map +1 -1
  5. package/dist/assignments.d.ts +95 -0
  6. package/dist/assignments.d.ts.map +1 -0
  7. package/dist/assignments.js +229 -0
  8. package/dist/assignments.js.map +1 -0
  9. package/dist/draft.d.ts +36 -10
  10. package/dist/draft.d.ts.map +1 -1
  11. package/dist/draft.js +38 -12
  12. package/dist/draft.js.map +1 -1
  13. package/dist/drafts.d.ts +39 -60
  14. package/dist/drafts.d.ts.map +1 -1
  15. package/dist/drafts.js +58 -117
  16. package/dist/drafts.js.map +1 -1
  17. package/dist/index.d.ts +5 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/instrumentation/index.d.ts +1 -1
  22. package/dist/instrumentation/index.js +3 -3
  23. package/dist/instrumentation/index.js.map +1 -1
  24. package/dist/proxy/index.d.ts +175 -35
  25. package/dist/proxy/index.d.ts.map +1 -1
  26. package/dist/proxy/index.js +242 -41
  27. package/dist/proxy/index.js.map +1 -1
  28. package/dist/server/index.d.ts +59 -20
  29. package/dist/server/index.d.ts.map +1 -1
  30. package/dist/server/index.js +143 -25
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/slot.d.ts +25 -4
  33. package/dist/server/slot.d.ts.map +1 -1
  34. package/dist/server/slot.js +13 -9
  35. package/dist/server/slot.js.map +1 -1
  36. package/dist/visitor.d.ts +16 -0
  37. package/dist/visitor.d.ts.map +1 -0
  38. package/dist/visitor.js +16 -0
  39. package/dist/visitor.js.map +1 -0
  40. package/package.json +3 -3
  41. package/src/DraftPreviewIndicator.tsx +4 -3
  42. package/src/assignments.ts +278 -0
  43. package/src/config/index.cjs +1 -1
  44. package/src/draft.ts +43 -12
  45. package/src/drafts.ts +71 -126
  46. package/src/index.ts +5 -2
  47. package/src/instrumentation/index.ts +3 -3
  48. package/src/proxy/index.ts +371 -59
  49. package/src/server/index.ts +173 -33
  50. package/src/server/slot.ts +33 -5
  51. package/src/visitor.ts +16 -0
@@ -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
- * A page (and, through the `x-deco-draft` header the proxy forwards, the root
6
- * layout) binds the request's draft before it reads content, then reads
7
- * through `contentClient(cms)`:
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, site: "my-store" });
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 with the same
19
- * options, so a route the proxy doesn't match, or a forged `x-deco-draft`,
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 { DRAFT_HEADER, DRAFT_PARAM } from "../draft.ts";
26
- import { allowedDraft, isDraftPreviewEnabled, type PreviewPolicy, requestLike } from "../drafts.ts";
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 { type PreviewPolicy, requestLike } from "../drafts.ts";
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 extends PreviewPolicy {
36
- /** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-draft`). */
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's draft, if any: the page's own `?__draft=` first (`off`
47
- * leaves preview), then the pointer the proxy forwarded on `x-deco-draft`,
48
- * then the draft cookie, each on a host the options allow. Returns whether a
49
- * draft was bound. With previews off on every host it returns `false`
50
- * without reading the request, so a static page stays static.
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: Pick<CMS, "draftPointer" | "settings" | "forRelease">,
54
- options: BindDraftOptions = {},
55
- ): Promise<boolean> {
56
- const { searchParams, ...policy } = options;
57
- if (!(await isDraftPreviewEnabled(cms, policy))) return false;
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
- const forwarded = param ?? requestHeaders.get(DRAFT_HEADER);
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
- const pointer = request ? await allowedDraft(cms, request, policy) : null;
65
- if (pointer === null) return false;
66
- draftSlot().pointer = pointer;
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: the draft `bindDraft` bound, or the
72
- * release.
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 contentClient(cms: Pick<CMS, "forDraft" | "forRelease">): Client {
75
- const pointer = activeDraftPointer();
76
- return pointer ? cms.forDraft(pointer) : cms.forRelease();
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
  }
@@ -1,14 +1,42 @@
1
1
  /**
2
- * The draft a request renders, held for the request's Server Components.
3
- * React's `cache()` gives one slot per request in the React Server
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
- export const draftSlot = cache((): { pointer: string | null } => ({ pointer: null }));
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 draft pointer bound to this request (`bindDraft`), or `null` when it renders the release. */
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().pointer;
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";