@decocms/nextjs 8.5.2 → 8.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +82 -15
  2. package/dist/DraftPreviewIndicator.d.ts.map +1 -1
  3. package/dist/DraftPreviewIndicator.js +4 -3
  4. package/dist/DraftPreviewIndicator.js.map +1 -1
  5. package/dist/assignments.d.ts +95 -0
  6. package/dist/assignments.d.ts.map +1 -0
  7. package/dist/assignments.js +229 -0
  8. package/dist/assignments.js.map +1 -0
  9. package/dist/draft.d.ts +36 -10
  10. package/dist/draft.d.ts.map +1 -1
  11. package/dist/draft.js +38 -12
  12. package/dist/draft.js.map +1 -1
  13. package/dist/drafts.d.ts +39 -60
  14. package/dist/drafts.d.ts.map +1 -1
  15. package/dist/drafts.js +58 -117
  16. package/dist/drafts.js.map +1 -1
  17. package/dist/index.d.ts +5 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +5 -2
  20. package/dist/index.js.map +1 -1
  21. package/dist/instrumentation/index.d.ts +1 -1
  22. package/dist/instrumentation/index.js +3 -3
  23. package/dist/instrumentation/index.js.map +1 -1
  24. package/dist/proxy/index.d.ts +175 -35
  25. package/dist/proxy/index.d.ts.map +1 -1
  26. package/dist/proxy/index.js +242 -41
  27. package/dist/proxy/index.js.map +1 -1
  28. package/dist/server/index.d.ts +59 -20
  29. package/dist/server/index.d.ts.map +1 -1
  30. package/dist/server/index.js +143 -25
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/slot.d.ts +25 -4
  33. package/dist/server/slot.d.ts.map +1 -1
  34. package/dist/server/slot.js +13 -9
  35. package/dist/server/slot.js.map +1 -1
  36. package/dist/visitor.d.ts +16 -0
  37. package/dist/visitor.d.ts.map +1 -0
  38. package/dist/visitor.js +16 -0
  39. package/dist/visitor.js.map +1 -0
  40. package/package.json +3 -3
  41. package/src/DraftPreviewIndicator.tsx +4 -3
  42. package/src/assignments.ts +278 -0
  43. package/src/config/index.cjs +1 -1
  44. package/src/draft.ts +43 -12
  45. package/src/drafts.ts +71 -126
  46. package/src/index.ts +5 -2
  47. package/src/instrumentation/index.ts +3 -3
  48. package/src/proxy/index.ts +371 -59
  49. package/src/server/index.ts +173 -33
  50. package/src/server/slot.ts +33 -5
  51. package/src/visitor.ts +16 -0
@@ -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
+ }
@@ -25,7 +25,7 @@
25
25
 
26
26
  /** `draftResponseHeaders(pointer)` (`src/draft.ts`), as `next.config` header entries. */
27
27
  const DRAFT_NO_STORE_HEADERS = [
28
- { key: "Cache-Control", value: "no-store, private" },
28
+ { key: "Cache-Control", value: "max-age=0, no-cache, must-revalidate" },
29
29
  { key: "Vary", value: "Cookie" },
30
30
  { key: "X-Robots-Tag", value: "noindex, nofollow" },
31
31
  ];
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,25 +23,56 @@ 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 the draft: the pointer, already checked. The proxy removes any
27
- * copy that arrives from outside, and `bindDraft` repeats the host check, so a
28
- * client can't send one itself.
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 DRAFT_HEADER = "x-deco-draft";
32
+ export const VERSION_HEADER = "x-deco-version";
33
+
34
+ /**
35
+ * The request header the proxy sets on a draft request it hands the pages:
36
+ * the id of the release check it made for it (`cms.checkRelease()`), so
37
+ * `bindDraft`'s host check and the page's draft reads wait for that check
38
+ * instead of making their own (one `latest.json` read per preview request).
39
+ * The proxy removes any copy that arrives from outside; an id the CMS didn't
40
+ * make (a proxy in another process) is a check of its own anyway.
41
+ */
42
+ export const RELEASE_CHECK_HEADER = "x-deco-release-check";
31
43
 
32
44
  /** The URL prefix a drafted request is rewritten onto (`app/%5Fdraft/[[...path]]`). */
33
45
  export const DRAFT_ROUTE_PREFIX = "/_draft";
34
46
 
35
47
  /**
36
- * The response headers of a page rendered from a draft: never stored by a
37
- * browser or a CDN (`Cache-Control: no-store, private`, `Vary: Cookie`) and
38
- * never indexed (`X-Robots-Tag: noindex, nofollow`). `{}` when `pointer` is
39
- * `null`, so the result of the draft decision applies as is.
48
+ * The request header the proxy sets when the link named a draft that can't be
49
+ * read anymore (deleted, say): the pages render the release and
50
+ * `DraftPreviewIndicator` says so calmly. The proxy removes any copy that
51
+ * arrives from outside.
52
+ */
53
+ export const DRAFT_UNAVAILABLE_HEADER = "x-deco-draft-unavailable";
54
+
55
+ /**
56
+ * The `Cache-Control` of every response to a preview request (a request with
57
+ * `?__draft=`, whatever its value, or the draft cookie): always revalidated,
58
+ * never answered from a cache (PO 2026-10-23).
59
+ */
60
+ export const DRAFT_CACHE_CONTROL = "max-age=0, no-cache, must-revalidate";
61
+
62
+ /**
63
+ * The response headers of a preview: never answered from a browser's or a
64
+ * CDN's cache (`Cache-Control: max-age=0, no-cache, must-revalidate`, `Vary:
65
+ * Cookie`) and never indexed (`X-Robots-Tag: noindex, nofollow`). `{}` when
66
+ * `preview` is `null`, `false` or empty, so the result of the draft decision
67
+ * applies as is: pass the version, or whether the request carries a draft
68
+ * signal at all (`?__draft=off` and a refused or unavailable draft too).
40
69
  */
41
- export function draftResponseHeaders(pointer: string | null | undefined): Record<string, string> {
42
- if (!pointer) return {};
70
+ export function draftResponseHeaders(
71
+ preview: string | boolean | null | undefined,
72
+ ): Record<string, string> {
73
+ if (!preview) return {};
43
74
  return {
44
- "Cache-Control": "no-store, private",
75
+ "Cache-Control": DRAFT_CACHE_CONTROL,
45
76
  Vary: "Cookie",
46
77
  "X-Robots-Tag": "noindex, nofollow",
47
78
  };
package/src/drafts.ts CHANGED
@@ -1,109 +1,35 @@
1
1
  /**
2
- * Which hosts may render drafts: v7's allowlist (`@decocms/blocks` 7.x
3
- * `cms/draftSource.ts`: `readAllowedHosts`, `isDraftHostAllowed`,
4
- * `isDraftPreviewEnabled`), ported as is. v7 read its override from the
5
- * `DECO_ALLOWED_PREVIEW_HOSTS` variable; here it is the `previewHosts` option,
6
- * and the site passes the same options to the proxy (`decideDraft`) and to
7
- * its pages (`bindDraft`).
2
+ * Which hosts may render drafts: the ones the CMS allows (`cms.readVersion`):
3
+ * the addresses Studio writes into the current release's metadata, plus
4
+ * `createCMS`'s `preview.hosts`; every host while the site has published no
5
+ * list, or with no `site` and no `preview.hosts`. The site declares nothing
6
+ * here (PO 2026-10-22). The proxy
7
+ * (`decideDraft`) and the pages (`bindDraft`) ask the same CMS.
8
8
  *
9
- * - The site's own preview hosts: the ones it declares, in the `CMS` block or
10
- * as `createCMS({ preview: { hosts } })` (matched as `cms.draftPointer`
11
- * matches them). A site that declares none has none, as in v7: blocks'
12
- * own default (every host) doesn't count as a declaration.
13
- * - The hosts Deco runs the site on, named after the site and always added:
14
- * `<site>.deco.site` and `<site>.deco-cx.workers.dev`, exact.
15
- * - `previewHosts: [...]` replaces the site's own list (the inferred hosts are
16
- * still added); `previewHosts: "none"` turns previews off on every host.
17
- *
18
- * Hosts compare exactly, lowercased, port included (`localhost:3000`). The
19
- * host is the one the visitor asked for: `x-forwarded-host` behind a proxy,
20
- * else `host`.
9
+ * The host is the one the visitor asked for: `x-forwarded-host` behind a
10
+ * proxy, else `host`.
21
11
  *
22
12
  * The draft itself is the capability: a host check only bounds where a draft
23
13
  * link works. A request the check refuses gets the release, never an error.
24
14
  *
15
+ * What a link carries is a version, as `cms.readVersion` reads it: a draft
16
+ * pointer, or a published release's commit SHA (Studio's "Preview this
17
+ * version"), with the preview's moment and switched tests and campaigns
18
+ * (`__at`, `__test`, `__campaign`; `parseVersion` from `@decocms/blocks`).
19
+ * Both pass the same host check: a visitor on the live site never picks a
20
+ * version.
21
+ *
25
22
  * No React, no Next.js and no module state: the proxy and the pages share it.
26
23
  */
27
- import { type CMS, parseDraftPointer, type RequestLike } from "@decocms/blocks";
24
+ import {
25
+ type CMS,
26
+ parseDraftPointer,
27
+ parseVersion,
28
+ type RequestLike,
29
+ type RequestVersion,
30
+ } from "@decocms/blocks";
28
31
  import { DRAFT_COOKIE, DRAFT_PARAM } from "./draft.ts";
29
32
 
30
- export interface PreviewPolicy {
31
- /** The site's name: `<site>.deco.site` and `<site>.deco-cx.workers.dev` may preview. */
32
- site?: string;
33
- /** Replaces the `CMS` block's preview hosts; `"none"` turns previews off everywhere. */
34
- previewHosts?: readonly string[] | "none";
35
- }
36
-
37
- /** The hosts Deco runs `site` on, which may always preview (unless previews are off). */
38
- export function inferredPreviewHosts(site: string | undefined): string[] {
39
- const s = (site ?? "").trim().toLowerCase();
40
- return s ? [`${s}.deco.site`, `${s}.deco-cx.workers.dev`] : [];
41
- }
42
-
43
- function overrideHosts(policy: PreviewPolicy): string[] {
44
- if (policy.previewHosts === "none") return ["none"];
45
- return (policy.previewHosts ?? []).map((h) => h.trim().toLowerCase()).filter(Boolean);
46
- }
47
-
48
- /**
49
- * Whether `host` may preview without asking the CMS: `true` (an override or
50
- * inferred host), `false` (previews off, or the override leaves it out), or
51
- * `"cms"` when only the site's declared preview hosts can say (none declared:
52
- * `false`, see {@link declaresPreviewHosts}).
53
- */
54
- export function previewHostDecision(host: string, policy: PreviewPolicy): boolean | "cms" {
55
- const override = overrideHosts(policy);
56
- if (override.includes("none")) return false;
57
- const h = host.trim().toLowerCase();
58
- if (h === "") return false;
59
- if (inferredPreviewHosts(policy.site).includes(h)) return true;
60
- if (policy.previewHosts !== undefined) return override.includes(h);
61
- return "cms";
62
- }
63
-
64
- /**
65
- * Whether any host may preview (v7's `isDraftPreviewEnabled`): a page checks
66
- * it before it reads the request's headers, so a site with previews off never
67
- * turns a static page dynamic. `false` for `previewHosts: "none"`; `true` with
68
- * a site name or an override list; otherwise whether the site declares
69
- * preview hosts and they allow any.
70
- */
71
- export async function isDraftPreviewEnabled(
72
- cms: Pick<CMS, "settings" | "forRelease">,
73
- policy: PreviewPolicy,
74
- ): Promise<boolean> {
75
- const override = overrideHosts(policy);
76
- if (override.includes("none")) return false;
77
- if (inferredPreviewHosts(policy.site).length > 0) return true;
78
- if (policy.previewHosts !== undefined) return override.length > 0;
79
- return (await declaresPreviewHosts(cms)) && (await cms.settings()).preview.hosts.length > 0;
80
- }
81
-
82
- /**
83
- * Whether the site declares its own preview hosts: in the `CMS` block, or as
84
- * `createCMS`'s `preview.hosts`. When it declares none, `cms.settings()`
85
- * reports blocks' default, every host (`["*"]`), which v7 never had: there,
86
- * a site without a list previewed only on its Deco hosts. The saved `CMS`
87
- * block tells an explicit `["*"]` from that default.
88
- */
89
- export async function declaresPreviewHosts(
90
- cms: Pick<CMS, "settings" | "forRelease">,
91
- ): Promise<boolean> {
92
- const { hosts } = (await cms.settings()).preview;
93
- if (hosts.length !== 1 || hosts[0] !== "*") return true;
94
- try {
95
- const [block] = await cms.forRelease().resolve<unknown>("CMS", { run: false });
96
- const preview = isRecord(block) ? block.preview : undefined;
97
- return isRecord(preview) && ("hosts" in preview || "__resolveType" in preview);
98
- } catch {
99
- return false;
100
- }
101
- }
102
-
103
- function isRecord(value: unknown): value is Record<string, unknown> {
104
- return typeof value === "object" && value !== null && !Array.isArray(value);
105
- }
106
-
107
33
  const DRAFT_OFF = "off";
108
34
 
109
35
  function draftParam(request: RequestLike): string | null {
@@ -129,16 +55,16 @@ function readCookie(header: string | null, name: string): string | null {
129
55
  }
130
56
 
131
57
  /**
132
- * The pointer a request carries, as `@decocms/blocks` reads it: `?__draft=`
133
- * first, then the draft cookie; `null` for `?__draft=off` and for a value
134
- * that doesn't parse.
58
+ * What a request's Studio link asks for, as `@decocms/blocks` reads it:
59
+ * `?__draft=` first, then the draft cookie; `null` for `?__draft=off` and
60
+ * for a value that doesn't parse.
135
61
  */
136
- export function readDraftPointer(request: RequestLike): string | null {
62
+ export function readDraftVersion(request: RequestLike): RequestVersion | null {
137
63
  const param = draftParam(request);
138
64
  if (param === DRAFT_OFF) return null;
139
- if (param && parseDraftPointer(param) !== null) return param;
140
- const cookie = readCookie(request.headers.get("cookie"), DRAFT_COOKIE);
141
- return cookie && parseDraftPointer(cookie) !== null ? cookie : null;
65
+ const fromLink = param ? parseVersion(param) : null;
66
+ if (fromLink !== null) return fromLink;
67
+ return parseVersion(readCookie(request.headers.get("cookie"), DRAFT_COOKIE));
142
68
  }
143
69
 
144
70
  /** Whether the request carries a draft signal at all: without one the CMS is never asked. */
@@ -176,29 +102,44 @@ export function requestLike(
176
102
  }
177
103
 
178
104
  /**
179
- * The draft `request` renders under `policy`, or `null` for the release: the
180
- * pointer it carries, on a host the policy (or, when only it can say, the
181
- * `CMS` block) allows.
105
+ * What `request` renders (a draft pointer or a release's commit SHA, with
106
+ * the preview's moment and switched tests and campaigns), or `null` for the
107
+ * current release: what its Studio link carries, on a host the CMS allows.
182
108
  */
183
109
  export async function allowedDraft(
184
- cms: Pick<CMS, "draftPointer" | "settings" | "forRelease">,
110
+ cms: Pick<CMS, "readVersion">,
185
111
  request: RequestLike,
186
- policy: PreviewPolicy,
187
- ): Promise<string | null> {
188
- const pointer = readDraftPointer(request);
189
- if (pointer === null) return null;
190
- let host: string;
191
- try {
192
- host = new URL(request.url).host;
193
- } catch {
194
- return null;
195
- }
196
- const decision = previewHostDecision(host, policy);
197
- const allowed =
198
- decision === "cms"
199
- ? (await declaresPreviewHosts(cms)) && (await cms.draftPointer(request)) !== null
200
- : decision;
201
- return allowed ? pointer : null;
112
+ releaseCheck?: string | null,
113
+ ): Promise<RequestVersion | null> {
114
+ const read = readDraftVersion(request);
115
+ if (read === null) return null;
116
+ return (await cms.readVersion(request, { releaseCheck })) === null ? null : read;
117
+ }
118
+
119
+ /** A draft pointer's version that names no draft: the release, nothing fetched. */
120
+ const LOCAL_VERSION = "local";
121
+
122
+ /** Whether a version names a saved draft (a pointer, not a release's SHA and not `local`). */
123
+ export function namesDraft(version: string): boolean {
124
+ const parsed = parseDraftPointer(version);
125
+ return parsed !== null && parsed.version !== LOCAL_VERSION;
126
+ }
127
+
128
+ /**
129
+ * The release check of a request whose link names a draft
130
+ * (`cms.checkRelease()`), so its host check, its draft reads and its render
131
+ * share one read of `latest.json`: the one `forwarded` names when the CMS
132
+ * made it (the proxy's, for the same request), else a new one. `undefined`
133
+ * for any other request: the release, a release's SHA, no link.
134
+ */
135
+ export async function draftReleaseCheck(
136
+ cms: Pick<CMS, "checkRelease">,
137
+ request: RequestLike,
138
+ forwarded?: string | null,
139
+ ): Promise<string | undefined> {
140
+ const read = readDraftVersion(request);
141
+ if (read === null || !namesDraft(read.version)) return undefined;
142
+ return cms.checkRelease(forwarded);
202
143
  }
203
144
 
204
145
  /**
@@ -223,7 +164,11 @@ export function endsPreview(request: RequestLike): boolean {
223
164
  return draftParam(request) === DRAFT_OFF;
224
165
  }
225
166
 
226
- /** Whether the request's own `?__draft=` carries `pointer` (a draft link, not the cookie). */
227
- export function fromDraftLink(request: RequestLike, pointer: string): boolean {
228
- return draftParam(request) === pointer;
167
+ /**
168
+ * The request's own `?__draft=` value when it is a version (a draft link,
169
+ * not the cookie): the value its cookie stores; `null` otherwise.
170
+ */
171
+ export function draftLinkValue(request: RequestLike): string | null {
172
+ const param = draftParam(request);
173
+ return param && param !== DRAFT_OFF && parseVersion(param) !== null ? param : null;
229
174
  }
package/src/index.ts CHANGED
@@ -11,7 +11,8 @@
11
11
  * Outside Studio's frame, "." opens the page in Studio's editor.
12
12
  * - `StudioPage`: rendered inside each CMS page, `<StudioPage pathTemplate={page.path} />`,
13
13
  * tells the bridge which page "." opens (the root layout doesn't re-render per page).
14
- * - `DRAFT_PARAM`, `DRAFT_COOKIE`, `DRAFT_HEADER`, `draftResponseHeaders`,
14
+ * - `DRAFT_PARAM`, `DRAFT_COOKIE`, `VERSION_HEADER`, `draftResponseHeaders`,
15
+ * `VISITOR_HEADER`, `ASSIGNMENTS_HEADER`,
15
16
  * `STUDIO_ORIGINS`, `STUDIO_FRAME_ANCESTORS`, `isStudioOrigin`: for a site
16
17
  * that needs them in its own code (a route that sets its own headers, a CSP
17
18
  * it writes itself). The package's own copy: it never imports
@@ -23,10 +24,12 @@
23
24
  */
24
25
 
25
26
  export { DraftBadge, StudioPage, type StudioPageInfo } from "@decocms/blocks/preview";
27
+ export { ASSIGNMENTS_HEADER } from "./assignments.ts";
26
28
  export {
27
29
  DraftPreviewIndicator,
28
30
  type DraftPreviewIndicatorProps,
29
31
  } from "./DraftPreviewIndicator.tsx";
30
- export { DRAFT_COOKIE, DRAFT_HEADER, DRAFT_PARAM, draftResponseHeaders } from "./draft.ts";
32
+ export { DRAFT_COOKIE, DRAFT_PARAM, draftResponseHeaders, VERSION_HEADER } from "./draft.ts";
31
33
  export { StudioBridge, type StudioBridgeProps } from "./StudioBridge.ts";
32
34
  export { isStudioOrigin, STUDIO_FRAME_ANCESTORS, STUDIO_ORIGINS } from "./studio.ts";
35
+ export { VISITOR_HEADER } from "./visitor.ts";
@@ -24,7 +24,7 @@
24
24
  * Labels: `http.route` is Next's route template (`unmatched` when Next names
25
25
  * none); RSC requests count under their page's route, as the requests they
26
26
  * are. `cache` is `DRAFT` for a draft render (the proxy forwarded
27
- * `x-deco-draft`, or the request was rewritten onto `/_draft`) and otherwise
27
+ * `x-deco-version`, or the request was rewritten onto `/_draft`) and otherwise
28
28
  * `BYPASS`: Next decides its cache (`x-nextjs-cache`) on the response, which
29
29
  * the span never sees.
30
30
  */
@@ -40,7 +40,7 @@ import type {
40
40
  Tracer,
41
41
  TracerProvider,
42
42
  } from "@opentelemetry/api";
43
- import { DRAFT_HEADER, DRAFT_ROUTE_PREFIX } from "../draft.ts";
43
+ import { DRAFT_ROUTE_PREFIX, VERSION_HEADER } from "../draft.ts";
44
44
 
45
45
  type Api = typeof import("@opentelemetry/api");
46
46
 
@@ -235,7 +235,7 @@ function draftPropagator(DRAFT: symbol): TextMapPropagator {
235
235
  inject() {},
236
236
  extract(context, carrier, getter) {
237
237
  try {
238
- const value = getter.get(carrier, DRAFT_HEADER);
238
+ const value = getter.get(carrier, VERSION_HEADER);
239
239
  return value !== undefined && value !== "" ? context.setValue(DRAFT, true) : context;
240
240
  } catch {
241
241
  return context;