diff --git a/packages/blocks/src/cms/applySectionConventions.ts b/packages/blocks/src/cms/applySectionConventions.ts index 56dc2386..03115462 100644 --- a/packages/blocks/src/cms/applySectionConventions.ts +++ b/packages/blocks/src/cms/applySectionConventions.ts @@ -6,23 +6,20 @@ * registerSectionsSync, setAsyncRenderingConfig, registerCacheableSections, * registerLayoutSections, registerSeoSections, and registerSection. */ -import { - registerSection, - registerSectionsSync, -} from "./registry"; -import { - type CacheableSectionInput, - registerCacheableSections, - registerLayoutSections, -} from "./sectionLoaders"; +import { registerSection, registerSectionsSync } from "./registry"; import { type AsyncRenderingConfig, + getAsyncRenderingConfig, registerEagerSections, registerNeverDeferSections, registerSeoSections, setAsyncRenderingConfig, - getAsyncRenderingConfig, } from "./resolve"; +import { + type CacheableSectionInput, + registerCacheableSections, + registerLayoutSections, +} from "./sectionLoaders"; export interface SectionMetaEntry { eager?: boolean; @@ -96,8 +93,7 @@ export function applySectionConventions(input: ApplySectionConventionsInput): vo // it, asyncConfig stays null, `useAsync` is false in resolveDecoPage, and // even editor-marked ⚡ sections render eagerly. (The default foldThreshold is // Infinity, so position-based deferral stays off unless a site opts in.) - const existing: Partial = - getAsyncRenderingConfig() ?? {}; + const existing: Partial = getAsyncRenderingConfig() ?? {}; setAsyncRenderingConfig({ ...existing, alwaysEager: [...(existing.alwaysEager ?? []), ...eagerSections], diff --git a/packages/blocks/src/cms/blockSource.test.ts b/packages/blocks/src/cms/blockSource.test.ts index 87701491..7f1351d1 100644 --- a/packages/blocks/src/cms/blockSource.test.ts +++ b/packages/blocks/src/cms/blockSource.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { djb2Hex } from "../sdk/djb2"; import { BundledBlockSource, computeRevision, @@ -8,7 +9,6 @@ import { revisionKey, snapshotKey, } from "./blockSource"; -import { djb2Hex } from "../sdk/djb2"; describe("computeRevision", () => { it("matches loader.ts computeRevision (djb2Hex of JSON.stringify)", () => { diff --git a/packages/blocks/src/cms/client.browserBundle.test.ts b/packages/blocks/src/cms/client.browserBundle.test.ts index 56747c93..add6e492 100644 --- a/packages/blocks/src/cms/client.browserBundle.test.ts +++ b/packages/blocks/src/cms/client.browserBundle.test.ts @@ -1,7 +1,7 @@ -import { describe, expect, it } from "vitest"; import { execFileSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; const here = dirname(fileURLToPath(import.meta.url)); // esbuild's JS API relies on `new TextEncoder().encode("") instanceof diff --git a/packages/blocks/src/cms/client.ts b/packages/blocks/src/cms/client.ts index a8d7d806..716cdb7c 100644 --- a/packages/blocks/src/cms/client.ts +++ b/packages/blocks/src/cms/client.ts @@ -49,8 +49,6 @@ export { registerSectionsSync, setResolvedComponent, } from "./registry"; -export type { SectionLoaderFn } from "./sectionLoaders"; -export { compose, withDevice, withMobile, withSearchParam, withSectionLoader } from "./sectionMixins"; export type { ActionConfig, AppSchemas, @@ -72,3 +70,11 @@ export { registerMatcherSchema, registerMatcherSchemas, } from "./schema"; +export type { SectionLoaderFn } from "./sectionLoaders"; +export { + compose, + withDevice, + withMobile, + withSearchParam, + withSectionLoader, +} from "./sectionMixins"; diff --git a/packages/blocks/src/cms/draftSource.test.ts b/packages/blocks/src/cms/draftSource.test.ts new file mode 100644 index 00000000..45b243e4 --- /dev/null +++ b/packages/blocks/src/cms/draftSource.test.ts @@ -0,0 +1,258 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + clearDraftCache, + DEFAULT_PREVIEW_API_DOMAINS, + isDraftHostAllowed, + isDraftPreviewEnabled, + parseDraftPointer, + previewApiOriginForHost, + resolveDraftDecofile, + setDraftPreviewHosts, +} from "./draftSource"; + +const ENV_ON = { DECO_ALLOWED_PREVIEW_HOSTS: "preview.example" }; + +function jsonResponse(body: unknown, init?: ResponseInit): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + ...init, + }); +} + +beforeEach(() => { + clearDraftCache(); +}); + +describe("parseDraftPointer", () => { + it("parses authority@version, lowercasing the authority", () => { + expect(parseDraftPointer("ABC.Preview-Studio.decocms.com@FF00")).toEqual({ + host: "abc.preview-studio.decocms.com", + version: "FF00", + }); + }); + + it("keeps an explicit port on the authority", () => { + expect(parseDraftPointer("abc.localhost:60534@v1")).toEqual({ + host: "abc.localhost:60534", + version: "v1", + }); + }); + + it("rejects more than one @", () => { + // A naive split("@") accepts this and silently uses the first two + // segments — the exact hole found while spiking the fetch path. + expect(parseDraftPointer("a.example@b@c")).toBeNull(); + }); + + it("rejects anything that could escape the authority", () => { + // No scheme, no path, no userinfo — the token carries an authority only, + // so `javascript:`/`file:`/full URLs fail structurally at parse time. + expect(parseDraftPointer("https://evil.example@v1")).toBeNull(); + expect(parseDraftPointer("evil.example/x@v1")).toBeNull(); + expect(parseDraftPointer("a.example:80:80@v1")).toBeNull(); + expect(parseDraftPointer("a.example:abc@v1")).toBeNull(); + expect(parseDraftPointer(".leading.dot@v1")).toBeNull(); + expect(parseDraftPointer("bare-label@v1")).toBeNull(); + }); + + it("validates the version charset — it becomes a cache key", () => { + expect(parseDraftPointer("a.example@")).toBeNull(); + expect(parseDraftPointer(`a.example@${"x".repeat(65)}`)).toBeNull(); + expect(parseDraftPointer("a.example@v 1")).toBeNull(); + expect(parseDraftPointer(null)).toBeNull(); + }); +}); + +describe("previewApiOriginForHost", () => { + it("admits authorities under the default deco domains", () => { + expect(previewApiOriginForHost("abc.preview-studio.decocms.com", {})).toBe( + "https://abc.preview-studio.decocms.com", + ); + expect(previewApiOriginForHost("abc.local.studio.decocms.com", {})).toBe( + "https://abc.local.studio.decocms.com", + ); + expect(previewApiOriginForHost("abc.localhost:60534", {})).toBe("http://abc.localhost:60534"); + }); + + it("rejects hosts outside the domains — the token proposes, config disposes", () => { + expect(previewApiOriginForHost("evil.example", {})).toBeNull(); + // Dot-prefixed suffixes guarantee a label boundary: a lookalike domain + // that merely ends with the same characters cannot pass. + expect(previewApiOriginForHost("evil-preview-studio.decocms.com", {})).toBeNull(); + // The domain itself (no label in front) is not a draft host. + expect(previewApiOriginForHost("preview-studio.decocms.com", {})).toBeNull(); + }); + + it("allows an explicit port only under localhost-ish domains", () => { + // A public-domain token must not steer the fetch at odd ports. + expect(previewApiOriginForHost("abc.preview-studio.decocms.com:8500", {})).toBeNull(); + }); + + it("honours a configured override instead of the defaults", () => { + const env = { DECO_PREVIEW_API_DOMAINS: ".staging.example" }; + expect(previewApiOriginForHost("abc.staging.example", env)).toBe("https://abc.staging.example"); + expect(previewApiOriginForHost("abc.preview-studio.decocms.com", env)).toBeNull(); + }); +}); + +describe("gating", () => { + it("is on iff an allowed host is configured — API domains have defaults", () => { + expect(isDraftPreviewEnabled(ENV_ON)).toBe(true); + expect(isDraftPreviewEnabled({})).toBe(false); + }); + + it("matches request hosts verbatim, port included, case-insensitively", () => { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "fila.vtex.app, localhost:3100" }; + expect(isDraftHostAllowed("FILA.VTEX.APP", env)).toBe(true); + expect(isDraftHostAllowed("localhost:3100", env)).toBe(true); + expect(isDraftHostAllowed("fila.com.br", env)).toBe(false); + expect(isDraftHostAllowed("localhost", env)).toBe(false); + expect(isDraftHostAllowed(null, env)).toBe(false); + expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(false); + }); +}); + +describe("resolveDraftDecofile", () => { + it("fetches exactly the token's validated origin", async () => { + const calls: string[] = []; + const blocks = await resolveDraftDecofile({ + pointer: "abc.preview-studio.decocms.com@v1", + env: ENV_ON, + fetchImpl: (async (url: string) => { + calls.push(String(url)); + return jsonResponse({ "pages-home": { title: "draft" } }); + }) as unknown as typeof fetch, + }); + + expect(blocks).toEqual({ "pages-home": { title: "draft" } }); + expect(calls).toEqual(["https://abc.preview-studio.decocms.com/_sandbox/decofile"]); + }); + + it("is inert without a host allowlist — no fetch at all", async () => { + let called = false; + const blocks = await resolveDraftDecofile({ + pointer: "abc.preview-studio.decocms.com@v1", + env: {}, + fetchImpl: (async () => { + called = true; + return jsonResponse({}); + }) as unknown as typeof fetch, + }); + expect(blocks).toBeNull(); + expect(called).toBe(false); + }); + + it("refuses a parseable token whose origin no domain admits — no fetch", async () => { + let called = false; + const blocks = await resolveDraftDecofile({ + pointer: "abc.evil.example@v1", + env: ENV_ON, + fetchImpl: (async () => { + called = true; + return jsonResponse({}); + }) as unknown as typeof fetch, + }); + expect(blocks).toBeNull(); + expect(called).toBe(false); + }); + + it("caches by version — one fetch per version, not per request", async () => { + let fetches = 0; + const fetchImpl = (async () => { + fetches++; + return jsonResponse({ n: fetches }); + }) as unknown as typeof fetch; + const P = "abc.preview-studio.decocms.com"; + + const a = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl }); + const b = await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl }); + expect(fetches).toBe(1); + expect(b).toBe(a); + + await resolveDraftDecofile({ pointer: `${P}@v2`, env: ENV_ON, fetchImpl }); + expect(fetches).toBe(2); + }); + + it("bounds the cache so multi-MB decofiles can't accumulate", async () => { + let fetches = 0; + const fetchImpl = (async () => { + fetches++; + return jsonResponse({ n: fetches }); + }) as unknown as typeof fetch; + const P = "abc.preview-studio.decocms.com"; + + for (const v of ["v1", "v2", "v3", "v4"]) { + await resolveDraftDecofile({ pointer: `${P}@${v}`, env: ENV_ON, fetchImpl }); + } + expect(fetches).toBe(4); + await resolveDraftDecofile({ pointer: `${P}@v1`, env: ENV_ON, fetchImpl }); + expect(fetches).toBe(5); // v1 evicted (cap 3) — re-fetch, never stale + await resolveDraftDecofile({ pointer: `${P}@v4`, env: ENV_ON, fetchImpl }); + expect(fetches).toBe(5); // v4 resident + }); + + it("degrades to published on unreachable / non-2xx / unparseable", async () => { + const P = "abc.preview-studio.decocms.com"; + for (const fetchImpl of [ + async () => { + throw new Error("ECONNREFUSED"); + }, + async () => new Response("nope", { status: 404 }), + async () => + new Response("not json", { + status: 200, + headers: { "content-type": "text/html" }, + }), + ]) { + clearDraftCache(); + expect( + await resolveDraftDecofile({ + pointer: `${P}@v1`, + env: ENV_ON, + fetchImpl: fetchImpl as unknown as typeof fetch, + }), + ).toBeNull(); + } + }); +}); + +// DEFAULT_PREVIEW_API_DOMAINS is part of the public contract — pin it. +describe("DEFAULT_PREVIEW_API_DOMAINS", () => { + it("ships the deco-operated origins, dot-prefixed", () => { + expect(DEFAULT_PREVIEW_API_DOMAINS).toEqual([ + ".preview-studio.decocms.com", + ".local.studio.decocms.com", + ".localhost", + ]); + }); +}); + +describe("site-block preview hosts", () => { + it("enables the feature from the site block alone — no env needed", () => { + setDraftPreviewHosts(["fila.vtex.app", "LOCALHOST:3100", 42, " "]); + try { + expect(isDraftPreviewEnabled({})).toBe(true); + expect(isDraftHostAllowed("fila.vtex.app", {})).toBe(true); + // Sanitized: lowercased, non-strings and blanks dropped. + expect(isDraftHostAllowed("localhost:3100", {})).toBe(true); + expect(isDraftHostAllowed("evil.example", {})).toBe(false); + } finally { + setDraftPreviewHosts([]); + } + }); + + it("env REPLACES the block hosts when set — the operational escape hatch", () => { + setDraftPreviewHosts(["fila.vtex.app"]); + try { + const env = { DECO_ALLOWED_PREVIEW_HOSTS: "other.example" }; + expect(isDraftHostAllowed("other.example", env)).toBe(true); + // Not merged: env is a kill switch / override, so the block value must + // not survive alongside it. + expect(isDraftHostAllowed("fila.vtex.app", env)).toBe(false); + } finally { + setDraftPreviewHosts([]); + } + }); +}); diff --git a/packages/blocks/src/cms/draftSource.ts b/packages/blocks/src/cms/draftSource.ts new file mode 100644 index 00000000..288ab8b4 --- /dev/null +++ b/packages/blocks/src/cms/draftSource.ts @@ -0,0 +1,284 @@ +/** + * Draft preview — pull-based decofile override. + * + * A Studio preview serves the working-tree draft at + * `GET /_sandbox/decofile`; a production site pulls it and renders its + * own real pages against it. This replaces pushing the decofile into a POST + * body, which only deco's own runtime honours — Next.js and most frameworks + * render on GET only. + * + * This module is the framework-agnostic half: token parsing, origin + * validation, fetching, and version caching. Binding a resolved draft to a + * request is framework-specific (see `@decocms/nextjs`'s draft wiring) and + * reaches this module through {@link setDraftOverrideGetter} — the same + * dependency-injection shape as `setFastDeployKVGetter`, so `blocks` keeps its + * zero-dependency direction. + * + * Inert unless `DECO_ALLOWED_PREVIEW_HOSTS` names the request's host: upgrading + * the package must never be enough to start fetching from the network and + * rendering unpublished content. Host-scoping (rather than a boolean) exists + * because one deployment commonly serves several domains — the preview domain + * may render drafts while the production domain, on the same build, must + * ignore a `?__draft=` entirely. + */ + +/** + * A parsed `?__draft=` token: `@`. + * + * The token carries the AUTHORITY of the draft content API, never a scheme or + * path — a full URL would be an SSRF vector, and the scheme is derived from + * the matched domain instead. Reserved evolution: a future signed token uses a + * distinguishable prefix (e.g. `s1.`), so this strict two-part parse rejects + * it cleanly rather than half-reading it. + */ +export interface DraftPointer { + /** Content-API authority, e.g. `abc.preview-studio.decocms.com` or `abc.localhost:60534`. */ + host: string; + /** Opaque content version (the server's ETag). Immutable → safe cache key. */ + version: string; +} + +/** Lowercase DNS hostname, at least two labels (a bare label can't match any domain). */ +const HOST_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +const PORT_RE = /^[0-9]{1,5}$/; +const VERSION_RE = /^[A-Za-z0-9._-]{1,64}$/; + +/** + * Parse `@`. Null on anything unexpected — callers fall + * back to published content. Requires EXACTLY one `@`: a naive split accepts + * `a@b@c` and silently uses the first two segments. + */ +export function parseDraftPointer(raw: string | null | undefined): DraftPointer | null { + if (!raw) return null; + const parts = raw.split("@"); + if (parts.length !== 2) return null; + const [authority, version] = parts; + if (!authority || !version || !VERSION_RE.test(version)) return null; + + const [host, port, extra] = authority.toLowerCase().split(":"); + if (extra !== undefined) return null; + if (!host || !HOST_RE.test(host)) return null; + if (port !== undefined && !PORT_RE.test(port)) return null; + + return { host: port === undefined ? host : `${host}:${port}`, version }; +} + +/** + * Domains the draft content API may live under — deco-operated, so shipping + * them as defaults adds no SSRF surface. `DECO_PREVIEW_API_DOMAINS` overrides + * the whole list when set. Entries are dot-prefixed suffixes, which guarantees + * a label boundary on match (`evil-preview-studio.decocms.com` cannot pass). + */ +export const DEFAULT_PREVIEW_API_DOMAINS = [ + ".preview-studio.decocms.com", + ".local.studio.decocms.com", + ".localhost", +]; + +function readApiDomains(env: Record): string[] { + const configured = (env.DECO_PREVIEW_API_DOMAINS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return configured.length > 0 ? configured : DEFAULT_PREVIEW_API_DOMAINS; +} + +/** + * Validate the token's authority against the configured domains and derive the + * fetch origin, or null if no domain admits it. + * + * The token proposes, configuration disposes: only the hostname-suffix match + * decides, so a caller can steer WHICH label under your domains, never which + * domains. Scheme is derived — http for localhost-ish domains, https + * otherwise — and an explicit port is allowed only there, so a public-domain + * token cannot aim at odd ports. + */ +export function previewApiOriginForHost( + authority: string, + env?: Record, +): string | null { + const [host, port] = authority.toLowerCase().split(":"); + if (!host) return null; + const domain = readApiDomains(envOrProcess(env)).find( + (d) => host.length > d.length && host.endsWith(d), + ); + if (!domain) return null; + const local = domain.includes("localhost"); + if (port !== undefined && !local) return null; + return `${local ? "http" : "https"}://${host}${port === undefined ? "" : `:${port}`}`; +} + +/** + * Hosts declared by the site itself (the global `site` block's `previewHosts`), + * installed once at setup time by the framework binding. + * + * MUST be fed from the setup-time base blocks, never from `loadBlocks()` at + * request time: the request path merges the draft override, and an allowlist + * readable through the override could be rewritten by the very draft it gates. + */ +// globalThis-backed, like the block loader itself: bundlers can duplicate +// this module across graphs, and a plain module variable set in one instance +// is invisible to the others. (The MIDDLEWARE runtime is a separate world +// even so — which is why the page-side gate is the authoritative one and the +// middleware only hard-gates when the env override is present.) +const G = globalThis as { __decoDraftHosts?: string[] }; + +/** Install the site-declared preview hosts. Called by the framework binding at setup. */ +export function setDraftPreviewHosts(hosts: readonly unknown[]): void { + G.__decoDraftHosts = hosts + .filter((h): h is string => typeof h === "string") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); +} + +/** + * Hosts allowed to render drafts. + * + * The site block is the expected source — the opt-in lives in the repo, + * reviewed in a PR, versioned with branches. `DECO_ALLOWED_PREVIEW_HOSTS` + * REPLACES it when set: an operational escape hatch (kill a bad value without + * a deploy, add a machine-specific port) — not the primary configuration. + */ +function readAllowedHosts(env: Record): string[] { + const fromEnv = (env.DECO_ALLOWED_PREVIEW_HOSTS ?? "") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + return fromEnv.length > 0 ? fromEnv : (G.__decoDraftHosts ?? []); +} + +/** + * Whether `host` (as seen on the request) may render drafts. + * + * Compared against `DECO_ALLOWED_PREVIEW_HOSTS` verbatim, port included — + * local dev is `localhost:3100`, not `localhost`. The header is spoofable by a + * direct-to-origin request, but the draft id is the actual capability; + * host-scoping bounds blast radius (production domains stay inert), it is not + * a secret. + */ +export function isDraftHostAllowed( + host: string | null | undefined, + env?: Record, +): boolean { + if (!host) return false; + return readAllowedHosts(envOrProcess(env)).includes(host.trim().toLowerCase()); +} + +/** + * True when any host is allowed to preview. A plain env read — callers use it + * to gate BEFORE touching dynamic APIs (`cookies()`/`headers()`), so an + * unconfigured site never loses static/ISR rendering. The per-request host + * match happens later, in `isDraftHostAllowed`. + */ +export function isDraftPreviewEnabled(env?: Record): boolean { + return readAllowedHosts(envOrProcess(env)).length > 0; +} + +function envOrProcess( + env?: Record, +): Record { + return ( + env ?? + (globalThis as { process?: { env?: Record } }).process?.env ?? + {} + ); +} + +/** + * Version cache. Bounded on purpose: a decofile is routinely multi-megabyte, + * so an unbounded map keyed by version would grow with every save until the + * process died. Content-addressed, so a hit is always correct. + */ +const MAX_CACHED_VERSIONS = 3; +const byVersion = new Map>(); + +function cacheDraft(version: string, blocks: Record): void { + byVersion.delete(version); + byVersion.set(version, blocks); + while (byVersion.size > MAX_CACHED_VERSIONS) { + const oldest = byVersion.keys().next().value; + if (oldest === undefined) break; + byVersion.delete(oldest); + } +} + +/** Test seam — drops every cached version. */ +export function clearDraftCache(): void { + byVersion.clear(); +} + +export interface ResolveDraftOptions { + /** Raw `@` token from the request. */ + pointer: string | null | undefined; + /** Defaults to `process.env`. */ + env?: Record; + /** Defaults to global `fetch`. Injected in tests. */ + fetchImpl?: typeof fetch; +} + +/** + * Resolve a draft token to a decofile, or null to render published content. + * + * Null on every failure path — disabled, malformed token, disallowed origin, + * unreachable, non-2xx — because a draft that cannot be resolved must degrade + * to published rather than break the page. + */ +export async function resolveDraftDecofile( + options: ResolveDraftOptions, +): Promise | null> { + const env = envOrProcess(options.env); + if (readAllowedHosts(env).length === 0) return null; + + const parsed = parseDraftPointer(options.pointer); + if (!parsed) return null; + + const cached = byVersion.get(parsed.version); + if (cached) return cached; + + const origin = previewApiOriginForHost(parsed.host, env); + if (!origin) return null; + + const doFetch = options.fetchImpl ?? fetch; + let res: Response; + try { + res = await doFetch(`${origin}/_sandbox/decofile`, { cache: "no-store" }); + } catch { + return null; + } + if (!res.ok) return null; + + let blocks: Record; + try { + blocks = (await res.json()) as Record; + } catch { + return null; + } + + cacheDraft(parsed.version, blocks); + return blocks; +} + +// --------------------------------------------------------------------------- +// Request binding (dependency-injected by the framework binding) +// --------------------------------------------------------------------------- + +type DraftOverrideGetter = () => Record | null | undefined; + +let getDraftOverride: DraftOverrideGetter = () => undefined; + +/** + * Inject the request-scoped draft getter. + * + * Binding a value to "the current request" is framework-specific and `blocks` + * must not know about any framework: `@decocms/nextjs` backs this with React + * `cache()` (App Router has no AsyncLocalStorage request scope of its own). + * Never called → returns undefined → `loadBlocks()` behaves exactly as before. + */ +export function setDraftOverrideGetter(getter: DraftOverrideGetter): void { + getDraftOverride = getter; +} + +/** The current request's draft blocks, if a binding registered one. */ +export function getRequestDraftOverride(): Record | null | undefined { + return getDraftOverride(); +} diff --git a/packages/blocks/src/cms/index.ts b/packages/blocks/src/cms/index.ts index 8ec9206c..86e6ef6b 100644 --- a/packages/blocks/src/cms/index.ts +++ b/packages/blocks/src/cms/index.ts @@ -12,6 +12,19 @@ export { revisionKey, snapshotKey, } from "./blockSource"; +export type { DraftPointer, ResolveDraftOptions } from "./draftSource"; +export { + clearDraftCache, + DEFAULT_PREVIEW_API_DOMAINS, + getRequestDraftOverride, + isDraftHostAllowed, + isDraftPreviewEnabled, + parseDraftPointer, + previewApiOriginForHost, + resolveDraftDecofile, + setDraftOverrideGetter, + setDraftPreviewHosts, +} from "./draftSource"; export type { DecoPage, Resolvable } from "./loader"; export { findPageByPath, @@ -83,8 +96,6 @@ export { unregisterCommerceLoader, WELL_KNOWN_TYPES, } from "./resolve"; -export type { SectionLoaderContext } from "./sectionLoaderContext"; -export { buildSectionLoaderContext } from "./sectionLoaderContext"; export type { ActionConfig, AppSchemas, @@ -106,6 +117,8 @@ export { registerMatcherSchema, registerMatcherSchemas, } from "./schema"; +export type { SectionLoaderContext } from "./sectionLoaderContext"; +export { buildSectionLoaderContext } from "./sectionLoaderContext"; export type { SectionLoaderFn } from "./sectionLoaders"; export { getDegradedSections, diff --git a/packages/blocks/src/cms/loader.test.ts b/packages/blocks/src/cms/loader.test.ts index 33e0d493..f8faff2a 100644 --- a/packages/blocks/src/cms/loader.test.ts +++ b/packages/blocks/src/cms/loader.test.ts @@ -82,12 +82,12 @@ describe("matchPath", () => { }); it("matches with an optional prefix before a literal segment", () => { - expect( - matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023"), - ).toEqual({ "0": "destaques-2023" }); - expect( - matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023"), - ).toEqual({ "0": "destaques-2023" }); + expect(matchPath("/{granado/}?campanhas/*", "/granado/campanhas/destaques-2023")).toEqual({ + "0": "destaques-2023", + }); + expect(matchPath("/{granado/}?campanhas/*", "/campanhas/destaques-2023")).toEqual({ + "0": "destaques-2023", + }); }); it("matches an optional suffix group present and absent", () => { @@ -112,9 +112,7 @@ describe("matchPath", () => { // biome-ignore lint/performance/noDelete: restoring exact global state delete g.URLPattern; try { - expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow( - /URLPattern.*Node\.js >= 24/s, - ); + expect(() => matchPath("/foo/:slug", "/foo/bar")).toThrow(/URLPattern.*Node\.js >= 24/s); } finally { if (saved !== undefined) g.URLPattern = saved; } diff --git a/packages/blocks/src/cms/loader.ts b/packages/blocks/src/cms/loader.ts index 5c98e8f7..5ac26069 100644 --- a/packages/blocks/src/cms/loader.ts +++ b/packages/blocks/src/cms/loader.ts @@ -1,5 +1,6 @@ import * as asyncHooks from "node:async_hooks"; import { djb2Hex } from "../sdk/djb2"; +import { getRequestDraftOverride } from "./draftSource"; export type Resolvable = { __resolveType?: string; @@ -86,7 +87,8 @@ export function setBlocks(blocks: Record) { /** * Load the current blocks. If running inside a `withBlocksOverride` scope - * (admin preview), the override is merged on top of the base blocks. + * (admin preview) or a request carrying a draft-preview override, that + * override is merged on top of the base blocks. */ export function loadBlocks(): Record { // Re-sync from globalThis in case setBlocks was called in another module instance @@ -95,7 +97,10 @@ export function loadBlocks(): Record { revision = G.__deco.revision ?? null; } - const override = blocksOverrideStorage.getStore(); + // `withBlocksOverride` (an explicit admin render of a specific payload) wins + // over an ambient draft: the caller named the exact blocks to render, so a + // draft pointer on the same request must not silently replace them. + const override = blocksOverrideStorage.getStore() ?? getRequestDraftOverride(); if (override) { const merged = { ...blockData }; for (const [key, value] of Object.entries(override)) { @@ -203,12 +208,14 @@ export function getAllPages(): Array<{ key: string; page: DecoPage }> { // same regardless of whether @types/node has its own `URLPattern` global or // not — there's nothing for a local declaration to collide with. type MatchPatternResult = { - pathname: { groups: Record }; + pathname: { groups: Record }; }; declare const URLPattern: { - new (init: { pathname: string }): { - exec(input: { pathname: string }): MatchPatternResult | null; - }; + new (init: { + pathname: string; + }): { + exec(input: { pathname: string }): MatchPatternResult | null; + }; }; /** @@ -231,10 +238,7 @@ declare const URLPattern: { * `URLPattern` is native in browsers, workerd, Deno, and Node >= 24 (this * package's `engines` floor). Node 22 and older lack it. */ -export function matchPath( - pattern: string, - urlPath: string, -): Record | null { +export function matchPath(pattern: string, urlPath: string): Record | null { if (typeof URLPattern === "undefined") { throw new Error( "@decocms/blocks: this runtime has no URLPattern Web API, so CMS page " + diff --git a/packages/blocks/src/cms/registry.test.ts b/packages/blocks/src/cms/registry.test.ts index 5461d3ce..977ba876 100644 --- a/packages/blocks/src/cms/registry.test.ts +++ b/packages/blocks/src/cms/registry.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it } from "vitest"; import { - registerSection, - registerSectionsSync, + getResolvedComponent, getSection, getSectionOptions, getSyncComponent, - getResolvedComponent, + registerSection, + registerSectionsSync, } from "./registry"; // Reset globalThis.__deco between tests to avoid cross-test pollution @@ -34,44 +34,36 @@ describe("registerSection + getSection", () => { describe("registerSection with options", () => { it("stores loadingFallback in section options", () => { const fallback = () => null; - registerSection( - "site/sections/Shelf.tsx", - async () => ({ default: () => null }), - { loadingFallback: fallback }, - ); + registerSection("site/sections/Shelf.tsx", async () => ({ default: () => null }), { + loadingFallback: fallback, + }); const opts = getSectionOptions("site/sections/Shelf.tsx"); expect(opts?.loadingFallback).toBe(fallback); }); it("stores clientOnly flag in section options", () => { - registerSection( - "site/sections/Analytics.tsx", - async () => ({ default: () => null }), - { clientOnly: true }, - ); + registerSection("site/sections/Analytics.tsx", async () => ({ default: () => null }), { + clientOnly: true, + }); const opts = getSectionOptions("site/sections/Analytics.tsx"); expect(opts?.clientOnly).toBe(true); }); it("clientOnly defaults to undefined when not set", () => { - registerSection( - "site/sections/Normal.tsx", - async () => ({ default: () => null }), - { loadingFallback: () => null }, - ); + registerSection("site/sections/Normal.tsx", async () => ({ default: () => null }), { + loadingFallback: () => null, + }); const opts = getSectionOptions("site/sections/Normal.tsx"); expect(opts?.clientOnly).toBeUndefined(); }); it("clientOnly false is preserved", () => { - registerSection( - "site/sections/Explicit.tsx", - async () => ({ default: () => null }), - { clientOnly: false }, - ); + registerSection("site/sections/Explicit.tsx", async () => ({ default: () => null }), { + clientOnly: false, + }); const opts = getSectionOptions("site/sections/Explicit.tsx"); expect(opts?.clientOnly).toBe(false); diff --git a/packages/blocks/src/cms/registry.ts b/packages/blocks/src/cms/registry.ts index beeb9c9d..42e63e61 100644 --- a/packages/blocks/src/cms/registry.ts +++ b/packages/blocks/src/cms/registry.ts @@ -1,8 +1,6 @@ import type { ComponentType } from "react"; -export type OnBeforeResolveProps = ( - props: Record, -) => Record; +export type OnBeforeResolveProps = (props: Record) => Record; export type SectionModule = { default: ComponentType; @@ -35,8 +33,7 @@ if (!G.__deco.sectionRegistry) G.__deco.sectionRegistry = {}; if (!G.__deco.sectionOptions) G.__deco.sectionOptions = {}; if (!G.__deco.resolvedComponents) G.__deco.resolvedComponents = {}; if (!G.__deco.syncComponents) G.__deco.syncComponents = {}; -if (!G.__deco.onBeforeResolvePropsRegistry) - G.__deco.onBeforeResolvePropsRegistry = {}; +if (!G.__deco.onBeforeResolvePropsRegistry) G.__deco.onBeforeResolvePropsRegistry = {}; const registry: Record = G.__deco.sectionRegistry; const sectionOptions: Record = G.__deco.sectionOptions; @@ -194,9 +191,7 @@ export function registerSectionsSync(sections: Record) ]; const component = typeof raw === "function" || - (raw != null && - typeof raw === "object" && - REACT_WRAPPERS.includes((raw as any).$$typeof)) + (raw != null && typeof raw === "object" && REACT_WRAPPERS.includes((raw as any).$$typeof)) ? raw : undefined; if (!component) { @@ -229,17 +224,12 @@ export function getSyncComponent(key: string): ComponentType | undefined { * BEFORE the resolution engine resolves them. Use to extract metadata from * resolvable structures that would be lost after resolution. */ -export function registerOnBeforeResolveProps( - sectionKey: string, - fn: OnBeforeResolveProps, -): void { +export function registerOnBeforeResolveProps(sectionKey: string, fn: OnBeforeResolveProps): void { onBeforeResolvePropsRegistry[sectionKey] = fn; } /** Get the registered onBeforeResolveProps for a section, if any. */ -export function getOnBeforeResolveProps( - sectionKey: string, -): OnBeforeResolveProps | undefined { +export function getOnBeforeResolveProps(sectionKey: string): OnBeforeResolveProps | undefined { return onBeforeResolvePropsRegistry[sectionKey]; } diff --git a/packages/blocks/src/cms/resolve.test.ts b/packages/blocks/src/cms/resolve.test.ts index 3fb0bafc..f228d36b 100644 --- a/packages/blocks/src/cms/resolve.test.ts +++ b/packages/blocks/src/cms/resolve.test.ts @@ -20,6 +20,10 @@ vi.mock("./registry", () => ({ getOnBeforeResolveProps: vi.fn(), })); +import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; +import { findPageByPath } from "./loader"; +import { getSection } from "./registry"; +import type { AsyncRenderingConfig, DeferredSection } from "./resolve"; import { clearCommerceLoaders, DEFAULT_FOLD_THRESHOLD, @@ -29,8 +33,8 @@ import { registerCommerceLoader, registerEagerSections, registerNeverDeferSections, - resolveDeferredSectionFull, resolveDecoPage, + resolveDeferredSectionFull, resolvePageSeoBlock, resolveSectionsList, resolveValue, @@ -39,10 +43,6 @@ import { WELL_KNOWN_TYPES, } from "./resolve"; import { runSingleSectionLoader } from "./sectionLoaders"; -import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; -import { findPageByPath } from "./loader"; -import { getSection } from "./registry"; -import type { AsyncRenderingConfig, DeferredSection } from "./resolve"; describe("resolveDeferredSectionFull", () => { it("resolves a deferred section and preserves index", async () => { @@ -217,14 +217,10 @@ describe("commerce loader auto-injects URL search params as props", () => { return null; }); - await resolveValue( - { __resolveType: KEY, slug: "sabonete" }, - undefined, - { - url: "https://store.com/produto/sabonete/p?skuId=12345&size=M", - path: "/produto/sabonete/p", - }, - ); + await resolveValue({ __resolveType: KEY, slug: "sabonete" }, undefined, { + url: "https://store.com/produto/sabonete/p?skuId=12345&size=M", + path: "/produto/sabonete/p", + }); expect(calls).toHaveLength(1); expect(calls[0]).toMatchObject({ @@ -243,11 +239,10 @@ describe("commerce loader auto-injects URL search params as props", () => { return null; }); - await resolveValue( - { __resolveType: KEY, skuId: "cms-locked-sku" }, - undefined, - { url: "https://store.com/p?skuId=url-value", path: "/p" }, - ); + await resolveValue({ __resolveType: KEY, skuId: "cms-locked-sku" }, undefined, { + url: "https://store.com/p?skuId=url-value", + path: "/p", + }); expect(calls[0]?.skuId).toBe("cms-locked-sku"); }); @@ -358,7 +353,10 @@ describe("commerce loader resolves legacy .ts-suffixed resolveType", () => { await resolveValue( { __resolveType: "shopify/loaders/ProductDetailsPage.ts", slug: "oversize-t-shirt-123" }, undefined, - { url: "https://store.com/products/oversize-t-shirt-123", path: "/products/oversize-t-shirt-123" }, + { + url: "https://store.com/products/oversize-t-shirt-123", + path: "/products/oversize-t-shirt-123", + }, ); expect(calls).toHaveLength(1); @@ -509,9 +507,9 @@ describe("isEagerRequest — programmatic fetch detection", () => { }); it("falls back to matcherCtx.headers when no Request is present", () => { - expect( - isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } }), - ).toBe(true); + expect(isEagerRequest({ userAgent: HUMAN_UA, headers: { "sec-fetch-dest": "empty" } })).toBe( + true, + ); }); it("a request with no Sec-Fetch headers stays deferred (no UA bot, no override)", () => { diff --git a/packages/blocks/src/cms/resolve.ts b/packages/blocks/src/cms/resolve.ts index b9f483a8..9880ea80 100644 --- a/packages/blocks/src/cms/resolve.ts +++ b/packages/blocks/src/cms/resolve.ts @@ -1,10 +1,3 @@ -import { - type ActionConfig, - inferLoaderTags, - type LoaderConfig, - registerActionSchemas, - registerLoaderSchemas, -} from "./schema"; import { getMatchersOverride, getRuleOverrideId, hasMatchersOverride } from "../matchers/override"; import { getMeter, MetricNames, withTracing } from "../middleware/observability"; import { djb2Hex } from "../sdk/djb2"; @@ -13,6 +6,13 @@ import { withInflightTimeout } from "../sdk/inflightTimeout"; import { normalizeUrlsInObject } from "../sdk/normalizeUrls"; import { findPageByPath, loadBlocks } from "./loader"; import { getOnBeforeResolveProps, getSection, registerOnBeforeResolveProps } from "./registry"; +import { + type ActionConfig, + inferLoaderTags, + type LoaderConfig, + registerActionSchemas, + registerLoaderSchemas, +} from "./schema"; import { isLayoutSection, markSectionDegraded, runSingleSectionLoader } from "./sectionLoaders"; // globalThis-backed: share state across Vite server function split modules @@ -235,7 +235,7 @@ function isEagerSection(key: string): boolean { * during hydration (search filters, configurators, etc.). */ export function registerNeverDeferSections(keys: string[]): void { - const set: Set = G.__deco.neverDeferSectionKeys ??= new Set(); + const set: Set = (G.__deco.neverDeferSectionKeys ??= new Set()); for (const k of keys) set.add(k); } @@ -350,8 +350,7 @@ function hasForceEagerParam(ctx?: MatcherContext): boolean { */ function isProgrammaticFetch(ctx?: MatcherContext): boolean { if (ctx?.isClientNavigation) return false; - const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? - ctx?.headers?.["sec-fetch-dest"]; + const dest = ctx?.request?.headers.get("sec-fetch-dest") ?? ctx?.headers?.["sec-fetch-dest"]; return dest === "empty"; } @@ -362,8 +361,7 @@ function isProgrammaticFetch(ctx?: MatcherContext): boolean { * to gate both section deferral and page-SEO commerce resolution. */ export function isEagerRequest(ctx?: MatcherContext): boolean { - return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || - isProgrammaticFetch(ctx); + return isBot(ctx?.userAgent) || hasForceEagerParam(ctx) || isProgrammaticFetch(ctx); } /** @@ -743,7 +741,9 @@ function evaluateVariantRule( const already = ctx.flags?.find((f) => f.name === meta.name && f.pct === meta.pct); if (already) return already.value; - const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find((f) => f.name === meta.name); + const stored = parseSegmentCookie(ctx.cookies?.[SEGMENT_COOKIE]).find( + (f) => f.name === meta.name, + ); // pct === -1 marks a classic-deco segment without a fingerprint — honor it // (stay sticky) instead of re-rolling. A stale fingerprint re-rolls. const useStored = stored && (stored.pct === -1 || stored.pct === meta.pct); @@ -1297,9 +1297,7 @@ function sectionIgnoresStructuredData(props: Record): boolean { * serialized into the HTML. Lightweight literal props (title, description, * canonical, …) are preserved. */ -function stripCommerceLoaderProps( - rawProps: Record, -): Record { +function stripCommerceLoaderProps(rawProps: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(rawProps)) { if (resolvesToCommerceLoader(v)) continue; diff --git a/packages/blocks/src/cms/schema.ts b/packages/blocks/src/cms/schema.ts index 30131f66..76c4dd65 100644 --- a/packages/blocks/src/cms/schema.ts +++ b/packages/blocks/src/cms/schema.ts @@ -1100,10 +1100,7 @@ export interface ComposeMetaOptions { framework?: string; } -export function composeMeta( - siteMeta: MetaResponse, - options?: ComposeMetaOptions, -): MetaResponse { +export function composeMeta(siteMeta: MetaResponse, options?: ComposeMetaOptions): MetaResponse { // Idempotency guard. composeMeta is NOT structurally idempotent — it appends // the framework section refs to `root.sections.anyOf` (and `__SECTION_REF__`), // so composing an already-composed meta a second time duplicates those refs. diff --git a/packages/blocks/src/cms/sectionMixins.test.ts b/packages/blocks/src/cms/sectionMixins.test.ts index 34527102..484de956 100644 --- a/packages/blocks/src/cms/sectionMixins.test.ts +++ b/packages/blocks/src/cms/sectionMixins.test.ts @@ -17,8 +17,7 @@ import { // request must opt in by setting this flag — and `compose` must propagate // it whenever any input has it. const isRequestDependent = (fn: unknown): boolean => - typeof fn === "function" && - (fn as { __requestDependent?: boolean }).__requestDependent === true; + typeof fn === "function" && (fn as { __requestDependent?: boolean }).__requestDependent === true; const makeReq = (url = "https://store.example/foo?q=hello") => new Request(url, { headers: { "user-agent": "vitest" } }); @@ -87,12 +86,10 @@ describe("withSectionLoader", () => { it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => { const seen: Array> = []; - const sectionLoader = vi.fn( - async (props: Record) => { - seen.push({ ...props }); - return { ...props, sectionLoaderRan: true }; - }, - ); + const sectionLoader = vi.fn(async (props: Record) => { + seen.push({ ...props }); + return { ...props, sectionLoaderRan: true }; + }); const composed = compose( withSearchParam(), @@ -154,7 +151,14 @@ describe("request-dependent tagging (#206)", () => { }); it("compose does NOT set the flag when no input is request-dependent", () => { - expect(isRequestDependent(compose(async (p) => p, async (p) => p))).toBe(false); + expect( + isRequestDependent( + compose( + async (p) => p, + async (p) => p, + ), + ), + ).toBe(false); }); it("empty compose() is not request-dependent", () => { diff --git a/packages/blocks/src/cms/sectionMixins.ts b/packages/blocks/src/cms/sectionMixins.ts index dd2eb18c..d67543e9 100644 --- a/packages/blocks/src/cms/sectionMixins.ts +++ b/packages/blocks/src/cms/sectionMixins.ts @@ -135,9 +135,7 @@ export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn { * }); * ``` */ -export function withSectionLoader( - modImport: () => Promise, -): SectionLoaderFn { +export function withSectionLoader(modImport: () => Promise): SectionLoaderFn { return async (props, req, ctx) => { const mod = (await modImport()) as { loader?: unknown } | undefined; const loader = mod?.loader; diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 6512f759..e1ca070e 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -1,50 +1,51 @@ { - "name": "@decocms/nextjs", - "version": "0.0.0", - "type": "module", - "engines": { - "node": ">=24" - }, - "description": "Deco framework binding for Next.js App Router", - "repository": { - "type": "git", - "url": "https://github.com/decocms/blocks.git", - "directory": "packages/nextjs" - }, - "main": "./src/index.ts", - "exports": { - ".": "./src/index.ts", - "./routeHandlers": "./src/routeHandlers.ts", - "./setup": "./src/setup.ts", - "./config": { - "types": "./src/config.d.cts", - "default": "./src/config.cjs" - } - }, - "scripts": { - "build": "tsc", - "test": "vitest run --root ../.. packages/nextjs/", - "typecheck": "tsc --noEmit", - "lint:unused": "knip" - }, - "dependencies": { - "@decocms/blocks": "workspace:*", - "@decocms/blocks-admin": "workspace:*" - }, - "peerDependencies": { - "next": ">=15.0.0", - "react": "^19.0.0", - "react-dom": "^19.0.0" - }, - "devDependencies": { - "@types/react": "^19.0.0", - "@types/react-dom": "^19.0.0", - "knip": "^5.86.0", - "next": "^15.4.0", - "typescript": "^5.9.0" - }, - "publishConfig": { - "registry": "https://registry.npmjs.org", - "access": "public" - } + "name": "@decocms/nextjs", + "version": "0.0.0", + "type": "module", + "engines": { + "node": ">=24" + }, + "description": "Deco framework binding for Next.js App Router", + "repository": { + "type": "git", + "url": "https://github.com/decocms/blocks.git", + "directory": "packages/nextjs" + }, + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./routeHandlers": "./src/routeHandlers.ts", + "./setup": "./src/setup.ts", + "./config": { + "types": "./src/config.d.cts", + "default": "./src/config.cjs" + }, + "./middleware": "./src/draftMiddleware.ts" + }, + "scripts": { + "build": "tsc", + "test": "vitest run --root ../.. packages/nextjs/", + "typecheck": "tsc --noEmit", + "lint:unused": "knip" + }, + "dependencies": { + "@decocms/blocks": "workspace:*", + "@decocms/blocks-admin": "workspace:*" + }, + "peerDependencies": { + "next": ">=15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "knip": "^5.86.0", + "next": "^15.4.0", + "typescript": "^5.9.0" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + } } diff --git a/packages/nextjs/src/DecoPageRenderer.test.tsx b/packages/nextjs/src/DecoPageRenderer.test.tsx index f2ffa492..1f524379 100644 --- a/packages/nextjs/src/DecoPageRenderer.test.tsx +++ b/packages/nextjs/src/DecoPageRenderer.test.tsx @@ -1,6 +1,6 @@ +import { registerSectionsSync } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { registerSectionsSync } from "@decocms/blocks/cms"; import { DecoPageRenderer } from "./DecoPageRenderer"; function Hero({ label }: { label?: string }) { @@ -16,8 +16,18 @@ describe("DecoPageRenderer (next)", () => { const element = await DecoPageRenderer({ sections: [ - { key: "site/sections/NextPageA.tsx", component: "site/sections/NextPageA.tsx", props: { label: "first" }, index: 0 } as any, - { key: "site/sections/NextPageB.tsx", component: "site/sections/NextPageB.tsx", props: { label: "second" }, index: 1 } as any, + { + key: "site/sections/NextPageA.tsx", + component: "site/sections/NextPageA.tsx", + props: { label: "first" }, + index: 0, + } as any, + { + key: "site/sections/NextPageB.tsx", + component: "site/sections/NextPageB.tsx", + props: { label: "second" }, + index: 1, + } as any, ], pagePath: "/", }); diff --git a/packages/nextjs/src/DecoPageRenderer.tsx b/packages/nextjs/src/DecoPageRenderer.tsx index 37f8452a..77d5f9d8 100644 --- a/packages/nextjs/src/DecoPageRenderer.tsx +++ b/packages/nextjs/src/DecoPageRenderer.tsx @@ -1,8 +1,8 @@ import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms"; import { resolveDeferredSection } from "@decocms/blocks/cms"; -import { SectionRenderer } from "./SectionRenderer"; -import { DeferredSectionBoundary } from "./DeferredSection"; import { cloneElement, type ReactElement, type ReactNode } from "react"; +import { DeferredSectionBoundary } from "./DeferredSection"; +import { SectionRenderer } from "./SectionRenderer"; interface DecoPageRendererProps { sections: ResolvedSection[]; @@ -19,7 +19,9 @@ type PageItem = function mergeSections(resolved: ResolvedSection[], deferred: DeferredSection[]): PageItem[] { const items: PageItem[] = []; - resolved.forEach((section, i) => items.push({ type: "eager", section, sort: section.index ?? i })); + resolved.forEach((section, i) => + items.push({ type: "eager", section, sort: section.index ?? i }), + ); for (const d of deferred) items.push({ type: "deferred", deferred: d, sort: d.index }); items.sort((a, b) => a.sort - b.sort); return items; @@ -54,10 +56,12 @@ export async function DecoPageRenderer({ for (const d of deferredSections) { deferredPromises.set( d.index, - resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then((resolved) => { - if (!resolved) return null; - return { ...resolved, index: d.index }; - }), + resolveDeferredSection(d.component, d.rawProps ?? {}, pagePath, matcherCtx).then( + (resolved) => { + if (!resolved) return null; + return { ...resolved, index: d.index }; + }, + ), ); } diff --git a/packages/nextjs/src/DecoRootLayout.tsx b/packages/nextjs/src/DecoRootLayout.tsx index 22205fdc..159304ce 100644 --- a/packages/nextjs/src/DecoRootLayout.tsx +++ b/packages/nextjs/src/DecoRootLayout.tsx @@ -1,6 +1,6 @@ -import type { ReactNode } from "react"; import { LiveControls } from "@decocms/blocks/hooks"; import { ANALYTICS_SCRIPT } from "@decocms/blocks/sdk/analytics"; +import type { ReactNode } from "react"; function buildDecoEventsBootstrap(account?: string): string { const accountJson = JSON.stringify(account ?? ""); diff --git a/packages/nextjs/src/DeferredSection.test.tsx b/packages/nextjs/src/DeferredSection.test.tsx index e7ced8e7..a6deabc8 100644 --- a/packages/nextjs/src/DeferredSection.test.tsx +++ b/packages/nextjs/src/DeferredSection.test.tsx @@ -1,6 +1,6 @@ +import { registerSectionsSync } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { registerSectionsSync } from "@decocms/blocks/cms"; import { DeferredSectionBoundary } from "./DeferredSection"; function Hero({ label }: { label?: string }) { diff --git a/packages/nextjs/src/DeferredSection.tsx b/packages/nextjs/src/DeferredSection.tsx index 87b1b508..d0929477 100644 --- a/packages/nextjs/src/DeferredSection.tsx +++ b/packages/nextjs/src/DeferredSection.tsx @@ -1,6 +1,6 @@ -import { Suspense, type ReactNode } from "react"; import type { DeferredSection, ResolvedSection } from "@decocms/blocks/cms"; import { SectionErrorBoundary } from "@decocms/blocks/hooks"; +import { type ReactNode, Suspense } from "react"; import { SectionRenderer } from "./SectionRenderer"; interface DeferredSectionBoundaryProps { diff --git a/packages/nextjs/src/SectionRenderer.test.tsx b/packages/nextjs/src/SectionRenderer.test.tsx index fcae14d5..e6ac0d15 100644 --- a/packages/nextjs/src/SectionRenderer.test.tsx +++ b/packages/nextjs/src/SectionRenderer.test.tsx @@ -1,6 +1,6 @@ +import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { registerSection, registerSections, registerSectionsSync } from "@decocms/blocks/cms"; import { SectionRenderer } from "./SectionRenderer"; function Hero({ label }: { label?: string }) { diff --git a/packages/nextjs/src/SectionRenderer.tsx b/packages/nextjs/src/SectionRenderer.tsx index 206ac650..040e270c 100644 --- a/packages/nextjs/src/SectionRenderer.tsx +++ b/packages/nextjs/src/SectionRenderer.tsx @@ -1,11 +1,7 @@ -import { createElement, type ReactNode } from "react"; -import { - getSection, - getSectionOptions, - getSyncComponent, -} from "@decocms/blocks/cms"; import type { ResolvedSection } from "@decocms/blocks/cms"; +import { getSection, getSectionOptions, getSyncComponent } from "@decocms/blocks/cms"; import { SectionErrorBoundary } from "@decocms/blocks/hooks"; +import { createElement, type ReactNode } from "react"; import { ClientOnlySection } from "./ClientOnlySection"; interface SectionRendererProps { diff --git a/packages/nextjs/src/config.cjs b/packages/nextjs/src/config.cjs index 56ee330f..ce1714c9 100644 --- a/packages/nextjs/src/config.cjs +++ b/packages/nextjs/src/config.cjs @@ -12,6 +12,7 @@ * (`app/deco/[[...deco]]/route.ts` + createDecoRouteHandlers) serves * the whole protocol. * 2. transpilePackages for the raw-TS @decocms packages. + * 3. Response headers that keep a draft-preview render out of every cache. */ const DECO_REWRITES = [ { source: "/.decofile", destination: "/deco/decofile" }, @@ -21,8 +22,56 @@ const DECO_REWRITES = [ const DECO_TRANSPILE = ["@decocms/blocks", "@decocms/blocks-admin", "@decocms/nextjs"]; +/** + * A draft render must never be cached or indexed: with the pointer in a cookie + * it shares a URL with the published page, so a shared cache keyed on URL alone + * would serve unpublished content to a real visitor. + * + * Two rules, not one: `has` entries within a rule are ANDed, and the two + * signals never coincide. On ENTRY the pointer is in the query and the cookie + * has not been set yet (middleware sets it in the response); on every + * navigation after that it is the cookie and the query is gone. + * + * MEASURED LIMIT — do not assume `no-store` is in force. On a *dynamic* App + * Router response Next reserves `Cache-Control` and `Vary` and overwrites + * whatever we set, from here AND from middleware: the wire shows + * `Cache-Control: no-cache, must-revalidate` and + * `Vary: rsc, next-router-state-tree, …`. Verified that these rules do match + * (a marker header on the same rules came through on both signals and was + * correctly absent otherwise) — it is those two header names specifically that + * Next owns. `X-Robots-Tag` does get through. + * + * Why that is still safe: `no-cache` is not "don't cache", it is "never reuse + * without revalidating with the origin", so a compliant shared cache cannot + * serve a stored draft to a different visitor — it has to ask us first, and we + * answer per-request. That also covers the loss of `Vary: Cookie`. The residual + * risk is a cache that ignores `no-cache`; closing that needs a CDN-level rule, + * which is a deployment concern rather than something this package can set. + * `Cache-Control`/`Vary` are kept here so the intent is explicit and so they + * apply anywhere Next is not overriding them. + */ +const DRAFT_NO_STORE_HEADERS = [ + { key: "Cache-Control", value: "no-store, private" }, + { key: "Vary", value: "Cookie" }, + { key: "X-Robots-Tag", value: "noindex, nofollow" }, +]; + +const DECO_DRAFT_HEADERS = [ + { + source: "/:path*", + has: [{ type: "query", key: "__draft" }], + headers: DRAFT_NO_STORE_HEADERS, + }, + { + source: "/:path*", + has: [{ type: "cookie", key: "__deco_draft" }], + headers: DRAFT_NO_STORE_HEADERS, + }, +]; + function withDeco(nextConfig = {}) { const userRewrites = nextConfig.rewrites; + const userHeaders = nextConfig.headers; return { ...nextConfig, transpilePackages: [...new Set([...(nextConfig.transpilePackages ?? []), ...DECO_TRANSPILE])], @@ -31,7 +80,13 @@ function withDeco(nextConfig = {}) { if (Array.isArray(user)) return [...DECO_REWRITES, ...user]; return { ...user, beforeFiles: [...DECO_REWRITES, ...(user.beforeFiles ?? [])] }; }, + async headers() { + const user = typeof userHeaders === "function" ? await userHeaders() : (userHeaders ?? []); + // Draft rules first: a site's own catch-all Cache-Control must not win + // over the one thing standing between a draft and a shared cache. + return [...DECO_DRAFT_HEADERS, ...user]; + }, }; } -module.exports = { withDeco, DECO_REWRITES }; +module.exports = { withDeco, DECO_REWRITES, DECO_DRAFT_HEADERS }; diff --git a/packages/nextjs/src/config.test.ts b/packages/nextjs/src/config.test.ts index 57fb3d24..8eeec50a 100644 --- a/packages/nextjs/src/config.test.ts +++ b/packages/nextjs/src/config.test.ts @@ -45,3 +45,42 @@ describe("withDeco", () => { expect(cfg.transpilePackages).toContain("other"); }); }); + +describe("withDeco draft headers", () => { + it("adds no-store/no-index rules for both draft signals", async () => { + const headers = await withDeco({}).headers!(); + // Two rules, not one: `has` entries within a rule are ANDed, and the query + // (entry) and cookie (navigation) signals never coincide. + expect(headers).toHaveLength(2); + expect(headers[0].has).toEqual([{ type: "query", key: "__draft" }]); + expect(headers[1].has).toEqual([{ type: "cookie", key: "__deco_draft" }]); + + for (const rule of headers) { + const byKey = Object.fromEntries(rule.headers.map((h) => [h.key, h.value])); + // `no-store` is what actually stops a shared cache serving unpublished + // content to a real visitor — the pointer being a cookie means draft and + // published share a URL. + expect(byKey["Cache-Control"]).toBe("no-store, private"); + expect(byKey.Vary).toBe("Cookie"); + expect(byKey["X-Robots-Tag"]).toBe("noindex, nofollow"); + } + }); + + it("keeps a site's own headers, with the draft rules taking precedence", async () => { + const cfg = withDeco({ + headers: async () => [ + { source: "/:path*", headers: [{ key: "Cache-Control", value: "public, max-age=3600" }] }, + ], + }); + const headers = await cfg.headers!(); + expect(headers).toHaveLength(3); + // A site's catch-all cache policy must not out-rank the draft rules. + expect(headers[0].has).toBeDefined(); + expect(headers[1].has).toBeDefined(); + expect(headers[2].headers[0].value).toBe("public, max-age=3600"); + }); + + it("leaves a site without headers() untouched apart from the draft rules", async () => { + expect(await withDeco({}).headers!()).toHaveLength(2); + }); +}); diff --git a/packages/nextjs/src/createDecoPage.draft.test.tsx b/packages/nextjs/src/createDecoPage.draft.test.tsx new file mode 100644 index 00000000..63740689 --- /dev/null +++ b/packages/nextjs/src/createDecoPage.draft.test.tsx @@ -0,0 +1,91 @@ +/** + * Draft-preview wiring in `createDecoPage`. + * + * Separate from `createDecoPage.test.tsx` because this file has to mock + * `next/headers`, and that mock would otherwise apply to the plain + * (non-draft) cases too — which are worth keeping honest about never + * touching a dynamic API at all. + */ + +import { registerSections, setBlocks } from "@decocms/blocks/cms"; +import { renderToString } from "react-dom/server"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +// The gate must stop us reaching cookies() at all when the feature is off. +const cookiesMock = vi.fn(async () => ({ get: () => undefined })); +vi.mock("next/headers", () => ({ + cookies: () => cookiesMock(), + headers: async () => new Headers(), +})); + +// `connection()` is Next's runtime opt-out from static rendering; outside a +// request scope it throws, so it is stubbed to a no-op here. +const connectionMock = vi.fn(async () => {}); +vi.mock("next/server", () => ({ connection: () => connectionMock() })); + +const { createDecoPage } = await import("./createDecoPage"); + +function Hero({ label }: { label?: string }) { + return

{`hero-${label ?? "none"}`}

; +} + +function seedPublishedHome() { + registerSections({ "site/sections/DraftHero.tsx": async () => ({ default: Hero }) }); + setBlocks({ + "pages-home": { + path: "/", + sections: [{ __resolveType: "site/sections/DraftHero.tsx", label: "published" }], + }, + }); +} + +afterEach(() => { + cookiesMock.mockClear(); + connectionMock.mockClear(); + delete process.env.DECO_ALLOWED_PREVIEW_HOSTS; + delete process.env.DECO_SANDBOX_ORIGIN_SUFFIXES; +}); + +describe("createDecoPage — draft preview gate", () => { + it("never reads cookies() when the feature is off", async () => { + // Load-bearing, not a micro-optimisation: cookies() is a dynamic API, so + // calling it unconditionally would opt EVERY page of EVERY site out of + // static/ISR rendering the moment this package is upgraded. + seedPublishedHome(); + + const { default: Page } = createDecoPage({ siteName: "test-site" }); + const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) })); + + expect(html).toContain("hero-published"); + expect(cookiesMock).not.toHaveBeenCalled(); + expect(connectionMock).not.toHaveBeenCalled(); + }); + + it("stays off when the flag is set but no origin suffix is configured", async () => { + // Both halves are required — a bare flag with nowhere to fetch from is + // inert, mirroring Fast Deploy's opt-in. + process.env.DECO_DRAFT_PREVIEW = "1"; + seedPublishedHome(); + + const { default: Page } = createDecoPage({ siteName: "test-site" }); + const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) })); + + expect(html).toContain("hero-published"); + expect(cookiesMock).not.toHaveBeenCalled(); + }); + + it("reads cookies() once the feature is fully configured", async () => { + process.env.DECO_ALLOWED_PREVIEW_HOSTS = "preview.example"; + seedPublishedHome(); + + const { default: Page } = createDecoPage({ siteName: "test-site" }); + const html = renderToString(await Page({ params: Promise.resolve({ slug: [] }) })); + + // No draft pointer on the request, so the page still renders published — + // enabling the feature must not change what an ordinary visitor sees. + expect(html).toContain("hero-published"); + expect(cookiesMock).toHaveBeenCalled(); + // Nothing was bound, so no need to force the page dynamic. + expect(connectionMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/nextjs/src/createDecoPage.test.tsx b/packages/nextjs/src/createDecoPage.test.tsx index 19c10295..5bd1c0f6 100644 --- a/packages/nextjs/src/createDecoPage.test.tsx +++ b/packages/nextjs/src/createDecoPage.test.tsx @@ -1,6 +1,6 @@ +import { registerSections, setBlocks } from "@decocms/blocks/cms"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; -import { registerSections, setBlocks } from "@decocms/blocks/cms"; import { createDecoPage } from "./createDecoPage"; function Hero({ label }: { label?: string }) { @@ -58,8 +58,6 @@ describe("createDecoPage (next)", () => { const { default: Page } = createDecoPage({ siteName: "test-site" }); - await expect( - Page({ params: Promise.resolve({ slug: ["missing"] }) }), - ).rejects.toThrow(); + await expect(Page({ params: Promise.resolve({ slug: ["missing"] }) })).rejects.toThrow(); }); }); diff --git a/packages/nextjs/src/createDecoPage.tsx b/packages/nextjs/src/createDecoPage.tsx index b782381c..b04887f7 100644 --- a/packages/nextjs/src/createDecoPage.tsx +++ b/packages/nextjs/src/createDecoPage.tsx @@ -1,14 +1,17 @@ -import { cache } from "react"; -import { notFound } from "next/navigation"; -import type { Metadata } from "next"; import { + type DecoPageResult, extractSeoFromProps, extractSeoFromSections, - resolveDecoPage, - type DecoPageResult, + isDraftPreviewEnabled, type PageSeo, + resolveDecoPage, } from "@decocms/blocks/cms"; +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { connection } from "next/server"; +import { cache } from "react"; import { DecoPageRenderer } from "./DecoPageRenderer"; +import { type DraftSearchParams, ensureDraft } from "./draft"; interface CreateDecoPageOptions { siteName: string; @@ -16,6 +19,12 @@ interface CreateDecoPageOptions { interface PageProps { params: Promise<{ slug?: string[] }>; + /** + * Next always supplies this; optional here so existing callers (and unit + * tests) that construct props by hand keep compiling. Draft preview reads + * `?__draft=` from it — see bindDraftOnce. + */ + searchParams?: Promise; } function pathFromSlug(slug: string[] | undefined): string { @@ -67,8 +76,48 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) { const resolveForPath = cache(async (pathname: string) => resolveDecoPage(pathname, {})); - async function generateMetadata({ params }: PageProps): Promise { + /** + * Bind this request's draft decofile, at most once, BEFORE anything resolves + * a page. + * + * Ordering is load-bearing twice over: + * + * 1. `resolveDecoPage` calls `loadBlocks()`, so a draft bound afterwards + * would resolve the page against published content and silently render + * the wrong thing. + * 2. `resolveForPath` is `cache()`d per request and Next may run + * `generateMetadata` and the page body concurrently — whichever resolves + * first wins for both. So this must be awaited at the top of BOTH, not + * just the page. `cache()` here makes the second call free. + * + * Gated on `isDraftPreviewEnabled()` (DECO_ALLOWED_PREVIEW_HOSTS non-empty) — a + * plain env read, not a dynamic API — + * so sites that never opt in behave exactly as before. That gate is doing + * real work: `cookies()` and `searchParams` are both dynamic, and touching + * them unconditionally would opt EVERY page out of static/ISR rendering. + * + * KNOWN COST: with the flag on, every page served by this route becomes + * dynamic, because reading the pointer requires `cookies()`. Acceptable + * while the feature is opt-in; the fix, when it needs to run on a + * statically-rendered production site, is for middleware to rewrite draft + * requests onto a separate dynamic route so ordinary traffic keeps its cache. + */ + const bindDraftOnce = cache(async (searchParams?: DraftSearchParams): Promise => { + if (!isDraftPreviewEnabled()) return false; + + const bound = await ensureDraft(searchParams); + + // A draft render must never be cached — ISR or the Full Route Cache would + // hand unpublished content to a real visitor. `revalidate` is a static + // export and cannot vary per request, so opt out at runtime instead. + if (bound) await connection(); + + return bound; + }); + + async function generateMetadata({ params, searchParams }: PageProps): Promise { const { slug } = await params; + await bindDraftOnce(await searchParams); const page = await resolveForPath(pathFromSlug(slug)); if (!page) return {}; @@ -81,9 +130,11 @@ export function createDecoPage({ siteName }: CreateDecoPageOptions) { }; } - async function Page({ params }: PageProps) { + async function Page({ params, searchParams }: PageProps) { const { slug } = await params; const pathname = pathFromSlug(slug); + // Before resolveForPath — see bindDraftOnce. + await bindDraftOnce(await searchParams); const page = await resolveForPath(pathname); if (!page) notFound(); diff --git a/packages/nextjs/src/draft.test.ts b/packages/nextjs/src/draft.test.ts new file mode 100644 index 00000000..7345cee6 --- /dev/null +++ b/packages/nextjs/src/draft.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { DRAFT_COOKIE_OPTIONS, DRAFT_PARAM, decideDraft } from "./draft"; + +function urlWith(param?: string): URL { + const url = new URL("https://site.example/some/page"); + if (param !== undefined) url.searchParams.set(DRAFT_PARAM, param); + return url; +} + +describe("decideDraft", () => { + it("enters draft mode from the param and sets the cookie", () => { + expect(decideDraft(urlWith("abc@v1"), null)).toEqual({ + pointer: "abc@v1", + setCookie: "abc@v1", + clearCookie: false, + }); + }); + + it("carries the draft across navigation via the cookie alone", () => { + // The param is gone — this is the in-preview link click that a + // param-only design would silently drop back to published. + expect(decideDraft(urlWith(), "abc@v1")).toEqual({ + pointer: "abc@v1", + setCookie: null, + clearCookie: false, + }); + }); + + it("lets the param override a stale cookie", () => { + // Studio navigating to a newer version must win over the older pointer + // still sitting in the cookie, or a save would never be reflected. + expect(decideDraft(urlWith("abc@v2"), "abc@v1")).toEqual({ + pointer: "abc@v2", + setCookie: "abc@v2", + clearCookie: false, + }); + }); + + it("leaves draft mode on ?__draft=off, even with a cookie set", () => { + expect(decideDraft(urlWith("off"), "abc@v1")).toEqual({ + pointer: null, + setCookie: null, + clearCookie: true, + }); + }); + + it("is inert for an ordinary request", () => { + expect(decideDraft(urlWith(), null)).toEqual({ + pointer: null, + setCookie: null, + clearCookie: false, + }); + }); +}); + +describe("DRAFT_COOKIE_OPTIONS", () => { + it("is set up to survive a cross-site preview iframe", () => { + // The preview iframe is cross-site (Studio embeds the production origin), + // so the cookie is third-party. Without SameSite=None+Secure it is never + // sent; without Partitioned (CHIPS) it is dropped as browsers wind down + // unpartitioned third-party cookies. These attributes are load-bearing, + // not incidental. + expect(DRAFT_COOKIE_OPTIONS.sameSite).toBe("none"); + expect(DRAFT_COOKIE_OPTIONS.secure).toBe(true); + expect(DRAFT_COOKIE_OPTIONS.partitioned).toBe(true); + expect(DRAFT_COOKIE_OPTIONS.httpOnly).toBe(true); + }); + + it("is short-lived so a stale pointer can't pin an old version", () => { + expect(DRAFT_COOKIE_OPTIONS.maxAge).toBeLessThanOrEqual(60 * 60); + }); +}); diff --git a/packages/nextjs/src/draft.ts b/packages/nextjs/src/draft.ts new file mode 100644 index 00000000..89123464 --- /dev/null +++ b/packages/nextjs/src/draft.ts @@ -0,0 +1,200 @@ +/** + * Draft preview — the Next.js binding. + * + * `@decocms/blocks`'s `draftSource` owns the framework-agnostic half (pointer + * parsing, origin construction, fetch, version cache). This file binds a + * resolved draft to the current request. + * + * ## Why React `cache()` and not AsyncLocalStorage + * + * App Router never enters `RequestContext.run` (that is a TanStack/Workers + * path), and ALS cannot help here anyway: you cannot wrap `ALS.run()` around a + * component's children, because the children render later, outside the call. + * `cache()` gives a per-request memoized value in RSC, which is exactly the + * scope needed — verified concurrently, see `draft.test.ts`. + * + * ## Why this must be awaited by the PAGE, not a layout + * + * A layout's `await` does NOT gate its children: App Router renders a layout + * and its children concurrently, so sections call `loadBlocks()` before a + * layout-level resolve lands, and silently render published content. The + * resolve has to happen somewhere that returns the subtree *after* awaiting — + * i.e. the page component. `ensureDraft()` exists so each site makes one call + * instead of re-deriving that ordering rule. + */ +import { + isDraftHostAllowed, + resolveDraftDecofile, + setDraftOverrideGetter, +} from "@decocms/blocks/cms"; +import { cookies, headers } from "next/headers"; +import { cache } from "react"; + +/** Cookie that carries the pointer across in-preview navigation. */ +export const DRAFT_COOKIE = "__deco_draft"; +/** Query param that enters draft mode (and, with `off`, leaves it). */ +export const DRAFT_PARAM = "__draft"; + +/** + * Request-scoped slot. + * + * `cache()` memoizes per-request, so every call within one request gets the + * same object and a different one per request. Mutable by design: the page + * fills it before returning its subtree, and nested sections read it + * synchronously through `loadBlocks()`. + */ +const draftSlot = cache((): { blocks: Record | null } => ({ + blocks: null, +})); + +/** + * Register the request-scoped getter with the runtime. + * + * Idempotent and safe to call at module scope: outside a request `cache()` + * still returns an object, whose `blocks` is null, so `loadBlocks()` sees no + * override and behaves exactly as before. + */ +let registered = false; +export function registerDraftOverride(): void { + if (registered) return; + registered = true; + setDraftOverrideGetter(() => draftSlot().blocks); +} + +/** A page's `searchParams` prop, before it is narrowed. */ +export type DraftSearchParams = Record; + +/** First value of a search param that may legitimately repeat. */ +function firstParam(searchParams: DraftSearchParams | undefined, key: string): string | null { + const raw = searchParams?.[key]; + if (Array.isArray(raw)) return raw[0] ?? null; + return raw ?? null; +} + +/** + * The pointer for this request: the query param wins, the cookie carries + * navigation, `off` exits. + * + * Read directly from the page rather than trusted from a request header. The + * param has to take precedence or a save would never surface — Studio + * navigating to a new version would keep rendering whatever older pointer is + * still sitting in the cookie. + */ +export function selectDraftPointer( + searchParams: DraftSearchParams | undefined, + cookieValue: string | null | undefined, +): string | null { + const param = firstParam(searchParams, DRAFT_PARAM); + if (param === "off") return null; + if (param) return param; + return cookieValue ?? null; +} + +/** + * Resolve the request's draft, if any, and bind it for the rest of the render. + * + * Call this from the PAGE, awaited, before returning the subtree: + * + * ```tsx + * export default async function Page({ searchParams }) { + * await ensureDraft(await searchParams); + * return ; + * } + * ``` + * + * Reads the param from the page's own `searchParams` and the cookie via + * `cookies()`. There is deliberately no request header in the path: the page + * owns the decision, so a draft keeps working on routes the middleware matcher + * never sees, and there is one less forgeable input to reason about. (The + * pointer was never a secret — the draft id (the token's authority) is the capability — so a + * client supplying one directly is equivalent to typing the query param.) + * + * Returns whether a draft was bound, so callers can surface an explicit + * "draft unavailable" state instead of silently showing published content — + * the failure mode most likely to mislead someone reviewing their own edits. + */ +export async function ensureDraft(searchParams?: DraftSearchParams): Promise { + registerDraftOverride(); + + const cookieStore = await cookies(); + const pointer = selectDraftPointer(searchParams, cookieStore.get(DRAFT_COOKIE)?.value); + if (!pointer) return false; + + // Host gate, checked only once a pointer exists (headers() is a dynamic + // API): the same build may serve the preview domain and the production + // domain, and only hosts named in DECO_ALLOWED_PREVIEW_HOSTS may render + // drafts — production stays published no matter what the URL carries. + const requestHeaders = await headers(); + const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"); + if (!isDraftHostAllowed(host)) return false; + + const blocks = await resolveDraftDecofile({ pointer }); + if (!blocks) return false; + + draftSlot().blocks = blocks; + return true; +} + +// --------------------------------------------------------------------------- +// Middleware helper +// --------------------------------------------------------------------------- + +/** What middleware should do with this request. */ +export interface DraftMiddlewareDecision { + /** The active pointer, or null. Drives the cache/indexing headers. */ + pointer: string | null; + /** Set the cookie to this value (entering draft mode). */ + setCookie: string | null; + /** Clear the cookie (leaving draft mode via `?__draft=off`). */ + clearCookie: boolean; +} + +/** + * Decide the draft state for a request, from `?__draft=` and the cookie. + * + * The param is authoritative on entry and the cookie carries subsequent + * in-preview navigation — a param alone dies on the first link click, and a + * cookie alone cannot be relied on: the preview iframe is cross-site, so the + * cookie is third-party and may be blocked outright (Safari ITP) or + * partitioned (Chrome CHIPS). Entry therefore never depends on cookie support. + * + * `?__draft=off` leaves draft mode, so a session can be ended deliberately + * rather than waiting for a cookie to expire. + * + * Pure and framework-free so it can be unit-tested without a Next request; the + * caller applies the decision to its own `NextResponse`. + */ +export function decideDraft( + url: URL, + cookieValue: string | null | undefined, +): DraftMiddlewareDecision { + const param = url.searchParams.get(DRAFT_PARAM); + + if (param === "off") { + return { pointer: null, setCookie: null, clearCookie: true }; + } + if (param) { + return { pointer: param, setCookie: param, clearCookie: false }; + } + if (cookieValue) { + return { pointer: cookieValue, setCookie: null, clearCookie: false }; + } + return { pointer: null, setCookie: null, clearCookie: false }; +} + +/** + * Cookie attributes for the draft pointer. + * + * `SameSite=None; Secure` is mandatory for a cross-site iframe, and + * `Partitioned` (CHIPS) is what keeps it working as browsers wind down + * unpartitioned third-party cookies. Short-lived: a draft session is minutes, + * and a stale pointer would keep pinning an old version. + */ +export const DRAFT_COOKIE_OPTIONS = { + httpOnly: true, + secure: true, + sameSite: "none", + partitioned: true, + path: "/", + maxAge: 60 * 30, +} as const; diff --git a/packages/nextjs/src/draftMiddleware.test.ts b/packages/nextjs/src/draftMiddleware.test.ts new file mode 100644 index 00000000..de4d4ed2 --- /dev/null +++ b/packages/nextjs/src/draftMiddleware.test.ts @@ -0,0 +1,147 @@ +import { NextRequest, NextResponse } from "next/server"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { DRAFT_COOKIE } from "./draft"; +import { applyDraft, prepareDraft, rewriteToDraftRoute } from "./draftMiddleware"; + +beforeEach(() => { + // The middleware is host-gated; these tests run as the allowed host. + process.env.DECO_ALLOWED_PREVIEW_HOSTS = "site.example"; +}); +afterEach(() => { + delete process.env.DECO_ALLOWED_PREVIEW_HOSTS; +}); + +function request(url: string, cookie?: string): NextRequest { + const req = new NextRequest(new URL(url), { + headers: cookie ? { cookie: `${DRAFT_COOKIE}=${cookie}` } : {}, + }); + return req; +} + +describe("prepareDraft", () => { + it("reads the pointer from the param", () => { + expect(prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")).pointer).toBe( + "abc.localhost@v1", + ); + }); + + it("reads the pointer from the cookie on a plain navigation", () => { + expect(prepareDraft(request("https://site.example/p", "abc.localhost@v1")).pointer).toBe( + "abc.localhost@v1", + ); + }); + + it("passes through when no env override is set — the page gate is authoritative", () => { + // The site-block hosts live in the server-components graph; middleware + // can't see them, so without the env kill switch it must not block. + delete process.env.DECO_ALLOWED_PREVIEW_HOSTS; + const req = request("https://anything.example/p?__draft=abc.localhost@v1"); + expect(prepareDraft(req).pointer).toBe("abc.localhost@v1"); + }); + + it("is inert on a host outside the allowlist — the production domain", () => { + // Same build, different Host: no cookie, no rewrite, nothing touched. + const req = request("https://fila.com.br/p?__draft=abc@v1"); + process.env.DECO_ALLOWED_PREVIEW_HOSTS = "fila.vtex.app"; + expect(prepareDraft(req)).toEqual({ pointer: null, setCookie: null, clearCookie: false }); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); + + it("ignores a client-supplied draft header — the page owns the decision", () => { + // There is no request-header path any more; the page reads searchParams + // and cookies itself, so a forged header is simply not an input. + const req = new NextRequest(new URL("https://site.example/p"), { + headers: { "x-deco-draft": "attacker.localhost@v1" }, + }); + expect(prepareDraft(req).pointer).toBeNull(); + }); +}); + +describe("applyDraft", () => { + it("sets a partitioned cross-site cookie when entering draft mode", () => { + const decision = prepareDraft(request("https://site.example/p?__draft=abc.localhost@v1")); + const res = applyDraft(NextResponse.next(), decision); + + const setCookie = res.headers.get("set-cookie") ?? ""; + expect(setCookie).toContain(`${DRAFT_COOKIE}=abc.localhost%40v1`); + // The preview iframe is cross-site, so these attributes are what make the + // cookie survive at all — not hardening extras. + expect(setCookie).toMatch(/SameSite=None/i); + expect(setCookie).toMatch(/Secure/i); + expect(setCookie).toMatch(/Partitioned/i); + expect(setCookie).toMatch(/HttpOnly/i); + }); + + it("marks a draft response uncacheable and unindexable", () => { + // With the pointer in a cookie, draft and published share a URL — a CDN + // keyed on URL alone would serve unpublished content to a real visitor. + const decision = prepareDraft(request("https://site.example/p", "abc.localhost@v1")); + const res = applyDraft(NextResponse.next(), decision); + + expect(res.headers.get("cache-control")).toBe("no-store, private"); + expect(res.headers.get("vary")).toBe("Cookie"); + expect(res.headers.get("x-robots-tag")).toBe("noindex, nofollow"); + }); + + it("leaves an ordinary response completely untouched", () => { + const decision = prepareDraft(request("https://site.example/p")); + const res = applyDraft(NextResponse.next(), decision); + + expect(res.headers.get("cache-control")).toBeNull(); + expect(res.headers.get("vary")).toBeNull(); + expect(res.headers.get("x-robots-tag")).toBeNull(); + expect(res.headers.get("set-cookie")).toBeNull(); + }); + + it("clears the cookie on ?__draft=off", () => { + const decision = prepareDraft( + request("https://site.example/p?__draft=off", "abc.localhost@v1"), + ); + expect(decision.clearCookie).toBe(true); + + const res = applyDraft(NextResponse.next(), decision); + const setCookie = res.headers.get("set-cookie") ?? ""; + // Deletion is a set with an immediate expiry. + expect(setCookie).toContain(DRAFT_COOKIE); + expect(setCookie).toMatch(/Max-Age=0|Expires=Thu, 01 Jan 1970/i); + // And the response is not treated as a draft render. + expect(res.headers.get("cache-control")).toBeNull(); + }); +}); + +describe("rewriteToDraftRoute", () => { + it("rewrites a drafted request onto the dynamic draft route", () => { + const req = request("https://site.example/blog/hello?__draft=abc.localhost@v1"); + const res = rewriteToDraftRoute(req, prepareDraft(req)); + const target = new URL(res?.headers.get("x-middleware-rewrite") ?? ""); + expect(target.pathname).toBe("/_draft/blog/hello"); + // The pointer must survive: the draft page reads it from searchParams. + expect(target.searchParams.get("__draft")).toBe("abc.localhost@v1"); + }); + + it("rewrites a cookie-only navigation too", () => { + const req = request("https://site.example/blog/hello", "abc.localhost@v1"); + const res = rewriteToDraftRoute(req, prepareDraft(req)); + expect(new URL(res?.headers.get("x-middleware-rewrite") ?? "").pathname).toBe( + "/_draft/blog/hello", + ); + }); + + it("leaves an ordinary request alone — ISR must not be disturbed", () => { + // The whole point: only drafted requests go dynamic. A shopper's request + // must reach the real, statically rendered route untouched. + const req = request("https://site.example/blog/hello"); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); + + it("never nests the prefix when middleware re-runs on the rewritten URL", () => { + const req = request("https://site.example/_draft/blog/hello?__draft=abc.localhost@v1"); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); + + it("does not rewrite when leaving draft mode", () => { + const req = request("https://site.example/blog/hello?__draft=off", "abc.localhost@v1"); + expect(rewriteToDraftRoute(req, prepareDraft(req))).toBeNull(); + }); +}); diff --git a/packages/nextjs/src/draftMiddleware.ts b/packages/nextjs/src/draftMiddleware.ts new file mode 100644 index 00000000..7b008ad7 --- /dev/null +++ b/packages/nextjs/src/draftMiddleware.ts @@ -0,0 +1,136 @@ +/** + * Draft preview — middleware half. + * + * Two composable calls, because sites already own their middleware and this + * must slot into it rather than replace it: + * + * ```ts + * // middleware.ts (Next <16) / proxy.ts (Next 16+) + * export function middleware(request: NextRequest) { + * const decision = prepareDraft(request); + * return applyDraft(NextResponse.next(), decision); + * } + * ``` + * + * The middleware owns only the cookie and the cache/indexing headers — a + * Server Component can read cookies but cannot set them, so that part has to + * live here. The pointer itself is read by the page. + * + * Lives on its own subpath so middleware (edge runtime) never imports the root + * barrel, which pulls in the client component graph. + */ + +import { isDraftHostAllowed } from "@decocms/blocks/cms"; +import { type NextRequest, NextResponse } from "next/server"; +import { + DRAFT_COOKIE, + DRAFT_COOKIE_OPTIONS, + type DraftMiddlewareDecision, + decideDraft, +} from "./draft"; + +/** + * Compute the draft decision for a request. + * + * Only the cookie and the response headers are the middleware's business now — + * the page reads the pointer itself from `searchParams` + `cookies()`, so + * nothing has to be forwarded through the request. That also means a draft + * still works on routes this middleware never matches. + */ +const INERT: DraftMiddlewareDecision = { pointer: null, setCookie: null, clearCookie: false }; + +export function prepareDraft(request: NextRequest): DraftMiddlewareDecision { + // The AUTHORITATIVE host gate lives page-side (`ensureDraft`): the site + // block's `previewHosts` are installed in the server-components graph, and + // middleware runs in a separate module graph/runtime that never sees them. + // Middleware therefore hard-gates only on the env override — keeping + // DECO_ALLOWED_PREVIEW_HOSTS a full kill switch — and otherwise passes the + // decision through. Worst case on a non-preview host is a cookie the + // page-side gate then ignores; drafts never render there. + if (process.env.DECO_ALLOWED_PREVIEW_HOSTS) { + const host = + request.headers.get("x-forwarded-host") ?? + request.headers.get("host") ?? + request.nextUrl.host; + if (!isDraftHostAllowed(host)) return INERT; + } + return decideDraft(new URL(request.url), request.cookies.get(DRAFT_COOKIE)?.value ?? null); +} + +/** + * URL prefix a drafted request is rewritten onto. + * + * The site must mount the matching route as `app/%5Fdraft/...` — URL-encoded. + * A literal `_draft/` directory is a Next "private folder", excluded from + * routing entirely, so the rewrite would fall through to whatever catch-all + * follows and 404. The encoded directory serves the same `/_draft` URL while + * staying routable. + */ +export const DRAFT_ROUTE_PREFIX = "/_draft"; + +/** + * Rewrite a drafted request onto the dynamic draft route. + * + * This exists because `dynamic` / `revalidate` are STATIC route exports: a + * statically rendered page cannot become dynamic for one request. Making the + * real route dynamic to support drafts would cost every shopper their cached + * page, and — worse — a statically rendered draft would be cached and served to + * them. Rewriting sends only drafted requests to a route that is dynamic by + * construction, leaving ordinary traffic's ISR completely untouched. + * + * The original path is preserved in the rewritten pathname, so the draft route + * can resolve exactly the page the visitor asked for. + * + * Returns null when this request is not drafted, so callers fall through to + * their normal response. + */ +export function rewriteToDraftRoute( + request: NextRequest, + decision: DraftMiddlewareDecision, +): NextResponse | null { + if (!decision.pointer) return null; + const url = request.nextUrl.clone(); + // Already rewritten (Next re-runs middleware on the rewritten URL in some + // configurations) — never nest the prefix. + if (url.pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`)) return null; + url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`; + return NextResponse.rewrite(url); +} + +/** + * Apply the cookie and the cache/indexing headers a draft response requires. + * + * The caching headers are the difference between a preview and a **leak**. + * With the pointer in a cookie, a draft response and a published one share an + * identical URL, so a CDN keyed on URL alone would happily serve unpublished + * content to a real visitor. `no-store` is what actually prevents that; + * `Vary: Cookie` keeps any intermediary that *does* respect it from mixing the + * two; `X-Robots-Tag` keeps a leaked draft out of search results. + */ +export function applyDraft( + response: NextResponse, + decision: DraftMiddlewareDecision, +): NextResponse { + if (decision.clearCookie) { + response.cookies.delete(DRAFT_COOKIE); + } else if (decision.setCookie) { + response.cookies.set(DRAFT_COOKIE, decision.setCookie, DRAFT_COOKIE_OPTIONS); + } + + if (decision.pointer) { + response.headers.set("cache-control", "no-store, private"); + response.headers.set("vary", "Cookie"); + response.headers.set("x-robots-tag", "noindex, nofollow"); + } + + return response; +} + +/** + * Convenience for sites with no middleware of their own: prepare, continue, + * apply. Sites that already have middleware should call the two halves + * directly so their own logic sits in between. + */ +export function draftMiddleware(request: NextRequest): NextResponse { + return applyDraft(NextResponse.next(), prepareDraft(request)); +} diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 5392a1d7..788a4ccf 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -8,6 +8,23 @@ export { export { DecoPageRenderer } from "./DecoPageRenderer"; export { DecoRootLayout, type DecoRootLayoutProps } from "./DecoRootLayout"; export { DeferredSectionBoundary } from "./DeferredSection"; +export { + DRAFT_COOKIE, + DRAFT_COOKIE_OPTIONS, + DRAFT_PARAM, + type DraftMiddlewareDecision, + type DraftSearchParams, + decideDraft, + ensureDraft, + registerDraftOverride, + selectDraftPointer, +} from "./draft"; +export { + applyDraft, + DRAFT_ROUTE_PREFIX, + prepareDraft, + rewriteToDraftRoute, +} from "./draftMiddleware"; export { decofileGET, decofilePOST, diff --git a/packages/nextjs/src/setup.ts b/packages/nextjs/src/setup.ts index bdf75942..388c8e92 100644 --- a/packages/nextjs/src/setup.ts +++ b/packages/nextjs/src/setup.ts @@ -38,7 +38,7 @@ * deploys must ship the directory alongside the server bundle). */ import type { ApplySectionConventionsInput } from "@decocms/blocks/cms"; -import { applySectionConventions, loadBlocks } from "@decocms/blocks/cms"; +import { applySectionConventions, loadBlocks, setDraftPreviewHosts } from "@decocms/blocks/cms"; import { loadDecofileDirectory } from "@decocms/blocks/cms/loadDecofileDirectory"; import { createSiteSetup, type SiteSetupOptions } from "@decocms/blocks/setup"; @@ -110,9 +110,29 @@ export interface NextSetupOptions { * instead, nothing imports the JSON, so edits invalidate nothing and dev * serves stale content until a full restart. */ +/** + * Install the site block's `previewHosts` as the draft-preview allowlist. + * + * Reads the BASE blocks handed to setup — never `loadBlocks()` at request + * time, where the draft override is merged in: an allowlist readable through + * the override could be rewritten by the very draft it gates. + * `DECO_ALLOWED_PREVIEW_HOSTS` remains an operational override that replaces + * this list when set. + */ +function installPreviewHosts(blocks: Record | undefined): void { + const site = blocks?.site as { previewHosts?: unknown } | undefined; + if (Array.isArray(site?.previewHosts)) setDraftPreviewHosts(site.previewHosts); +} + export function createNextSetup(options: NextSetupOptions): () => Promise { let setupPromise: Promise | null = null; + // Draft preview opt-in from the repo: read SYNCHRONOUSLY at createNextSetup + // time (module evaluation), not inside the lazy ensureSetup — pages call + // `ensureDraft` BEFORE they resolve CMS content, so hosts installed lazily + // would arrive after the gate already said no on the first request. + installPreviewHosts(options.blocks); + return function ensureSetup(): Promise { setupPromise ??= (async () => { const dirBlocks = @@ -120,6 +140,8 @@ export function createNextSetup(options: NextSetupOptions): () => Promise ? {} : await loadDecofileDirectory(options.blocksDir ?? ".deco/blocks"); const blocks = { ...dirBlocks, ...options.blocks }; + // Covers the blocksDir mode, where blocks only exist after the fs read. + installPreviewHosts(blocks); createSiteSetup({ sections: options.sections,