@decocms/nextjs 8.5.1 → 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.
- package/README.md +81 -14
- 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 +9 -7
- package/dist/draft.d.ts.map +1 -1
- package/dist/draft.js +10 -8
- package/dist/draft.js.map +1 -1
- package/dist/drafts.d.ts +29 -60
- package/dist/drafts.d.ts.map +1 -1
- package/dist/drafts.js +38 -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 +134 -30
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +169 -37
- 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 +129 -25
- package/dist/server/index.js.map +1 -1
- package/dist/server/slot.d.ts +19 -4
- package/dist/server/slot.d.ts.map +1 -1
- package/dist/server/slot.js +3 -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/assignments.ts +278 -0
- package/src/draft.ts +10 -8
- package/src/drafts.ts +38 -126
- package/src/index.ts +5 -2
- package/src/instrumentation/index.ts +3 -3
- package/src/proxy/index.ts +269 -56
- package/src/server/index.ts +152 -33
- package/src/server/slot.ts +18 -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,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`,
|
|
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 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 {
|
|
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
|
|
47
|
-
export type CMSHandle = Pick<CMS, "
|
|
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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
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 {
|
|
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({
|
|
104
|
+
const INERT: DraftDecision = Object.freeze({ version: null, setCookie: null });
|
|
60
105
|
|
|
61
106
|
/**
|
|
62
|
-
* The
|
|
63
|
-
*
|
|
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 {
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
108
|
-
*
|
|
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.
|
|
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:
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
149
|
-
* `export const proxy = draftProxy(proxyCms
|
|
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,
|
|
355
|
+
const { rewrite = () => true, assignments } = options;
|
|
156
356
|
return async (request) => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* The proxy decides what a request reads (`@decocms/nextjs/proxy`): the
|
|
6
|
+
* version, the visitor, and each test and campaign on or off, forwarded on
|
|
7
|
+
* `x-deco-version`, `x-deco-visitor` and `x-deco-assignments`. A page (and,
|
|
8
|
+
* through those headers, the root layout) binds them before it reads
|
|
9
|
+
* content, then reads through `contentClient(cms)`, which builds
|
|
10
|
+
* `cms.client({ version, experiments, campaigns })` and decides nothing
|
|
11
|
+
* again:
|
|
8
12
|
*
|
|
9
13
|
* ```tsx
|
|
10
14
|
* export default async function Page({ searchParams }) {
|
|
11
|
-
* await bindDraft(cms, { searchParams: await searchParams
|
|
15
|
+
* await bindDraft(cms, { searchParams: await searchParams });
|
|
12
16
|
* const client = contentClient(cms);
|
|
13
17
|
* …
|
|
14
18
|
* return <>{sections}<DraftPreviewIndicator /></>;
|
|
15
19
|
* }
|
|
16
20
|
* ```
|
|
17
21
|
*
|
|
18
|
-
* The host check is the proxy's (`decideDraft`), repeated here
|
|
19
|
-
*
|
|
20
|
-
* still gets the release on a host that may not preview.
|
|
22
|
+
* The host check is the proxy's (`decideDraft`), repeated here against the
|
|
23
|
+
* same CMS, so a route the proxy doesn't match, or a forged `x-deco-version`,
|
|
24
|
+
* still gets the release on a host that may not preview. On the release, a
|
|
25
|
+
* test at 0% stays off whatever `x-deco-assignments` says.
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
|
-
import type { Client, CMS } from "@decocms/blocks";
|
|
28
|
+
import type { Client, ClientOptions, CMS } from "@decocms/blocks";
|
|
29
|
+
import { parseVisitor } from "@decocms/blocks/experiments";
|
|
24
30
|
import { headers } from "next/headers.js";
|
|
25
|
-
import {
|
|
26
|
-
|
|
31
|
+
import {
|
|
32
|
+
ASSIGNMENTS_HEADER,
|
|
33
|
+
type PathAssignments,
|
|
34
|
+
parseAssignmentsSegment,
|
|
35
|
+
withoutDarkTests,
|
|
36
|
+
} from "../assignments.ts";
|
|
37
|
+
import { 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 {
|
|
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
|
|
36
|
-
/** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-
|
|
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
|
|
47
|
-
*
|
|
48
|
-
* then the draft cookie, each on a host the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
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
|
|
65
|
-
if (
|
|
66
|
-
draftSlot().
|
|
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
|
|
72
|
-
*
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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
|
}
|