@decocms/nextjs 8.5.2 → 8.6.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 (46) hide show
  1. package/README.md +81 -14
  2. package/dist/assignments.d.ts +95 -0
  3. package/dist/assignments.d.ts.map +1 -0
  4. package/dist/assignments.js +229 -0
  5. package/dist/assignments.js.map +1 -0
  6. package/dist/draft.d.ts +9 -7
  7. package/dist/draft.d.ts.map +1 -1
  8. package/dist/draft.js +10 -8
  9. package/dist/draft.js.map +1 -1
  10. package/dist/drafts.d.ts +29 -60
  11. package/dist/drafts.d.ts.map +1 -1
  12. package/dist/drafts.js +38 -117
  13. package/dist/drafts.js.map +1 -1
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +5 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/instrumentation/index.d.ts +1 -1
  19. package/dist/instrumentation/index.js +3 -3
  20. package/dist/instrumentation/index.js.map +1 -1
  21. package/dist/proxy/index.d.ts +134 -30
  22. package/dist/proxy/index.d.ts.map +1 -1
  23. package/dist/proxy/index.js +169 -37
  24. package/dist/proxy/index.js.map +1 -1
  25. package/dist/server/index.d.ts +59 -20
  26. package/dist/server/index.d.ts.map +1 -1
  27. package/dist/server/index.js +129 -25
  28. package/dist/server/index.js.map +1 -1
  29. package/dist/server/slot.d.ts +19 -4
  30. package/dist/server/slot.d.ts.map +1 -1
  31. package/dist/server/slot.js +3 -9
  32. package/dist/server/slot.js.map +1 -1
  33. package/dist/visitor.d.ts +16 -0
  34. package/dist/visitor.d.ts.map +1 -0
  35. package/dist/visitor.js +16 -0
  36. package/dist/visitor.js.map +1 -0
  37. package/package.json +3 -3
  38. package/src/assignments.ts +278 -0
  39. package/src/draft.ts +10 -8
  40. package/src/drafts.ts +38 -126
  41. package/src/index.ts +5 -2
  42. package/src/instrumentation/index.ts +3 -3
  43. package/src/proxy/index.ts +269 -56
  44. package/src/server/index.ts +152 -33
  45. package/src/server/slot.ts +18 -5
  46. package/src/visitor.ts +16 -0
@@ -1,5 +1,6 @@
1
1
  /**
2
- * `@decocms/nextjs/proxy`: draft previews in a Next.js proxy (`proxy.ts`, or
2
+ * `@decocms/nextjs/proxy`: draft previews, the visitor and what a request's
3
+ * content gives it, decided once, in a Next.js proxy (`proxy.ts`, or
3
4
  * `middleware.ts` before Next 16). No React and no block map: pass the proxy
4
5
  * a CMS handle made with an empty block map and the app's options, which
5
6
  * shares the app's instance (one per process) without pulling the site's
@@ -10,90 +11,230 @@
10
11
  * export const proxyCms = createCMS({ blocks: {}, ...cmsConfig });
11
12
  * ```
12
13
  *
13
- * A site with a proxy of its own calls the pieces (`decideDraft`, then
14
- * `draftRequestHeaders` on the request it forwards, then `applyDraft` on the
15
- * response); a site without one exports `draftProxy(...)`.
14
+ * A site with a proxy of its own calls the pieces (`decideDraft`,
15
+ * `decideVisitor` and `decideAssignments`, then `draftRequestHeaders`,
16
+ * `visitorRequestHeaders` and `assignmentsRequestHeaders` on the request it
17
+ * forwards, then `applyDraft` and `applyVisitor` on the response); a site
18
+ * without one exports `draftProxy(...)`.
16
19
  *
17
- * Statically rendered pages can't read a draft, so a drafted request to one
18
- * is rewritten onto a dynamic draft route (`/_draft/<path>`, mounted as
20
+ * The proxy decides: the version (`decideDraft`: a Studio link's draft or old
21
+ * release, after the host allowlist, with the preview's moment and switched
22
+ * tests and campaigns), the visitor (`decideVisitor`: `readVisitor` from
23
+ * `@decocms/blocks/experiments`, the `deco_segment` cookie or a new visitor)
24
+ * and, from both, each test and campaign on or off (`decideAssignments`:
25
+ * `cms.client({ version, visitor, date, experiments, campaigns })` resolving
26
+ * the saved `experiments` and `campaigns` blocks). It forwards them on
27
+ * `x-deco-version`, `x-deco-visitor` and `x-deco-assignments` (copies from
28
+ * outside are always removed), and the page builds
29
+ * `cms.client({ version, experiments, campaigns })` from them
30
+ * (`contentClient`), deciding nothing again. It sends the visitor's cookie on
31
+ * a first visit only, never from a preview.
32
+ *
33
+ * Cached (ISR) pages can't read a request, so a drafted request to one is
34
+ * rewritten onto a dynamic draft route (`/_draft/<path>`, mounted as
19
35
  * `app/%5Fdraft/...`: a literal `_draft` folder is private in Next and never
20
- * routes).
36
+ * routes), and a published one onto the assignments route
37
+ * (`/_assignments/<assignments>/<path>`, `rewriteToAssignmentsRoute`), where
38
+ * `assignedClient` from `@decocms/nextjs/server` builds the client from the
39
+ * path.
40
+ *
41
+ * The binding caches nothing. A site that caches in front of its pages (a
42
+ * CDN keyed on a request header) keys on the request's assignments, never on
43
+ * the visitor: `assignmentsKey(await decideAssignments(proxyCms, draft,
44
+ * visitor))`, `""` when there are none.
21
45
  *
22
46
  * The proxy measures no requests: it runs before Next routes and renders the
23
47
  * page. `http.server.request.duration` comes from Next's own request span
24
48
  * (`register` from `@decocms/nextjs/instrumentation`).
25
49
  */
26
- import type { CMS } from "@decocms/blocks";
50
+ import type { CMS, RequestVersion } from "@decocms/blocks";
51
+ import { readVisitor, serializeVisitor, type Visitor } from "@decocms/blocks/experiments";
27
52
  import { type NextRequest, NextResponse } from "next/server.js";
28
- import { DRAFT_HEADER, DRAFT_ROUTE_PREFIX, draftResponseHeaders } from "../draft.ts";
53
+ import {
54
+ ASSIGNMENTS_HEADER,
55
+ assignmentsRoutePath,
56
+ assignmentsSegment,
57
+ isAssignmentsRoutePath,
58
+ type RequestAssignments,
59
+ readAssignmentsAndScope,
60
+ } from "../assignments.ts";
61
+ import { DRAFT_ROUTE_PREFIX, draftResponseHeaders, VERSION_HEADER } from "../draft.ts";
29
62
  import {
30
63
  allowedDraft,
31
64
  draftCookie,
65
+ draftLinkValue,
32
66
  endsPreview,
33
- fromDraftLink,
34
67
  hasDraftSignal,
35
- type PreviewPolicy,
36
68
  requestLike,
37
69
  } from "../drafts.ts";
70
+ import { VISITOR_HEADER } from "../visitor.ts";
38
71
 
39
- export {
40
- inferredPreviewHosts,
41
- type PreviewPolicy,
42
- previewHostDecision,
43
- requestLike,
44
- } from "../drafts.ts";
72
+ export { requestLike } from "../drafts.ts";
45
73
 
46
- /** What the proxy needs from the CMS: the site's declared preview hosts and their check. */
47
- export type CMSHandle = Pick<CMS, "draftPointer" | "settings" | "forRelease">;
74
+ /** What the draft decision needs from the CMS: its preview host check, and the release. */
75
+ export type CMSHandle = Pick<CMS, "readVersion" | "forRelease">;
48
76
 
49
77
  /** What the proxy does with a request's draft state. */
50
78
  export interface DraftDecision {
51
- /** The draft this request renders, after the host check; `null` for the release. */
52
- pointer: string | null;
53
- /** The `Set-Cookie` the response carries: storing a draft link's pointer, or ending a preview. */
79
+ /**
80
+ * What this request's Studio link asks for, after the host check: the
81
+ * version (a draft pointer, or a release's commit SHA) and the preview's
82
+ * moment and switched tests and campaigns, in the shape of
83
+ * `cms.client()`'s options; `null` for the current release.
84
+ */
85
+ version: RequestVersion | null;
86
+ /** The `Set-Cookie` the response carries: storing a link's version, or ending a preview. */
54
87
  setCookie: string | null;
55
88
  }
56
89
 
57
- export { DRAFT_ROUTE_PREFIX } from "../draft.ts";
90
+ export {
91
+ ASSIGNMENTS_HEADER,
92
+ ASSIGNMENTS_ROUTE_PREFIX,
93
+ assignmentsKey,
94
+ assignmentsRoutePath,
95
+ assignmentsSegment,
96
+ isAssignmentsRoutePath,
97
+ type PathAssignments,
98
+ parseAssignmentsSegment,
99
+ type RequestAssignments,
100
+ readAssignments,
101
+ } from "../assignments.ts";
102
+ export { DRAFT_ROUTE_PREFIX, VERSION_HEADER } from "../draft.ts";
58
103
 
59
- const INERT: DraftDecision = Object.freeze({ pointer: null, setCookie: null });
104
+ const INERT: DraftDecision = Object.freeze({ version: null, setCookie: null });
60
105
 
61
106
  /**
62
- * The draft a request renders and the cookie its response sets, under
63
- * `policy` (the same options the pages pass to `bindDraft`). A request with
107
+ * The version a request renders and the cookie its response sets, on a host
108
+ * the CMS allows (as the pages' `bindDraft` checks again). A request with
64
109
  * no `?__draft=` and no draft cookie never reaches the CMS.
65
110
  */
66
111
  export async function decideDraft(
67
112
  cms: CMSHandle,
68
113
  request: NextRequest | Request,
69
- policy: PreviewPolicy = {},
70
114
  ): Promise<DraftDecision> {
71
115
  const url = new URL(request.url);
72
116
  const like = requestLike(request.headers, url.pathname + url.search, url.host);
73
117
  if (like === null || !hasDraftSignal(like)) return INERT;
74
118
  // Ending a preview is allowed on any host, so a cookie set earlier always goes.
75
- if (endsPreview(like)) return { pointer: null, setCookie: draftCookie("", ["Max-Age=0"]) };
76
- const pointer = await allowedDraft(cms, like, policy);
77
- if (pointer === null) return INERT;
78
- return {
79
- pointer,
80
- setCookie: fromDraftLink(like, pointer) ? draftCookie(encodeURIComponent(pointer)) : null,
81
- };
119
+ if (endsPreview(like)) return { version: null, setCookie: draftCookie("", ["Max-Age=0"]) };
120
+ const version = await allowedDraft(cms, like);
121
+ if (version === null) return INERT;
122
+ const link = draftLinkValue(like);
123
+ return { version, setCookie: link ? draftCookie(encodeURIComponent(link)) : null };
82
124
  }
83
125
 
126
+ /** The headers the proxy writes for the pages: never taken from outside. */
127
+ const OWN_HEADERS = [VERSION_HEADER, VISITOR_HEADER, ASSIGNMENTS_HEADER];
128
+
84
129
  /**
85
- * The request headers to forward: the accepted pointer on `x-deco-draft`, so
86
- * the root layout can bind the draft on the same request (a layout gets no
130
+ * The request headers to forward: the accepted version on `x-deco-version`,
131
+ * so the root layout can bind the draft on the same request (a layout gets no
87
132
  * `searchParams`, and on a draft link the cookie is only on the response).
88
- * A copy that arrives from outside is always removed.
133
+ * A copy that arrives from outside is always removed, and so are any
134
+ * `x-deco-visitor` and `x-deco-assignments` (`visitorRequestHeaders` and
135
+ * `assignmentsRequestHeaders` set the proxy's own).
89
136
  */
90
137
  export function draftRequestHeaders(
91
138
  request: NextRequest | Request,
92
139
  decision: DraftDecision,
93
140
  ): Headers {
94
141
  const headers = new Headers(request.headers);
95
- if (decision.pointer) headers.set(DRAFT_HEADER, decision.pointer);
96
- else headers.delete(DRAFT_HEADER);
142
+ for (const name of OWN_HEADERS) headers.delete(name);
143
+ if (decision.version) headers.set(VERSION_HEADER, decision.version.version);
144
+ return headers;
145
+ }
146
+
147
+ /** The visitor of one request. */
148
+ export interface VisitorDecision {
149
+ /** Who is visiting: the `deco_segment` cookie's visitor, or a new one. */
150
+ visitor: Visitor;
151
+ /** The `deco_segment` `Set-Cookie`, on a first visit to the live site; `null` otherwise. */
152
+ setCookie: string | null;
153
+ }
154
+
155
+ /** Whether the visitor asked over https (`x-forwarded-proto` first, as a host in front reports it). */
156
+ function isHttps(request: NextRequest | Request): boolean {
157
+ const forwarded = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
158
+ if (forwarded) return forwarded === "https";
159
+ return new URL(request.url).protocol === "https:";
160
+ }
161
+
162
+ /**
163
+ * The visitor of a request (`readVisitor`: the `deco_segment` cookie, or a
164
+ * new visitor when it had no id) and the cookie to send back, only on a first
165
+ * visit and never from a preview (`draft` holds a version): Studio is not a
166
+ * visitor. Reads no content.
167
+ */
168
+ export function decideVisitor(
169
+ request: NextRequest | Request,
170
+ draft: DraftDecision,
171
+ ): VisitorDecision {
172
+ // `readVisitor` makes the cookie `Secure` unless the request came over plain http.
173
+ const { visitor, cookie } = readVisitor({
174
+ url: isHttps(request) ? "https://localhost/" : "http://localhost/",
175
+ headers: request.headers,
176
+ });
177
+ return { visitor, setCookie: draft.version === null ? cookie : null };
178
+ }
179
+
180
+ /**
181
+ * The forwarded headers with the visitor on `x-deco-visitor`. Pass it the
182
+ * headers `draftRequestHeaders` gave, which already removed a copy from
183
+ * outside.
184
+ */
185
+ export function visitorRequestHeaders(headers: Headers, decision: VisitorDecision): Headers {
186
+ const out = new Headers(headers);
187
+ out.set(VISITOR_HEADER, serializeVisitor(decision.visitor));
188
+ return out;
189
+ }
190
+
191
+ /** The `deco_segment` cookie on the response, on a first visit. */
192
+ export function applyVisitor<R extends Response>(response: R, decision: VisitorDecision): R {
193
+ if (decision.setCookie) response.headers.append("set-cookie", decision.setCookie);
194
+ return response;
195
+ }
196
+
197
+ /**
198
+ * Each test and campaign on or off for this request, decided once:
199
+ * `cms.client({ visitor, ...version })` (the visitor, and the Studio link's
200
+ * version, moment and switched tests and campaigns) resolving the saved
201
+ * `experiments` and `campaigns` blocks. Missing blocks, or content that
202
+ * doesn't load, give none, never an error.
203
+ */
204
+ export function decideAssignments(
205
+ cms: Pick<CMS, "client">,
206
+ draft: DraftDecision,
207
+ visitor: VisitorDecision,
208
+ ): Promise<RequestAssignments> {
209
+ return readAssignmentsAndScope(cms, { visitor: visitor.visitor, ...draft.version }).then(
210
+ (read) => read.assignments,
211
+ );
212
+ }
213
+
214
+ /**
215
+ * The forwarded headers with the decided tests and campaigns on
216
+ * `x-deco-assignments` (`assignmentsSegment`), for `bindDraft` and
217
+ * `contentClient`. Pass it the headers `draftRequestHeaders` gave.
218
+ */
219
+ export function assignmentsRequestHeaders(
220
+ headers: Headers,
221
+ assignments: RequestAssignments,
222
+ ): Headers {
223
+ const out = new Headers(headers);
224
+ out.set(ASSIGNMENTS_HEADER, assignmentsSegment(assignments));
225
+ return out;
226
+ }
227
+
228
+ /** The request headers to forward: the version's and, when given, the visitor's and the assignments. */
229
+ function forwardedHeaders(
230
+ request: NextRequest | Request,
231
+ draft: DraftDecision,
232
+ visitor: VisitorDecision | undefined,
233
+ assignments: RequestAssignments | undefined,
234
+ ): Headers {
235
+ let headers = draftRequestHeaders(request, draft);
236
+ if (visitor !== undefined) headers = visitorRequestHeaders(headers, visitor);
237
+ if (assignments !== undefined) headers = assignmentsRequestHeaders(headers, assignments);
97
238
  return headers;
98
239
  }
99
240
 
@@ -104,61 +245,133 @@ export function isDraftRoutePath(pathname: string): boolean {
104
245
 
105
246
  /**
106
247
  * The rewrite of a drafted request onto the draft route, keeping its path and
107
- * forwarding the pointer; `null` when the request isn't drafted or is already
108
- * on the draft route.
248
+ * forwarding the version and, when given, the visitor (`decideVisitor`) and
249
+ * the assignments (`decideAssignments`); `null` when the request isn't
250
+ * drafted or is already on the draft route.
109
251
  */
110
252
  export function rewriteToDraftRoute(
111
253
  request: NextRequest,
112
254
  decision: DraftDecision,
255
+ visitor?: VisitorDecision,
256
+ assignments?: RequestAssignments,
113
257
  ): NextResponse | null {
114
- if (!decision.pointer) return null;
258
+ if (!decision.version) return null;
115
259
  const url = request.nextUrl.clone();
116
260
  if (isDraftRoutePath(url.pathname)) return null;
117
261
  url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
118
262
  return NextResponse.rewrite(url, {
119
- request: { headers: draftRequestHeaders(request, decision) },
263
+ request: { headers: forwardedHeaders(request, decision, visitor, assignments) },
264
+ });
265
+ }
266
+
267
+ /** An assignments-route response's `Cache-Control` when its content has tests or campaigns. */
268
+ const ASSIGNED_CACHE_CONTROL = "private, no-cache";
269
+
270
+ /**
271
+ * The rewrite of a published request onto the assignments route,
272
+ * `/_assignments/<assignments>/<path>`: each test on or off for the visitor
273
+ * and each campaign running or not now (`readAssignments` on the current
274
+ * release), as one path segment, so Next's own path-keyed cache (ISR, the
275
+ * full route cache) keeps one entry per set of assignments. Visitors on the
276
+ * same side of every test share a path; the visitor id is never in it, and
277
+ * the page reads neither the visitor nor the version (the proxy's headers are
278
+ * removed from the request it forwards), so Next can cache it. The page
279
+ * builds its client with `assignedClient` from `@decocms/nextjs/server`,
280
+ * `cms.client({ experiments, campaigns })` from the path: the proxy decides,
281
+ * the page renders that. A cached page (ISR, `revalidate = 600`) is about 10
282
+ * minutes stale (the first request after that still gets it while Next
283
+ * renders it again), a banner switching on late, which is accepted.
284
+ *
285
+ * `null` for a drafted request (it renders on the draft route, never cached)
286
+ * and for a path already on the draft or assignments route. When the content
287
+ * saves tests or campaigns, the response is marked `Cache-Control: private,
288
+ * no-cache`: Next keeps one cached page per set of assignments, but the URL
289
+ * the visitor asked for answers by their cookie and the moment, so a CDN in
290
+ * front must not share it. Call it only for the paths the assignments route
291
+ * renders (the CMS's pages), and answer a direct hit on the route with a 404
292
+ * (`isAssignmentsRoutePath`).
293
+ */
294
+ export async function rewriteToAssignmentsRoute(
295
+ cms: Pick<CMS, "client">,
296
+ request: NextRequest,
297
+ draft: DraftDecision,
298
+ visitor: VisitorDecision,
299
+ ): Promise<NextResponse | null> {
300
+ if (draft.version) return null;
301
+ const url = request.nextUrl.clone();
302
+ if (isDraftRoutePath(url.pathname) || isAssignmentsRoutePath(url.pathname)) return null;
303
+ const { assignments, varies } = await readAssignmentsAndScope(cms, { visitor: visitor.visitor });
304
+ url.pathname = assignmentsRoutePath(url.pathname, assignments);
305
+ const response = NextResponse.rewrite(url, {
306
+ request: { headers: draftRequestHeaders(request, INERT) },
120
307
  });
308
+ // The URL the visitor asked for answers differently by their cookie and the moment: a
309
+ // shared cache in front (a CDN) must not keep Next's ISR `s-maxage` under it.
310
+ if (varies) response.headers.set("Cache-Control", ASSIGNED_CACHE_CONTROL);
311
+ return response;
121
312
  }
122
313
 
123
314
  /**
124
315
  * The cookie and the response headers of a draft: `Set-Cookie`, and for a
125
316
  * drafted response `Cache-Control: no-store, private`, `Vary: Cookie` and
126
317
  * `X-Robots-Tag: noindex, nofollow` (`draftResponseHeaders`). With the
127
- * pointer in a cookie a draft shares its URL with the published page, so a
318
+ * version in a cookie a draft shares its URL with the published page, so a
128
319
  * cache keyed on the URL alone would serve it to visitors.
129
320
  */
130
321
  export function applyDraft<R extends Response>(response: R, decision: DraftDecision): R {
131
322
  if (decision.setCookie) response.headers.append("set-cookie", decision.setCookie);
132
- for (const [name, value] of Object.entries(draftResponseHeaders(decision.pointer))) {
323
+ for (const [name, value] of Object.entries(draftResponseHeaders(decision.version?.version))) {
133
324
  response.headers.set(name, value);
134
325
  }
135
326
  return response;
136
327
  }
137
328
 
138
- export interface DraftProxyOptions extends PreviewPolicy {
329
+ export interface DraftProxyOptions {
139
330
  /**
140
331
  * Which drafted requests go to the draft route (a statically rendered
141
332
  * page's). Default: every drafted request outside the draft route. Return
142
333
  * `false` for a route that is already dynamic and binds the draft itself.
143
334
  */
144
335
  rewrite?: (request: NextRequest) => boolean;
336
+ /**
337
+ * Which published requests render on the assignments route
338
+ * (`rewriteToAssignmentsRoute`): the CMS's pages, when the site mounts
339
+ * `app/%5Fassignments/[assignments]/[[...path]]`. Default: none. A direct
340
+ * hit on the route gets a 404 once this is set.
341
+ */
342
+ assignments?: (request: NextRequest) => boolean;
145
343
  }
146
344
 
147
345
  /**
148
- * The whole draft flow for a site with no proxy of its own:
149
- * `export const proxy = draftProxy(proxyCms, { site: "my-store" })`.
346
+ * The whole flow for a site with no proxy of its own:
347
+ * `export const proxy = draftProxy(proxyCms)`. Every
348
+ * request it forwards carries the version, the visitor and the assignments
349
+ * it decided.
150
350
  */
151
351
  export function draftProxy(
152
- cms: CMSHandle,
352
+ cms: CMSHandle & Pick<CMS, "client">,
153
353
  options: DraftProxyOptions = {},
154
354
  ): (request: NextRequest) => Promise<NextResponse> {
155
- const { rewrite = () => true, ...policy } = options;
355
+ const { rewrite = () => true, assignments } = options;
156
356
  return async (request) => {
157
- const decision = await decideDraft(cms, request, policy);
158
- const rewritten = rewrite(request) ? rewriteToDraftRoute(request, decision) : null;
159
- const response =
160
- rewritten ??
161
- NextResponse.next({ request: { headers: draftRequestHeaders(request, decision) } });
162
- return applyDraft(response, decision);
357
+ if (assignments && isAssignmentsRoutePath(request.nextUrl.pathname)) {
358
+ return new NextResponse(null, { status: 404 });
359
+ }
360
+ const decision = await decideDraft(cms, request);
361
+ const visitor = decideVisitor(request, decision);
362
+ const onAssignmentsRoute = decision.version === null && assignments?.(request) === true;
363
+ const rewritten = onAssignmentsRoute
364
+ ? await rewriteToAssignmentsRoute(cms, request, decision, visitor)
365
+ : null;
366
+ let response = rewritten;
367
+ if (response === null) {
368
+ const decided = await decideAssignments(cms, decision, visitor);
369
+ response =
370
+ (rewrite(request) ? rewriteToDraftRoute(request, decision, visitor, decided) : null) ??
371
+ NextResponse.next({
372
+ request: { headers: forwardedHeaders(request, decision, visitor, decided) },
373
+ });
374
+ }
375
+ return applyVisitor(applyDraft(response, decision), visitor);
163
376
  };
164
377
  }
@@ -2,76 +2,195 @@
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 { DRAFT_PARAM, VERSION_HEADER } from "../draft.ts";
38
+ import { allowedDraft, requestLike } from "../drafts.ts";
39
+ import { VISITOR_HEADER } from "../visitor.ts";
27
40
  import { activeDraftPointer, draftSlot } from "./slot.ts";
28
41
 
29
- export { type PreviewPolicy, requestLike } from "../drafts.ts";
42
+ export {
43
+ ASSIGNMENTS_ROUTE_PREFIX,
44
+ assignmentsSegment,
45
+ type PathAssignments,
46
+ parseAssignmentsSegment,
47
+ } from "../assignments.ts";
48
+ export { requestLike } from "../drafts.ts";
30
49
  export { activeDraftPointer };
31
50
 
32
51
  /** A page's `searchParams` prop, awaited. */
33
52
  export type SearchParams = Record<string, string | string[] | undefined>;
34
53
 
35
- export interface BindDraftOptions extends PreviewPolicy {
36
- /** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-draft`). */
54
+ export interface BindDraftOptions {
55
+ /** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-version`). */
37
56
  searchParams?: SearchParams;
38
57
  }
39
58
 
59
+ type BindCMS = Pick<CMS, "readVersion" | "forRelease">;
60
+
40
61
  function firstParam(searchParams: SearchParams | undefined, key: string): string | null {
41
62
  const raw = searchParams?.[key];
42
63
  return (Array.isArray(raw) ? raw[0] : raw) ?? null;
43
64
  }
44
65
 
45
66
  /**
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.
67
+ * Binds what the request reads: the version, if any (the page's own
68
+ * `?__draft=` first, `off` leaving preview, then the version the proxy
69
+ * forwarded on `x-deco-version`, then the draft cookie, each on a host the
70
+ * CMS allows), and what the proxy decided (`x-deco-assignments`) and the
71
+ * visitor it read (`x-deco-visitor`). Returns whether a version was bound:
72
+ * a draft, or a published release's commit SHA (Studio's "Preview this
73
+ * version"). It reads the request's headers, so it belongs on the draft
74
+ * route; a cached (ISR) page reads what its path names (`assignedClient`).
51
75
  */
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;
76
+ export async function bindDraft(cms: BindCMS, options: BindDraftOptions = {}): Promise<boolean> {
77
+ if (await bindVersion(cms, options)) {
78
+ // A preview renders per request anyway, and may switch on a test at 0%.
79
+ await bindForwarded(cms, false);
80
+ return true;
81
+ }
82
+ if (await variesByVisitor(cms)) await bindForwarded(cms, true);
83
+ return false;
84
+ }
85
+
86
+ /** The visitor and the assignments the proxy forwarded, if any. */
87
+ async function bindForwarded(cms: BindCMS, release: boolean): Promise<void> {
88
+ const requestHeaders = await headers();
89
+ const slot = draftSlot();
90
+ slot.visitor = parseVisitor(requestHeaders.get(VISITOR_HEADER));
91
+ const decided = parseAssignmentsSegment(requestHeaders.get(ASSIGNMENTS_HEADER) ?? "");
92
+ slot.assignments = decided && release ? await withoutDarkTests(cms, decided) : decided;
93
+ }
94
+
95
+ /**
96
+ * Whether the release saves an `experiments` block (the built-in
97
+ * `split-traffic`) or a `campaigns` block (the built-in `calendar`) with a
98
+ * name in it: only then does a page vary by what the proxy decided. Read from
99
+ * memory, unresolved; `false` when both are missing.
100
+ */
101
+ async function variesByVisitor(cms: Pick<CMS, "forRelease">): Promise<boolean> {
102
+ const saves = async (name: string, type: string): Promise<boolean> => {
103
+ try {
104
+ const [block] = await cms.forRelease().resolve<unknown>(name, { run: false });
105
+ if (typeof block !== "object" || block === null) return false;
106
+ const record = block as Record<string, unknown>;
107
+ return (
108
+ record.__resolveType === type && Object.keys(record).some((key) => key !== "__resolveType")
109
+ );
110
+ } catch {
111
+ return false;
112
+ }
113
+ };
114
+ const [tests, campaigns] = await Promise.all([
115
+ saves("experiments", "split-traffic"),
116
+ saves("campaigns", "calendar"),
117
+ ]);
118
+ return tests || campaigns;
119
+ }
120
+
121
+ async function bindVersion(cms: BindCMS, options: BindDraftOptions): Promise<boolean> {
122
+ const { searchParams } = options;
58
123
  const param = firstParam(searchParams, DRAFT_PARAM);
59
124
  if (param === "off") return false;
60
125
  const requestHeaders = await headers();
61
- const forwarded = param ?? requestHeaders.get(DRAFT_HEADER);
126
+ const forwarded = param ?? requestHeaders.get(VERSION_HEADER);
62
127
  const query = forwarded ? `?${DRAFT_PARAM}=${encodeURIComponent(forwarded)}` : "";
63
128
  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;
129
+ const version = request ? await allowedDraft(cms, request) : null;
130
+ if (version === null) return false;
131
+ draftSlot().version = version;
67
132
  return true;
68
133
  }
69
134
 
70
135
  /**
71
- * The content client for this request: the draft `bindDraft` bound, or the
72
- * release.
136
+ * The content client for this request, from what `bindDraft` bound:
137
+ * `cms.client({ version, experiments, campaigns })` with the tests and
138
+ * campaigns the proxy decided, deciding nothing again. On a route the proxy
139
+ * didn't decide for, the visitor and the preview's moment and switched tests
140
+ * and campaigns decide instead. Create one per request.
141
+ */
142
+ export function contentClient(cms: Pick<CMS, "client">): Client {
143
+ const { version, visitor, assignments } = draftSlot();
144
+ const options: ClientOptions = { visitor, version: version?.version ?? null };
145
+ if (version?.date !== undefined) options.date = version.date;
146
+ const experiments = over(version?.experiments, assignments?.experiments);
147
+ const campaigns = over(version?.campaigns, assignments?.campaigns);
148
+ if (experiments !== undefined) options.experiments = experiments;
149
+ if (campaigns !== undefined) options.campaigns = campaigns;
150
+ return cms.client(options);
151
+ }
152
+
153
+ /** `decided` over `preview`, name by name; `undefined` when neither has any. */
154
+ function over(
155
+ preview: Readonly<Record<string, boolean>> | undefined,
156
+ decided: Readonly<Record<string, boolean>> | undefined,
157
+ ): Record<string, boolean> | undefined {
158
+ if (preview === undefined && decided === undefined) return undefined;
159
+ return { ...preview, ...decided };
160
+ }
161
+
162
+ /**
163
+ * The content client of a page on the assignments route
164
+ * (`app/%5Fassignments/[assignments]/[[...path]]`, which the proxy's
165
+ * `rewriteToAssignmentsRoute` rewrites the CMS's pages to): the current
166
+ * release, read as `cms.client({ experiments, campaigns })` with every test
167
+ * and campaign the path's segment names on or off as the proxy decided, and
168
+ * no visitor. The proxy decides, the page renders that, reading no request,
169
+ * so Next can cache it (ISR, `revalidate = 600`). A test at 0% stays off
170
+ * whatever the path names, and a test or a campaign the content doesn't have
171
+ * stays out, never an error: a 404 here would be cached under the path by
172
+ * Next. `null` only when the segment isn't one the proxy writes: the page
173
+ * answers with `notFound()`.
174
+ *
175
+ * ```tsx
176
+ * export const revalidate = 600;
177
+ * export async function generateStaticParams() {
178
+ * return [];
179
+ * }
180
+ * export default async function Page({ params }) {
181
+ * const { assignments, path } = await params;
182
+ * const client = await assignedClient(cms, assignments);
183
+ * if (!client) notFound();
184
+ * …
185
+ * }
186
+ * ```
73
187
  */
74
- export function contentClient(cms: Pick<CMS, "forDraft" | "forRelease">): Client {
75
- const pointer = activeDraftPointer();
76
- return pointer ? cms.forDraft(pointer) : cms.forRelease();
188
+ export async function assignedClient(
189
+ cms: Pick<CMS, "client" | "forRelease">,
190
+ segment: string,
191
+ ): Promise<Client | null> {
192
+ const parsed = parseAssignmentsSegment(segment);
193
+ if (parsed === null) return null;
194
+ const assignments: PathAssignments = await withoutDarkTests(cms, parsed);
195
+ return cms.client({ experiments: assignments.experiments, campaigns: assignments.campaigns });
77
196
  }