@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
@@ -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,321 @@
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 preview request (any draft
34
+ * signal: `?__draft=` with any value, `off` included, or the draft cookie) is
35
+ * rewritten onto a dynamic draft route, never cached, and answers
36
+ * `Cache-Control: max-age=0, no-cache, must-revalidate`; a drafted request
37
+ * is rewritten onto it (`/_draft/<path>`, mounted as
19
38
  * `app/%5Fdraft/...`: a literal `_draft` folder is private in Next and never
20
- * routes).
39
+ * routes), and a published one onto the assignments route
40
+ * (`/_assignments/<assignments>/<path>`, `rewriteToAssignmentsRoute`), where
41
+ * `assignedClient` from `@decocms/nextjs/server` builds the client from the
42
+ * path.
43
+ *
44
+ * The binding caches nothing. A site that caches in front of its pages (a
45
+ * CDN keyed on a request header) keys on the request's assignments, never on
46
+ * the visitor: `assignmentsKey(await decideAssignments(proxyCms, draft,
47
+ * visitor))`, `""` when there are none.
21
48
  *
22
49
  * The proxy measures no requests: it runs before Next routes and renders the
23
50
  * page. `http.server.request.duration` comes from Next's own request span
24
51
  * (`register` from `@decocms/nextjs/instrumentation`).
25
52
  */
26
- import type { CMS } from "@decocms/blocks";
53
+ import type { CMS, RequestVersion } from "@decocms/blocks";
54
+ import { readVisitor, serializeVisitor, type Visitor } from "@decocms/blocks/experiments";
27
55
  import { type NextRequest, NextResponse } from "next/server.js";
28
- import { DRAFT_HEADER, DRAFT_ROUTE_PREFIX, draftResponseHeaders } from "../draft.ts";
56
+ import {
57
+ ASSIGNMENTS_HEADER,
58
+ assignmentsRoutePath,
59
+ assignmentsSegment,
60
+ isAssignmentsRoutePath,
61
+ type RequestAssignments,
62
+ readAssignmentsAndScope,
63
+ } from "../assignments.ts";
64
+ import {
65
+ DRAFT_ROUTE_PREFIX,
66
+ DRAFT_UNAVAILABLE_HEADER,
67
+ draftResponseHeaders,
68
+ RELEASE_CHECK_HEADER,
69
+ VERSION_HEADER,
70
+ } from "../draft.ts";
29
71
  import {
30
72
  allowedDraft,
31
73
  draftCookie,
74
+ draftLinkValue,
75
+ draftReleaseCheck,
32
76
  endsPreview,
33
- fromDraftLink,
34
77
  hasDraftSignal,
35
- type PreviewPolicy,
36
78
  requestLike,
37
79
  } from "../drafts.ts";
80
+ import { VISITOR_HEADER } from "../visitor.ts";
38
81
 
39
- export {
40
- inferredPreviewHosts,
41
- type PreviewPolicy,
42
- previewHostDecision,
43
- requestLike,
44
- } from "../drafts.ts";
82
+ export { requestLike } from "../drafts.ts";
45
83
 
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">;
84
+ /**
85
+ * What the draft decision needs from the CMS: its preview host check, the
86
+ * release, and the draft a link names (read once before the page renders).
87
+ */
88
+ export type CMSHandle = Pick<CMS, "readVersion" | "forRelease" | "forDraft" | "checkRelease">;
48
89
 
49
90
  /** What the proxy does with a request's draft state. */
50
91
  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. */
92
+ /**
93
+ * What this request's Studio link asks for, after the host check: the
94
+ * version (a draft pointer, or a release's commit SHA) and the preview's
95
+ * moment and switched tests and campaigns, in the shape of
96
+ * `cms.client()`'s options; `null` for the current release.
97
+ */
98
+ version: RequestVersion | null;
99
+ /** The `Set-Cookie` the response carries: storing a link's version, or ending a preview. */
54
100
  setCookie: string | null;
101
+ /**
102
+ * The request carries a draft signal at all (`?__draft=` with any value, or
103
+ * the draft cookie): it renders on the draft route, never from a cache,
104
+ * and answers `draftResponseHeaders`, whatever `version` is.
105
+ */
106
+ preview?: boolean;
107
+ /**
108
+ * The link named a draft that can't be read (deleted, or its host
109
+ * failing): the release renders, the cookie is expired when the draft is
110
+ * gone (a failure that may pass leaves it), and the pages show
111
+ * the calm notice (`x-deco-draft-unavailable`), never an error page.
112
+ */
113
+ unavailable?: true;
114
+ /**
115
+ * The release check made for this draft request (`cms.checkRelease()`),
116
+ * forwarded on `x-deco-release-check`: the host check, the draft read here
117
+ * and the page's all wait for it, so the request reads `latest.json` once.
118
+ */
119
+ releaseCheck?: string;
55
120
  }
56
121
 
57
- export { DRAFT_ROUTE_PREFIX } from "../draft.ts";
122
+ export {
123
+ ASSIGNMENTS_HEADER,
124
+ ASSIGNMENTS_ROUTE_PREFIX,
125
+ assignmentsKey,
126
+ assignmentsRoutePath,
127
+ assignmentsSegment,
128
+ isAssignmentsRoutePath,
129
+ type PathAssignments,
130
+ parseAssignmentsSegment,
131
+ type RequestAssignments,
132
+ readAssignments,
133
+ } from "../assignments.ts";
134
+ export { DRAFT_ROUTE_PREFIX, VERSION_HEADER } from "../draft.ts";
58
135
 
59
- const INERT: DraftDecision = Object.freeze({ pointer: null, setCookie: null });
136
+ const INERT: DraftDecision = Object.freeze({ version: null, setCookie: null });
137
+ /** A preview that renders the release (`off`, a refused host, a value that doesn't parse). */
138
+ const RELEASE_PREVIEW: DraftDecision = Object.freeze({
139
+ version: null,
140
+ setCookie: null,
141
+ preview: true,
142
+ });
60
143
 
61
144
  /**
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
64
- * no `?__draft=` and no draft cookie never reaches the CMS.
145
+ * The version a request renders and the cookie its response sets, on a host
146
+ * the CMS allows (as the pages' `bindDraft` checks again). A request with
147
+ * no `?__draft=` and no draft cookie never reaches the CMS. A draft request
148
+ * checks for the latest release once (`cms.checkRelease()`), shared by the
149
+ * host check, the read below and the page. A draft pointer
150
+ * is read once here (`forDraft(...).revision()`, the read the page makes
151
+ * again): a draft that can't be read gives the release and `unavailable`.
65
152
  */
66
153
  export async function decideDraft(
67
154
  cms: CMSHandle,
68
155
  request: NextRequest | Request,
69
- policy: PreviewPolicy = {},
70
156
  ): Promise<DraftDecision> {
71
157
  const url = new URL(request.url);
72
158
  const like = requestLike(request.headers, url.pathname + url.search, url.host);
73
159
  if (like === null || !hasDraftSignal(like)) return INERT;
74
160
  // 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;
161
+ if (endsPreview(like)) return { ...RELEASE_PREVIEW, setCookie: expiredCookie() };
162
+ // One release check per draft request, shared below and by the page.
163
+ const releaseCheck = await draftReleaseCheck(cms, like);
164
+ const version = await allowedDraft(cms, like, releaseCheck);
165
+ if (version === null) return RELEASE_PREVIEW;
166
+ if (releaseCheck !== undefined) {
167
+ try {
168
+ await cms.forDraft(version.version, { releaseCheck }).revision();
169
+ } catch (error) {
170
+ // Gone (deleted): the preview ends. Any other failure (the drafts' host
171
+ // down, a timeout) leaves the cookie alone, so the next request tries again.
172
+ const setCookie = isDraftGone(error) ? expiredCookie() : null;
173
+ return { ...RELEASE_PREVIEW, setCookie, unavailable: true };
174
+ }
175
+ }
176
+ const link = draftLinkValue(like);
78
177
  return {
79
- pointer,
80
- setCookie: fromDraftLink(like, pointer) ? draftCookie(encodeURIComponent(pointer)) : null,
178
+ version,
179
+ setCookie: link ? draftCookie(encodeURIComponent(link)) : null,
180
+ preview: true,
181
+ ...(releaseCheck === undefined ? {} : { releaseCheck }),
81
182
  };
82
183
  }
83
184
 
84
185
  /**
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
186
+ * Whether a draft read failed because the drafts' host no longer has the
187
+ * draft (`404`/`410`), as opposed to a failure that may pass (a `5xx`, a
188
+ * timeout, a network error). Reads the error and its causes.
189
+ */
190
+ function isDraftGone(error: unknown): boolean {
191
+ for (let e = error, depth = 0; e instanceof Error && depth < 5; e = e.cause, depth++) {
192
+ if (/draft changes: HTTP (404|410)\b/.test(e.message)) return true;
193
+ }
194
+ return false;
195
+ }
196
+
197
+ /** The `Set-Cookie` that ends a preview. */
198
+ function expiredCookie(): string {
199
+ return draftCookie("", ["Max-Age=0"]);
200
+ }
201
+
202
+ /** The headers the proxy writes for the pages: never taken from outside. */
203
+ const OWN_HEADERS = [
204
+ VERSION_HEADER,
205
+ VISITOR_HEADER,
206
+ ASSIGNMENTS_HEADER,
207
+ DRAFT_UNAVAILABLE_HEADER,
208
+ RELEASE_CHECK_HEADER,
209
+ ];
210
+
211
+ /**
212
+ * The request headers to forward: the accepted version on `x-deco-version`,
213
+ * so the root layout can bind the draft on the same request (a layout gets no
87
214
  * `searchParams`, and on a draft link the cookie is only on the response).
88
- * A copy that arrives from outside is always removed.
215
+ * A copy that arrives from outside is always removed, and so are any
216
+ * `x-deco-visitor` and `x-deco-assignments` (`visitorRequestHeaders` and
217
+ * `assignmentsRequestHeaders` set the proxy's own).
89
218
  */
90
219
  export function draftRequestHeaders(
91
220
  request: NextRequest | Request,
92
221
  decision: DraftDecision,
93
222
  ): Headers {
94
223
  const headers = new Headers(request.headers);
95
- if (decision.pointer) headers.set(DRAFT_HEADER, decision.pointer);
96
- else headers.delete(DRAFT_HEADER);
224
+ for (const name of OWN_HEADERS) headers.delete(name);
225
+ if (decision.version) headers.set(VERSION_HEADER, decision.version.version);
226
+ if (decision.version && decision.releaseCheck) {
227
+ headers.set(RELEASE_CHECK_HEADER, decision.releaseCheck);
228
+ }
229
+ if (decision.unavailable) headers.set(DRAFT_UNAVAILABLE_HEADER, "1");
230
+ return headers;
231
+ }
232
+
233
+ /** The visitor of one request. */
234
+ export interface VisitorDecision {
235
+ /** Who is visiting: the `deco_segment` cookie's visitor, or a new one. */
236
+ visitor: Visitor;
237
+ /** The `deco_segment` `Set-Cookie`, on a first visit to the live site; `null` otherwise. */
238
+ setCookie: string | null;
239
+ }
240
+
241
+ /** Whether the visitor asked over https (`x-forwarded-proto` first, as a host in front reports it). */
242
+ function isHttps(request: NextRequest | Request): boolean {
243
+ const forwarded = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
244
+ if (forwarded) return forwarded === "https";
245
+ return new URL(request.url).protocol === "https:";
246
+ }
247
+
248
+ /**
249
+ * The visitor of a request (`readVisitor`: the `deco_segment` cookie, or a
250
+ * new visitor when it had no id) and the cookie to send back, only on a first
251
+ * visit and never from a preview (`draft` holds a version): Studio is not a
252
+ * visitor. Reads no content.
253
+ */
254
+ export function decideVisitor(
255
+ request: NextRequest | Request,
256
+ draft: DraftDecision,
257
+ ): VisitorDecision {
258
+ // `readVisitor` makes the cookie `Secure` unless the request came over plain http.
259
+ const { visitor, cookie } = readVisitor({
260
+ url: isHttps(request) ? "https://localhost/" : "http://localhost/",
261
+ headers: request.headers,
262
+ });
263
+ return { visitor, setCookie: draft.version === null ? cookie : null };
264
+ }
265
+
266
+ /**
267
+ * The forwarded headers with the visitor on `x-deco-visitor`. Pass it the
268
+ * headers `draftRequestHeaders` gave, which already removed a copy from
269
+ * outside.
270
+ */
271
+ export function visitorRequestHeaders(headers: Headers, decision: VisitorDecision): Headers {
272
+ const out = new Headers(headers);
273
+ out.set(VISITOR_HEADER, serializeVisitor(decision.visitor));
274
+ return out;
275
+ }
276
+
277
+ /** The `deco_segment` cookie on the response, on a first visit. */
278
+ export function applyVisitor<R extends Response>(response: R, decision: VisitorDecision): R {
279
+ if (decision.setCookie) response.headers.append("set-cookie", decision.setCookie);
280
+ return response;
281
+ }
282
+
283
+ /**
284
+ * Each test and campaign on or off for this request, decided once:
285
+ * `cms.client({ visitor, ...version, releaseCheck })` (the visitor, the Studio
286
+ * link's version, moment and switched tests and campaigns, and the request's
287
+ * release check) resolving the saved
288
+ * `experiments` and `campaigns` blocks. Missing blocks, or content that
289
+ * doesn't load, give none, never an error.
290
+ */
291
+ export function decideAssignments(
292
+ cms: Pick<CMS, "client">,
293
+ draft: DraftDecision,
294
+ visitor: VisitorDecision,
295
+ ): Promise<RequestAssignments> {
296
+ // A draft's read shares the request's release check (`decideDraft`), never one of its own.
297
+ const releaseCheck = draft.version ? draft.releaseCheck : undefined;
298
+ return readAssignmentsAndScope(cms, {
299
+ visitor: visitor.visitor,
300
+ ...draft.version,
301
+ ...(releaseCheck ? { releaseCheck } : {}),
302
+ }).then((read) => read.assignments);
303
+ }
304
+
305
+ /**
306
+ * The forwarded headers with the decided tests and campaigns on
307
+ * `x-deco-assignments` (`assignmentsSegment`), for `bindDraft` and
308
+ * `contentClient`. Pass it the headers `draftRequestHeaders` gave.
309
+ */
310
+ export function assignmentsRequestHeaders(
311
+ headers: Headers,
312
+ assignments: RequestAssignments,
313
+ ): Headers {
314
+ const out = new Headers(headers);
315
+ out.set(ASSIGNMENTS_HEADER, assignmentsSegment(assignments));
316
+ return out;
317
+ }
318
+
319
+ /** The request headers to forward: the version's and, when given, the visitor's and the assignments. */
320
+ function forwardedHeaders(
321
+ request: NextRequest | Request,
322
+ draft: DraftDecision,
323
+ visitor: VisitorDecision | undefined,
324
+ assignments: RequestAssignments | undefined,
325
+ ): Headers {
326
+ let headers = draftRequestHeaders(request, draft);
327
+ if (visitor !== undefined) headers = visitorRequestHeaders(headers, visitor);
328
+ if (assignments !== undefined) headers = assignmentsRequestHeaders(headers, assignments);
97
329
  return headers;
98
330
  }
99
331
 
@@ -103,62 +335,142 @@ export function isDraftRoutePath(pathname: string): boolean {
103
335
  }
104
336
 
105
337
  /**
106
- * 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.
338
+ * The rewrite of a preview request (a drafted one, or any other with a draft
339
+ * signal: `off`, a refused host, a draft that can't be read) onto the draft
340
+ * route, which is dynamic and never cached, keeping its path and forwarding
341
+ * the version and, when given, the visitor (`decideVisitor`) and the
342
+ * assignments (`decideAssignments`); `null` when the request isn't a preview
343
+ * or is already on the draft route.
109
344
  */
110
345
  export function rewriteToDraftRoute(
111
346
  request: NextRequest,
112
347
  decision: DraftDecision,
348
+ visitor?: VisitorDecision,
349
+ assignments?: RequestAssignments,
113
350
  ): NextResponse | null {
114
- if (!decision.pointer) return null;
351
+ if (!decision.version && !decision.preview) return null;
115
352
  const url = request.nextUrl.clone();
116
353
  if (isDraftRoutePath(url.pathname)) return null;
117
354
  url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
118
355
  return NextResponse.rewrite(url, {
119
- request: { headers: draftRequestHeaders(request, decision) },
356
+ request: { headers: forwardedHeaders(request, decision, visitor, assignments) },
357
+ });
358
+ }
359
+
360
+ /** An assignments-route response's `Cache-Control` when its content has tests or campaigns. */
361
+ const ASSIGNED_CACHE_CONTROL = "private, no-cache";
362
+
363
+ /**
364
+ * The rewrite of a published request onto the assignments route,
365
+ * `/_assignments/<assignments>/<path>`: each test on or off for the visitor
366
+ * and each campaign running or not now (`readAssignments` on the current
367
+ * release), as one path segment, so Next's own path-keyed cache (ISR, the
368
+ * full route cache) keeps one entry per set of assignments. Visitors on the
369
+ * same side of every test share a path; the visitor id is never in it, and
370
+ * the page reads neither the visitor nor the version (the proxy's headers are
371
+ * removed from the request it forwards), so Next can cache it. The page
372
+ * builds its client with `assignedClient` from `@decocms/nextjs/server`,
373
+ * `cms.client({ experiments, campaigns })` from the path: the proxy decides,
374
+ * the page renders that. A cached page (ISR, `revalidate = 600`) is about 10
375
+ * minutes stale (the first request after that still gets it while Next
376
+ * renders it again), a banner switching on late, which is accepted.
377
+ *
378
+ * `null` for a preview request (it renders on the draft route, never cached)
379
+ * and for a path already on the draft or assignments route. When the content
380
+ * saves tests or campaigns, the response is marked `Cache-Control: private,
381
+ * no-cache`: Next keeps one cached page per set of assignments, but the URL
382
+ * the visitor asked for answers by their cookie and the moment, so a CDN in
383
+ * front must not share it. Call it only for the paths the assignments route
384
+ * renders (the CMS's pages), and answer a direct hit on the route with a 404
385
+ * (`isAssignmentsRoutePath`).
386
+ */
387
+ export async function rewriteToAssignmentsRoute(
388
+ cms: Pick<CMS, "client">,
389
+ request: NextRequest,
390
+ draft: DraftDecision,
391
+ visitor: VisitorDecision,
392
+ ): Promise<NextResponse | null> {
393
+ if (draft.version || draft.preview) return null;
394
+ const url = request.nextUrl.clone();
395
+ if (isDraftRoutePath(url.pathname) || isAssignmentsRoutePath(url.pathname)) return null;
396
+ const { assignments, varies } = await readAssignmentsAndScope(cms, { visitor: visitor.visitor });
397
+ url.pathname = assignmentsRoutePath(url.pathname, assignments);
398
+ const response = NextResponse.rewrite(url, {
399
+ request: { headers: draftRequestHeaders(request, INERT) },
120
400
  });
401
+ // The URL the visitor asked for answers differently by their cookie and the moment: a
402
+ // shared cache in front (a CDN) must not keep Next's ISR `s-maxage` under it.
403
+ if (varies) response.headers.set("Cache-Control", ASSIGNED_CACHE_CONTROL);
404
+ return response;
121
405
  }
122
406
 
123
407
  /**
124
- * The cookie and the response headers of a draft: `Set-Cookie`, and for a
125
- * drafted response `Cache-Control: no-store, private`, `Vary: Cookie` and
408
+ * The cookie and the response headers of a preview: `Set-Cookie`, and for
409
+ * any request with a draft signal (`preview`, or a version)
410
+ * `Cache-Control: max-age=0, no-cache, must-revalidate`, `Vary: Cookie` and
126
411
  * `X-Robots-Tag: noindex, nofollow` (`draftResponseHeaders`). With the
127
- * pointer in a cookie a draft shares its URL with the published page, so a
128
- * cache keyed on the URL alone would serve it to visitors.
412
+ * version in a cookie a draft shares its URL with the published page, so a
413
+ * cache keyed on the URL alone would serve it to visitors. The proxy answers
414
+ * before the page renders, so it can't hold the cookie back from a failed
415
+ * render; `decideDraft` reads the draft first instead, and a draft that
416
+ * can't be read expires the cookie.
129
417
  */
130
418
  export function applyDraft<R extends Response>(response: R, decision: DraftDecision): R {
131
419
  if (decision.setCookie) response.headers.append("set-cookie", decision.setCookie);
132
- for (const [name, value] of Object.entries(draftResponseHeaders(decision.pointer))) {
420
+ const preview = decision.preview === true || decision.version !== null;
421
+ for (const [name, value] of Object.entries(draftResponseHeaders(preview))) {
133
422
  response.headers.set(name, value);
134
423
  }
135
424
  return response;
136
425
  }
137
426
 
138
- export interface DraftProxyOptions extends PreviewPolicy {
427
+ export interface DraftProxyOptions {
139
428
  /**
140
429
  * Which drafted requests go to the draft route (a statically rendered
141
430
  * page's). Default: every drafted request outside the draft route. Return
142
431
  * `false` for a route that is already dynamic and binds the draft itself.
143
432
  */
144
433
  rewrite?: (request: NextRequest) => boolean;
434
+ /**
435
+ * Which published requests render on the assignments route
436
+ * (`rewriteToAssignmentsRoute`): the CMS's pages, when the site mounts
437
+ * `app/%5Fassignments/[assignments]/[[...path]]`. Default: none. A direct
438
+ * hit on the route gets a 404 once this is set.
439
+ */
440
+ assignments?: (request: NextRequest) => boolean;
145
441
  }
146
442
 
147
443
  /**
148
- * The whole draft flow for a site with no proxy of its own:
149
- * `export const proxy = draftProxy(proxyCms, { site: "my-store" })`.
444
+ * The whole flow for a site with no proxy of its own:
445
+ * `export const proxy = draftProxy(proxyCms)`. Every
446
+ * request it forwards carries the version, the visitor and the assignments
447
+ * it decided.
150
448
  */
151
449
  export function draftProxy(
152
- cms: CMSHandle,
450
+ cms: CMSHandle & Pick<CMS, "client">,
153
451
  options: DraftProxyOptions = {},
154
452
  ): (request: NextRequest) => Promise<NextResponse> {
155
- const { rewrite = () => true, ...policy } = options;
453
+ const { rewrite = () => true, assignments } = options;
156
454
  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);
455
+ if (assignments && isAssignmentsRoutePath(request.nextUrl.pathname)) {
456
+ return new NextResponse(null, { status: 404 });
457
+ }
458
+ const decision = await decideDraft(cms, request);
459
+ const visitor = decideVisitor(request, decision);
460
+ const onAssignmentsRoute =
461
+ decision.version === null && decision.preview !== true && assignments?.(request) === true;
462
+ const rewritten = onAssignmentsRoute
463
+ ? await rewriteToAssignmentsRoute(cms, request, decision, visitor)
464
+ : null;
465
+ let response = rewritten;
466
+ if (response === null) {
467
+ const decided = await decideAssignments(cms, decision, visitor);
468
+ response =
469
+ (rewrite(request) ? rewriteToDraftRoute(request, decision, visitor, decided) : null) ??
470
+ NextResponse.next({
471
+ request: { headers: forwardedHeaders(request, decision, visitor, decided) },
472
+ });
473
+ }
474
+ return applyVisitor(applyDraft(response, decision), visitor);
163
475
  };
164
476
  }