@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/proxy/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `@decocms/nextjs/proxy`: draft previews
|
|
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`,
|
|
14
|
-
* `
|
|
15
|
-
*
|
|
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
|
-
*
|
|
18
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
47
|
-
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
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 {
|
|
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({
|
|
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
|
|
63
|
-
*
|
|
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 {
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
setCookie:
|
|
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
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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.
|
|
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:
|
|
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
|
|
125
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
149
|
-
* `export const proxy = draftProxy(proxyCms
|
|
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,
|
|
453
|
+
const { rewrite = () => true, assignments } = options;
|
|
156
454
|
return async (request) => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|