@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/dist/server/index.js
CHANGED
|
@@ -2,27 +2,36 @@
|
|
|
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
|
*/
|
|
27
|
+
import { parseVisitor } from "@decocms/blocks/experiments";
|
|
22
28
|
import { headers } from "next/headers.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
29
|
+
import { ASSIGNMENTS_HEADER, parseAssignmentsSegment, withoutDarkTests, } from "../assignments.js";
|
|
30
|
+
import { DRAFT_PARAM, VERSION_HEADER } from "../draft.js";
|
|
31
|
+
import { allowedDraft, requestLike } from "../drafts.js";
|
|
32
|
+
import { VISITOR_HEADER } from "../visitor.js";
|
|
25
33
|
import { activeDraftPointer, draftSlot } from "./slot.js";
|
|
34
|
+
export { ASSIGNMENTS_ROUTE_PREFIX, assignmentsSegment, parseAssignmentsSegment, } from "../assignments.js";
|
|
26
35
|
export { requestLike } from "../drafts.js";
|
|
27
36
|
export { activeDraftPointer };
|
|
28
37
|
function firstParam(searchParams, key) {
|
|
@@ -30,35 +39,130 @@ function firstParam(searchParams, key) {
|
|
|
30
39
|
return (Array.isArray(raw) ? raw[0] : raw) ?? null;
|
|
31
40
|
}
|
|
32
41
|
/**
|
|
33
|
-
* Binds the request
|
|
34
|
-
*
|
|
35
|
-
* then the draft cookie, each on a host the
|
|
36
|
-
*
|
|
37
|
-
*
|
|
42
|
+
* Binds what the request reads: the version, if any (the page's own
|
|
43
|
+
* `?__draft=` first, `off` leaving preview, then the version the proxy
|
|
44
|
+
* forwarded on `x-deco-version`, then the draft cookie, each on a host the
|
|
45
|
+
* CMS allows), and what the proxy decided (`x-deco-assignments`) and the
|
|
46
|
+
* visitor it read (`x-deco-visitor`). Returns whether a version was bound:
|
|
47
|
+
* a draft, or a published release's commit SHA (Studio's "Preview this
|
|
48
|
+
* version"). It reads the request's headers, so it belongs on the draft
|
|
49
|
+
* route; a cached (ISR) page reads what its path names (`assignedClient`).
|
|
38
50
|
*/
|
|
39
51
|
export async function bindDraft(cms, options = {}) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
52
|
+
if (await bindVersion(cms, options)) {
|
|
53
|
+
// A preview renders per request anyway, and may switch on a test at 0%.
|
|
54
|
+
await bindForwarded(cms, false);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (await variesByVisitor(cms))
|
|
58
|
+
await bindForwarded(cms, true);
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
/** The visitor and the assignments the proxy forwarded, if any. */
|
|
62
|
+
async function bindForwarded(cms, release) {
|
|
63
|
+
const requestHeaders = await headers();
|
|
64
|
+
const slot = draftSlot();
|
|
65
|
+
slot.visitor = parseVisitor(requestHeaders.get(VISITOR_HEADER));
|
|
66
|
+
const decided = parseAssignmentsSegment(requestHeaders.get(ASSIGNMENTS_HEADER) ?? "");
|
|
67
|
+
slot.assignments = decided && release ? await withoutDarkTests(cms, decided) : decided;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Whether the release saves an `experiments` block (the built-in
|
|
71
|
+
* `split-traffic`) or a `campaigns` block (the built-in `calendar`) with a
|
|
72
|
+
* name in it: only then does a page vary by what the proxy decided. Read from
|
|
73
|
+
* memory, unresolved; `false` when both are missing.
|
|
74
|
+
*/
|
|
75
|
+
async function variesByVisitor(cms) {
|
|
76
|
+
const saves = async (name, type) => {
|
|
77
|
+
try {
|
|
78
|
+
const [block] = await cms.forRelease().resolve(name, { run: false });
|
|
79
|
+
if (typeof block !== "object" || block === null)
|
|
80
|
+
return false;
|
|
81
|
+
const record = block;
|
|
82
|
+
return (record.__resolveType === type && Object.keys(record).some((key) => key !== "__resolveType"));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const [tests, campaigns] = await Promise.all([
|
|
89
|
+
saves("experiments", "split-traffic"),
|
|
90
|
+
saves("campaigns", "calendar"),
|
|
91
|
+
]);
|
|
92
|
+
return tests || campaigns;
|
|
93
|
+
}
|
|
94
|
+
async function bindVersion(cms, options) {
|
|
95
|
+
const { searchParams } = options;
|
|
43
96
|
const param = firstParam(searchParams, DRAFT_PARAM);
|
|
44
97
|
if (param === "off")
|
|
45
98
|
return false;
|
|
46
99
|
const requestHeaders = await headers();
|
|
47
|
-
const forwarded = param ?? requestHeaders.get(
|
|
100
|
+
const forwarded = param ?? requestHeaders.get(VERSION_HEADER);
|
|
48
101
|
const query = forwarded ? `?${DRAFT_PARAM}=${encodeURIComponent(forwarded)}` : "";
|
|
49
102
|
const request = requestLike(requestHeaders, `/${query}`);
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
103
|
+
const version = request ? await allowedDraft(cms, request) : null;
|
|
104
|
+
if (version === null)
|
|
52
105
|
return false;
|
|
53
|
-
draftSlot().
|
|
106
|
+
draftSlot().version = version;
|
|
54
107
|
return true;
|
|
55
108
|
}
|
|
56
109
|
/**
|
|
57
|
-
* The content client for this request
|
|
58
|
-
*
|
|
110
|
+
* The content client for this request, from what `bindDraft` bound:
|
|
111
|
+
* `cms.client({ version, experiments, campaigns })` with the tests and
|
|
112
|
+
* campaigns the proxy decided, deciding nothing again. On a route the proxy
|
|
113
|
+
* didn't decide for, the visitor and the preview's moment and switched tests
|
|
114
|
+
* and campaigns decide instead. Create one per request.
|
|
59
115
|
*/
|
|
60
116
|
export function contentClient(cms) {
|
|
61
|
-
const
|
|
62
|
-
|
|
117
|
+
const { version, visitor, assignments } = draftSlot();
|
|
118
|
+
const options = { visitor, version: version?.version ?? null };
|
|
119
|
+
if (version?.date !== undefined)
|
|
120
|
+
options.date = version.date;
|
|
121
|
+
const experiments = over(version?.experiments, assignments?.experiments);
|
|
122
|
+
const campaigns = over(version?.campaigns, assignments?.campaigns);
|
|
123
|
+
if (experiments !== undefined)
|
|
124
|
+
options.experiments = experiments;
|
|
125
|
+
if (campaigns !== undefined)
|
|
126
|
+
options.campaigns = campaigns;
|
|
127
|
+
return cms.client(options);
|
|
128
|
+
}
|
|
129
|
+
/** `decided` over `preview`, name by name; `undefined` when neither has any. */
|
|
130
|
+
function over(preview, decided) {
|
|
131
|
+
if (preview === undefined && decided === undefined)
|
|
132
|
+
return undefined;
|
|
133
|
+
return { ...preview, ...decided };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The content client of a page on the assignments route
|
|
137
|
+
* (`app/%5Fassignments/[assignments]/[[...path]]`, which the proxy's
|
|
138
|
+
* `rewriteToAssignmentsRoute` rewrites the CMS's pages to): the current
|
|
139
|
+
* release, read as `cms.client({ experiments, campaigns })` with every test
|
|
140
|
+
* and campaign the path's segment names on or off as the proxy decided, and
|
|
141
|
+
* no visitor. The proxy decides, the page renders that, reading no request,
|
|
142
|
+
* so Next can cache it (ISR, `revalidate = 600`). A test at 0% stays off
|
|
143
|
+
* whatever the path names, and a test or a campaign the content doesn't have
|
|
144
|
+
* stays out, never an error: a 404 here would be cached under the path by
|
|
145
|
+
* Next. `null` only when the segment isn't one the proxy writes: the page
|
|
146
|
+
* answers with `notFound()`.
|
|
147
|
+
*
|
|
148
|
+
* ```tsx
|
|
149
|
+
* export const revalidate = 600;
|
|
150
|
+
* export async function generateStaticParams() {
|
|
151
|
+
* return [];
|
|
152
|
+
* }
|
|
153
|
+
* export default async function Page({ params }) {
|
|
154
|
+
* const { assignments, path } = await params;
|
|
155
|
+
* const client = await assignedClient(cms, assignments);
|
|
156
|
+
* if (!client) notFound();
|
|
157
|
+
* …
|
|
158
|
+
* }
|
|
159
|
+
* ```
|
|
160
|
+
*/
|
|
161
|
+
export async function assignedClient(cms, segment) {
|
|
162
|
+
const parsed = parseAssignmentsSegment(segment);
|
|
163
|
+
if (parsed === null)
|
|
164
|
+
return null;
|
|
165
|
+
const assignments = await withoutDarkTests(cms, parsed);
|
|
166
|
+
return cms.client({ experiments: assignments.experiments, campaigns: assignments.campaigns });
|
|
63
167
|
}
|
|
64
168
|
//# sourceMappingURL=index.js.map
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/server/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,6BAA6B,CAAC;AAC3D,OAAO,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAC1C,OAAO,EACL,kBAAkB,EAElB,uBAAuB,EACvB,gBAAgB,GACjB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAC1D,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAC/C,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAE1D,OAAO,EACL,wBAAwB,EACxB,kBAAkB,EAElB,uBAAuB,GACxB,MAAM,mBAAmB,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAC3C,OAAO,EAAE,kBAAkB,EAAE,CAAC;AAY9B,SAAS,UAAU,CAAC,YAAsC,EAAE,GAAW;IACrE,MAAM,GAAG,GAAG,YAAY,EAAE,CAAC,GAAG,CAAC,CAAC;IAChC,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC;AACrD,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,GAAY,EAAE,OAAO,GAAqB,EAAE;IAC1E,IAAI,MAAM,WAAW,CAAC,GAAG,EAAE,OAAO,CAAC,EAAE,CAAC;QACpC,wEAAwE;QACxE,MAAM,aAAa,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;QAChC,OAAO,IAAI,CAAC;IACd,CAAC;IACD,IAAI,MAAM,eAAe,CAAC,GAAG,CAAC;QAAE,MAAM,aAAa,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAC/D,OAAO,KAAK,CAAC;AACf,CAAC;AAED,mEAAmE;AACnE,KAAK,UAAU,aAAa,CAAC,GAAY,EAAE,OAAgB;IACzD,MAAM,cAAc,GAAG,MAAM,OAAO,EAAE,CAAC;IACvC,MAAM,IAAI,GAAG,SAAS,EAAE,CAAC;IACzB,IAAI,CAAC,OAAO,GAAG,YAAY,CAAC,cAAc,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC,CAAC;IAChE,MAAM,OAAO,GAAG,uBAAuB,CAAC,cAAc,CAAC,GAAG,CAAC,kBAAkB,CAAC,IAAI,EAAE,CAAC,CAAC;IACtF,IAAI,CAAC,WAAW,GAAG,OAAO,IAAI,OAAO,CAAC,CAAC,CAAC,MAAM,gBAAgB,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;AACzF,CAAC;AAED;;;;;GAKG;AACH,KAAK,UAAU,eAAe,CAAC,GAA4B;IACzD,MAAM,KAAK,GAAG,KAAK,EAAE,IAAY,EAAE,IAAY,EAAoB,EAAE;QACnE,IAAI,CAAC;YACH,MAAM,CAAC,KAAK,CAAC,GAAG,MAAM,GAAG,CAAC,UAAU,EAAE,CAAC,OAAO,CAAU,IAAI,EAAE,EAAE,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC;YAC9E,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI;gBAAE,OAAO,KAAK,CAAC;YAC9D,MAAM,MAAM,GAAG,KAAgC,CAAC;YAChD,OAAO,CACL,MAAM,CAAC,aAAa,KAAK,IAAI,IAAI,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,eAAe,CAAC,CAC5F,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC,CAAC;IACF,MAAM,CAAC,KAAK,EAAE,SAAS,CAAC,GAAG,MAAM,OAAO,CAAC,GAAG,CAAC;QAC3C,KAAK,CAAC,aAAa,EAAE,eAAe,CAAC;QACrC,KAAK,CAAC,WAAW,EAAE,UAAU,CAAC;KAC/B,CAAC,CAAC;IACH,OAAO,KAAK,IAAI,SAAS,CAAC;AAC5B,CAAC;AAED,KAAK,UAAU,WAAW,CAAC,GAAY,EAAE,OAAyB;IAChE,MAAM,EAAE,YAAY,EAAE,GAAG,OAAO,CAAC;IACjC,MAAM,KAAK,GAAG,UAAU,CAAC,YAAY,EAAE,WAAW,CAAC,CAAC;IACpD,IAAI,KAAK,KAAK,KAAK;QAAE,OAAO,KAAK,CAAC;IAClC,MAAM,cAAc,GAAG,MAAM,OAAO,EAAE,CAAC;IACvC,MAAM,SAAS,GAAG,KAAK,IAAI,cAAc,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;IAC9D,MAAM,KAAK,GAAG,SAAS,CAAC,CAAC,CAAC,IAAI,WAAW,IAAI,kBAAkB,CAAC,SAAS,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IAClF,MAAM,OAAO,GAAG,WAAW,CAAC,cAAyB,EAAE,IAAI,KAAK,EAAE,CAAC,CAAC;IACpE,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,MAAM,YAAY,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;IAClE,IAAI,OAAO,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IACnC,SAAS,EAAE,CAAC,OAAO,GAAG,OAAO,CAAC;IAC9B,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,aAAa,CAAC,GAAwB;IACpD,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,GAAG,SAAS,EAAE,CAAC;IACtD,MAAM,OAAO,GAAkB,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,EAAE,OAAO,IAAI,IAAI,EAAE,CAAC;IAC9E,IAAI,OAAO,EAAE,IAAI,KAAK,SAAS;QAAE,OAAO,CAAC,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;IAC7D,MAAM,WAAW,GAAG,IAAI,CAAC,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,WAAW,CAAC,CAAC;IACzE,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,EAAE,WAAW,EAAE,SAAS,CAAC,CAAC;IACnE,IAAI,WAAW,KAAK,SAAS;QAAE,OAAO,CAAC,WAAW,GAAG,WAAW,CAAC;IACjE,IAAI,SAAS,KAAK,SAAS;QAAE,OAAO,CAAC,SAAS,GAAG,SAAS,CAAC;IAC3D,OAAO,GAAG,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;AAC7B,CAAC;AAED,gFAAgF;AAChF,SAAS,IAAI,CACX,OAAsD,EACtD,OAAsD;IAEtD,IAAI,OAAO,KAAK,SAAS,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IACrE,OAAO,EAAE,GAAG,OAAO,EAAE,GAAG,OAAO,EAAE,CAAC;AACpC,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAuC,EACvC,OAAe;IAEf,MAAM,MAAM,GAAG,uBAAuB,CAAC,OAAO,CAAC,CAAC;IAChD,IAAI,MAAM,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACjC,MAAM,WAAW,GAAoB,MAAM,gBAAgB,CAAC,GAAG,EAAE,MAAM,CAAC,CAAC;IACzE,OAAO,GAAG,CAAC,MAAM,CAAC,EAAE,WAAW,EAAE,WAAW,CAAC,WAAW,EAAE,SAAS,EAAE,WAAW,CAAC,SAAS,EAAE,CAAC,CAAC;AAChG,CAAC"}
|
package/dist/server/slot.d.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
/**
|
|
2
|
+
* What a request renders, held for the request's Server Components: the
|
|
3
|
+
* version (a draft, or an old release, with its preview's moment and switched
|
|
4
|
+
* tests and campaigns), the visitor and the tests and campaigns the proxy
|
|
5
|
+
* decided. React's `cache()` gives one slot per request in the React Server
|
|
6
|
+
* Components graph, the scope a draft needs; no `next/headers` here, so the
|
|
7
|
+
* root components can read it without binding anything.
|
|
8
|
+
*/
|
|
9
|
+
import type { RequestVersion } from "@decocms/blocks";
|
|
10
|
+
import type { Visitor } from "@decocms/blocks/experiments";
|
|
11
|
+
import type { PathAssignments } from "../assignments.ts";
|
|
12
|
+
interface DraftSlot {
|
|
13
|
+
version: RequestVersion | null;
|
|
14
|
+
visitor: Visitor | null;
|
|
15
|
+
assignments: PathAssignments | null;
|
|
16
|
+
}
|
|
17
|
+
export declare const draftSlot: () => DraftSlot;
|
|
18
|
+
/** The version bound to this request (`bindDraft`), or `null` when it renders the current release. */
|
|
5
19
|
export declare function activeDraftPointer(): string | null;
|
|
20
|
+
export {};
|
|
6
21
|
//# sourceMappingURL=slot.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slot.d.ts","sourceRoot":"","sources":["../../src/server/slot.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"slot.d.ts","sourceRoot":"","sources":["../../src/server/slot.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,6BAA6B,CAAC;AAE3D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAEzD,UAAU,SAAS;IACjB,OAAO,EAAE,cAAc,GAAG,IAAI,CAAC;IAC/B,OAAO,EAAE,OAAO,GAAG,IAAI,CAAC;IACxB,WAAW,EAAE,eAAe,GAAG,IAAI,CAAC;CACrC;AAED,eAAO,MAAM,SAAS,QAChB,SACL,CAAC;AAEF,sGAAsG;AACtG,wBAAgB,kBAAkB,IAAI,MAAM,GAAG,IAAI,CAElD"}
|
package/dist/server/slot.js
CHANGED
|
@@ -1,13 +1,7 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The draft a request renders, held for the request's Server Components.
|
|
3
|
-
* React's `cache()` gives one slot per request in the React Server
|
|
4
|
-
* Components graph, the scope a draft needs; no `next/headers` here, so the
|
|
5
|
-
* root components can read it without binding anything.
|
|
6
|
-
*/
|
|
7
1
|
import { cache } from "react";
|
|
8
|
-
export const draftSlot = cache(() => ({
|
|
9
|
-
/** The
|
|
2
|
+
export const draftSlot = cache(() => ({ version: null, visitor: null, assignments: null }));
|
|
3
|
+
/** The version bound to this request (`bindDraft`), or `null` when it renders the current release. */
|
|
10
4
|
export function activeDraftPointer() {
|
|
11
|
-
return draftSlot().
|
|
5
|
+
return draftSlot().version?.version ?? null;
|
|
12
6
|
}
|
|
13
7
|
//# sourceMappingURL=slot.js.map
|
package/dist/server/slot.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slot.js","sourceRoot":"","sources":["../../src/server/slot.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"slot.js","sourceRoot":"","sources":["../../src/server/slot.ts"],"names":[],"mappings":"AAUA,OAAO,EAAE,KAAK,EAAE,MAAM,OAAO,CAAC;AAS9B,MAAM,CAAC,MAAM,SAAS,GAAG,KAAK,CAC5B,GAAc,EAAE,CAAC,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CACvE,CAAC;AAEF,sGAAsG;AACtG,MAAM,UAAU,kBAAkB;IAChC,OAAO,SAAS,EAAE,CAAC,OAAO,EAAE,OAAO,IAAI,IAAI,CAAC;AAC9C,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the proxy hands the pages the visitor: the request header it forwards
|
|
3
|
+
* once it read the visitor (`readVisitor` from `@decocms/blocks/experiments`,
|
|
4
|
+
* a new one on a first visit). The proxy writes it (`visitorRequestHeaders`
|
|
5
|
+
* from `@decocms/nextjs/proxy`); the pages read it (`bindDraft`, then
|
|
6
|
+
* `contentClient`, from `@decocms/nextjs/server`). The package's own copy: it
|
|
7
|
+
* never imports `@decocms/tanstack`. No imports, no state: safe in every
|
|
8
|
+
* bundle.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The request header carrying the visitor, in its stored form (the
|
|
12
|
+
* `deco_segment` cookie's value, `serializeVisitor`). The proxy removes any
|
|
13
|
+
* copy that arrives from outside (`draftRequestHeaders`).
|
|
14
|
+
*/
|
|
15
|
+
export declare const VISITOR_HEADER = "x-deco-visitor";
|
|
16
|
+
//# sourceMappingURL=visitor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"visitor.d.ts","sourceRoot":"","sources":["../src/visitor.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,eAAO,MAAM,cAAc,mBAAmB,CAAC"}
|
package/dist/visitor.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How the proxy hands the pages the visitor: the request header it forwards
|
|
3
|
+
* once it read the visitor (`readVisitor` from `@decocms/blocks/experiments`,
|
|
4
|
+
* a new one on a first visit). The proxy writes it (`visitorRequestHeaders`
|
|
5
|
+
* from `@decocms/nextjs/proxy`); the pages read it (`bindDraft`, then
|
|
6
|
+
* `contentClient`, from `@decocms/nextjs/server`). The package's own copy: it
|
|
7
|
+
* never imports `@decocms/tanstack`. No imports, no state: safe in every
|
|
8
|
+
* bundle.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* The request header carrying the visitor, in its stored form (the
|
|
12
|
+
* `deco_segment` cookie's value, `serializeVisitor`). The proxy removes any
|
|
13
|
+
* copy that arrives from outside (`draftRequestHeaders`).
|
|
14
|
+
*/
|
|
15
|
+
export const VISITOR_HEADER = "x-deco-visitor";
|
|
16
|
+
//# sourceMappingURL=visitor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"visitor.js","sourceRoot":"","sources":["../src/visitor.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,gBAAgB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decocms/nextjs",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=24"
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"typecheck": "node ../../scripts/tsc.mjs -p tsconfig.json"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
|
-
"@decocms/blocks": "^8.
|
|
59
|
+
"@decocms/blocks": "^8.6.0",
|
|
60
60
|
"@opentelemetry/api": "^1.0.0",
|
|
61
61
|
"next": "^16.0.0",
|
|
62
62
|
"react": "^19.0.0"
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
|
-
"@decocms/blocks": "8.
|
|
70
|
+
"@decocms/blocks": "8.6.0",
|
|
71
71
|
"@opentelemetry/api": "^1.9.1",
|
|
72
72
|
"@types/react": "^19.0.0",
|
|
73
73
|
"@types/react-dom": "^19.0.0",
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request's assignments: what its content gives it, decided once, in the
|
|
3
|
+
* proxy, and handed to the page. The version it reads (a Studio link's draft
|
|
4
|
+
* or release SHA, `null` for the current release), each test of the saved
|
|
5
|
+
* `experiments` block on or off for its visitor, and each campaign of the
|
|
6
|
+
* saved `campaigns` block running or not at its date, with a preview's
|
|
7
|
+
* switched tests and campaigns on top. The page builds
|
|
8
|
+
* `cms.client({ version, experiments, campaigns })` from them, a client that
|
|
9
|
+
* decides nothing again.
|
|
10
|
+
*
|
|
11
|
+
* Two requests with the same assignments render the same content, so they
|
|
12
|
+
* share a cache entry: `assignmentsKey` for a cache keyed on a string,
|
|
13
|
+
* `assignmentsSegment` for Next's own path-keyed cache. The visitor id itself
|
|
14
|
+
* is never in either, or every visitor would miss. The binding caches nothing
|
|
15
|
+
* itself.
|
|
16
|
+
*
|
|
17
|
+
* The same reading as `@decocms/tanstack/worker`'s.
|
|
18
|
+
*/
|
|
19
|
+
import type { ClientOptions, CMS } from "@decocms/blocks";
|
|
20
|
+
import type { Assignments } from "@decocms/blocks/experiments";
|
|
21
|
+
|
|
22
|
+
export interface RequestAssignments {
|
|
23
|
+
/** The version read, `null` for the current release. */
|
|
24
|
+
version: string | null;
|
|
25
|
+
/** Each test of the saved `experiments` block: on or off. */
|
|
26
|
+
experiments: Readonly<Assignments>;
|
|
27
|
+
/** Each campaign of the saved `campaigns` block: running or not. */
|
|
28
|
+
campaigns: Readonly<Assignments>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The request header the proxy hands the pages the assignments it decided on
|
|
33
|
+
* (`assignmentsSegment`), for `bindDraft` and `contentClient`. The proxy
|
|
34
|
+
* removes any copy that arrives from outside.
|
|
35
|
+
*/
|
|
36
|
+
export const ASSIGNMENTS_HEADER = "x-deco-assignments";
|
|
37
|
+
|
|
38
|
+
/** What a page's path and the decisions header carry: the tests and campaigns, never the version or the visitor. */
|
|
39
|
+
export type PathAssignments = Pick<RequestAssignments, "experiments" | "campaigns">;
|
|
40
|
+
|
|
41
|
+
const EXPERIMENTS = { block: "experiments", type: "split-traffic" } as const;
|
|
42
|
+
const CAMPAIGNS = { block: "campaigns", type: "calendar" } as const;
|
|
43
|
+
|
|
44
|
+
type Reader = ReturnType<CMS["client"]>;
|
|
45
|
+
|
|
46
|
+
/** Plain code-unit order, the same on every server and locale. */
|
|
47
|
+
const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
|
|
48
|
+
|
|
49
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
50
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function record(): Assignments {
|
|
54
|
+
return Object.create(null) as Assignments;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the content saves a block of `type`: listing never fails on a
|
|
59
|
+
* missing block, so a site without tests or campaigns reports no error.
|
|
60
|
+
*/
|
|
61
|
+
async function saves(client: Reader, type: string): Promise<boolean> {
|
|
62
|
+
const [list] = await client.list(type);
|
|
63
|
+
return (list?.length ?? 0) > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The saved `block` resolved, each name `true` or `false`; `null` when the content saves none. */
|
|
67
|
+
async function decided(
|
|
68
|
+
client: Reader,
|
|
69
|
+
spec: { block: string; type: string },
|
|
70
|
+
): Promise<Assignments | null> {
|
|
71
|
+
try {
|
|
72
|
+
if (!(await saves(client, spec.type))) return null;
|
|
73
|
+
const [value] = await client.resolve<unknown>(spec.block);
|
|
74
|
+
const out = record();
|
|
75
|
+
if (!isRecord(value)) return out;
|
|
76
|
+
for (const name of Object.keys(value).sort(byCodeUnit)) out[name] = value[name] === true;
|
|
77
|
+
return out;
|
|
78
|
+
} catch {
|
|
79
|
+
return record();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The assignments of one request: `cms.client(options)` (the version, the
|
|
85
|
+
* visitor, the date and a preview's switched tests and campaigns, as
|
|
86
|
+
* `cms.readVersion` reads them) resolving `experiments` and `campaigns`.
|
|
87
|
+
* Missing blocks (or content that doesn't load) give none, never an error.
|
|
88
|
+
*/
|
|
89
|
+
export async function readAssignments(
|
|
90
|
+
cms: Pick<CMS, "client">,
|
|
91
|
+
options: ClientOptions,
|
|
92
|
+
): Promise<RequestAssignments> {
|
|
93
|
+
return (await readAssignmentsAndScope(cms, options)).assignments;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* `readAssignments`, and whether its page can differ by them: `true` when the
|
|
98
|
+
* content saves tests or campaigns (or couldn't be read, to stay safe), so a
|
|
99
|
+
* response keyed on them must never be shared by its URL alone.
|
|
100
|
+
*/
|
|
101
|
+
export async function readAssignmentsAndScope(
|
|
102
|
+
cms: Pick<CMS, "client">,
|
|
103
|
+
options: ClientOptions,
|
|
104
|
+
): Promise<{ assignments: RequestAssignments; varies: boolean }> {
|
|
105
|
+
const version = typeof options.version === "string" ? options.version : null;
|
|
106
|
+
const none: RequestAssignments = { version, experiments: record(), campaigns: record() };
|
|
107
|
+
let client: Reader;
|
|
108
|
+
try {
|
|
109
|
+
client = cms.client(options);
|
|
110
|
+
} catch {
|
|
111
|
+
return { assignments: none, varies: true };
|
|
112
|
+
}
|
|
113
|
+
const [experiments, campaigns] = await Promise.all([
|
|
114
|
+
decided(client, EXPERIMENTS),
|
|
115
|
+
decided(client, CAMPAIGNS),
|
|
116
|
+
]);
|
|
117
|
+
return {
|
|
118
|
+
assignments: {
|
|
119
|
+
version,
|
|
120
|
+
experiments: experiments ?? none.experiments,
|
|
121
|
+
campaigns: campaigns ?? none.campaigns,
|
|
122
|
+
},
|
|
123
|
+
varies: experiments !== null || campaigns !== null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The names that are on, sorted. */
|
|
128
|
+
function onNames(assignments: Readonly<Assignments>): string[] {
|
|
129
|
+
return Object.keys(assignments)
|
|
130
|
+
.filter((name) => assignments[name] === true)
|
|
131
|
+
.sort(byCodeUnit);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The assignments as one stable string for a cache key:
|
|
136
|
+
* `v=<version>|x=<tests on>|c=<campaigns running>`, names sorted and
|
|
137
|
+
* URL-encoded, empty parts left out; `""` for none. The names that are off
|
|
138
|
+
* aren't in it: within one content revision they are the rest.
|
|
139
|
+
*/
|
|
140
|
+
export function assignmentsKey(assignments: RequestAssignments): string {
|
|
141
|
+
const list = (names: string[]) => names.map(encodeURIComponent).join(",");
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
if (assignments.version) parts.push(`v=${encodeURIComponent(assignments.version)}`);
|
|
144
|
+
const tests = onNames(assignments.experiments);
|
|
145
|
+
const campaigns = onNames(assignments.campaigns);
|
|
146
|
+
if (tests.length) parts.push(`x=${list(tests)}`);
|
|
147
|
+
if (campaigns.length) parts.push(`c=${list(campaigns)}`);
|
|
148
|
+
return parts.join("|");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* The route a page renders on under the assignments its proxy made:
|
|
153
|
+
* `/_assignments/<assignments>/<path>`, mounted as `app/%5Fassignments/[assignments]/[[...path]]`
|
|
154
|
+
* (a literal `_assignments` folder is private in Next and never routes). The proxy
|
|
155
|
+
* rewrites to it (`rewriteToAssignmentsRoute`) and answers a direct hit with
|
|
156
|
+
* its 404, so a visitor can't pick the assignments by typing a path.
|
|
157
|
+
*/
|
|
158
|
+
export const ASSIGNMENTS_ROUTE_PREFIX = "/_assignments";
|
|
159
|
+
|
|
160
|
+
/** The segment for content with no test and no campaign. */
|
|
161
|
+
const NONE = "-";
|
|
162
|
+
|
|
163
|
+
function toBase64Url(text: string): string {
|
|
164
|
+
let binary = "";
|
|
165
|
+
for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);
|
|
166
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function fromBase64Url(segment: string): string | null {
|
|
170
|
+
if (!/^[A-Za-z0-9_-]+$/.test(segment)) return null;
|
|
171
|
+
try {
|
|
172
|
+
const binary = atob(segment.replace(/-/g, "+").replace(/_/g, "/"));
|
|
173
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
174
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** `+name` on, `-name` off, names URL-encoded and sorted. */
|
|
181
|
+
function encodeStates(states: Readonly<Assignments>): string {
|
|
182
|
+
return Object.keys(states)
|
|
183
|
+
.sort(byCodeUnit)
|
|
184
|
+
.map((name) => `${states[name] === true ? "+" : "-"}${encodeURIComponent(name)}`)
|
|
185
|
+
.join(",");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The tests and campaigns as one path segment, every name with its state
|
|
190
|
+
* (`x=+on,-off|c=+running,-not`, base64url-encoded so Next passes it to the
|
|
191
|
+
* page unchanged), for Next's own path-keyed cache (ISR, the full route
|
|
192
|
+
* cache) and for the decisions header (`ASSIGNMENTS_HEADER`): `-` for none.
|
|
193
|
+
* The same assignments always give the same segment; the visitor is never in
|
|
194
|
+
* it.
|
|
195
|
+
*/
|
|
196
|
+
export function assignmentsSegment(assignments: PathAssignments): string {
|
|
197
|
+
const parts: string[] = [];
|
|
198
|
+
const tests = encodeStates(assignments.experiments);
|
|
199
|
+
const campaigns = encodeStates(assignments.campaigns);
|
|
200
|
+
if (tests) parts.push(`x=${tests}`);
|
|
201
|
+
if (campaigns) parts.push(`c=${campaigns}`);
|
|
202
|
+
return parts.length === 0 ? NONE : toBase64Url(parts.join("|"));
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The tests and campaigns a segment carries, or `null` when it isn't one
|
|
207
|
+
* `assignmentsSegment` writes (malformed, or the same assignments spelled
|
|
208
|
+
* another way), so each set of assignments has exactly one path.
|
|
209
|
+
*/
|
|
210
|
+
export function parseAssignmentsSegment(segment: string): PathAssignments | null {
|
|
211
|
+
if (segment === NONE) return { experiments: record(), campaigns: record() };
|
|
212
|
+
const key = fromBase64Url(segment);
|
|
213
|
+
if (key === null) return null;
|
|
214
|
+
const parsed = { experiments: record(), campaigns: record() };
|
|
215
|
+
const seen = new Set<string>();
|
|
216
|
+
try {
|
|
217
|
+
for (const part of key.split("|")) {
|
|
218
|
+
const eq = part.indexOf("=");
|
|
219
|
+
const tag = part.slice(0, eq);
|
|
220
|
+
const list = part.slice(eq + 1);
|
|
221
|
+
if (eq <= 0 || list === "" || seen.has(tag) || (tag !== "x" && tag !== "c")) return null;
|
|
222
|
+
seen.add(tag);
|
|
223
|
+
const target = tag === "x" ? parsed.experiments : parsed.campaigns;
|
|
224
|
+
for (const entry of list.split(",")) {
|
|
225
|
+
const state = entry[0];
|
|
226
|
+
if (state !== "+" && state !== "-") return null;
|
|
227
|
+
target[decodeURIComponent(entry.slice(1))] = state === "+";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
return assignmentsSegment(parsed) === segment ? parsed : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** `/_assignments/<segment><pathname>` (the bare prefix and segment for `/`). */
|
|
237
|
+
export function assignmentsRoutePath(pathname: string, assignments: PathAssignments): string {
|
|
238
|
+
const base = `${ASSIGNMENTS_ROUTE_PREFIX}/${assignmentsSegment(assignments)}`;
|
|
239
|
+
return pathname === "/" || pathname === "" ? base : `${base}${pathname}`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Whether `pathname` is on the assignments route: a site's proxy answers a direct hit there with its 404. */
|
|
243
|
+
export function isAssignmentsRoutePath(pathname: string): boolean {
|
|
244
|
+
return (
|
|
245
|
+
pathname === ASSIGNMENTS_ROUTE_PREFIX || pathname.startsWith(`${ASSIGNMENTS_ROUTE_PREFIX}/`)
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The tests a path or header names, as a page renders them on the current
|
|
251
|
+
* release: a test at 0% stays off whatever it says, so a forged path or
|
|
252
|
+
* header can't switch on a test nobody is in (a preview, on its own version,
|
|
253
|
+
* may). A name the content doesn't have stays out anyway (`cms.client`
|
|
254
|
+
* keeps only the block's names). Read from memory, unresolved.
|
|
255
|
+
*/
|
|
256
|
+
export async function withoutDarkTests(
|
|
257
|
+
cms: Pick<CMS, "forRelease">,
|
|
258
|
+
assignments: PathAssignments,
|
|
259
|
+
): Promise<PathAssignments> {
|
|
260
|
+
const on = Object.keys(assignments.experiments).filter(
|
|
261
|
+
(name) => assignments.experiments[name] === true,
|
|
262
|
+
);
|
|
263
|
+
if (on.length === 0) return assignments;
|
|
264
|
+
let saved: Record<string, unknown> = {};
|
|
265
|
+
try {
|
|
266
|
+
const [block] = await cms.forRelease().resolve<unknown>(EXPERIMENTS.block, { run: false });
|
|
267
|
+
if (isRecord(block) && block.__resolveType === EXPERIMENTS.type) saved = block;
|
|
268
|
+
} catch {
|
|
269
|
+
// No experiments block: every test the client is given stays out.
|
|
270
|
+
}
|
|
271
|
+
const experiments = record();
|
|
272
|
+
for (const name of Object.keys(assignments.experiments)) {
|
|
273
|
+
const pct = Object.hasOwn(saved, name) ? saved[name] : undefined;
|
|
274
|
+
const dark = typeof pct !== "number" || !Number.isFinite(pct) || Math.round(pct) <= 0;
|
|
275
|
+
experiments[name] = assignments.experiments[name] === true && !dark;
|
|
276
|
+
}
|
|
277
|
+
return { experiments, campaigns: assignments.campaigns };
|
|
278
|
+
}
|
package/src/draft.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The draft names and headers of a Deco Next.js site: the `?__draft=` link,
|
|
3
3
|
* the cookie that keeps a preview going, the request header the proxy hands
|
|
4
|
-
* the pages, and the response headers of a page rendered from a draft. The
|
|
4
|
+
* the pages the version on, and the response headers of a page rendered from a draft. The
|
|
5
5
|
* proxy reads and writes them (`./drafts.ts`, `./proxy`); the pages read the
|
|
6
6
|
* header (`./server`); `./config/index.cjs` repeats them (`config.test.ts`
|
|
7
7
|
* holds them equal). This package's own copy: it never imports
|
|
@@ -23,11 +23,13 @@ export const DRAFT_COOKIE = "__deco_draft";
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* The request header the proxy sets on the request it hands the pages once it
|
|
26
|
-
* accepted
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* accepted a Studio link: the version (a draft pointer or a release's commit
|
|
27
|
+
* SHA), already checked, without the preview's moment and switched tests
|
|
28
|
+
* (those are decided in the proxy: `ASSIGNMENTS_HEADER`). The proxy removes
|
|
29
|
+
* any copy that arrives from outside, and `bindDraft` repeats the host check,
|
|
30
|
+
* so a client can't send one itself.
|
|
29
31
|
*/
|
|
30
|
-
export const
|
|
32
|
+
export const VERSION_HEADER = "x-deco-version";
|
|
31
33
|
|
|
32
34
|
/** The URL prefix a drafted request is rewritten onto (`app/%5Fdraft/[[...path]]`). */
|
|
33
35
|
export const DRAFT_ROUTE_PREFIX = "/_draft";
|
|
@@ -35,11 +37,11 @@ export const DRAFT_ROUTE_PREFIX = "/_draft";
|
|
|
35
37
|
/**
|
|
36
38
|
* The response headers of a page rendered from a draft: never stored by a
|
|
37
39
|
* browser or a CDN (`Cache-Control: no-store, private`, `Vary: Cookie`) and
|
|
38
|
-
* never indexed (`X-Robots-Tag: noindex, nofollow`). `{}` when `
|
|
40
|
+
* never indexed (`X-Robots-Tag: noindex, nofollow`). `{}` when `version` is
|
|
39
41
|
* `null`, so the result of the draft decision applies as is.
|
|
40
42
|
*/
|
|
41
|
-
export function draftResponseHeaders(
|
|
42
|
-
if (!
|
|
43
|
+
export function draftResponseHeaders(version: string | null | undefined): Record<string, string> {
|
|
44
|
+
if (!version) return {};
|
|
43
45
|
return {
|
|
44
46
|
"Cache-Control": "no-store, private",
|
|
45
47
|
Vary: "Cookie",
|