@decocms/nextjs 8.1.0-next.0 → 8.5.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 (89) hide show
  1. package/README.md +97 -328
  2. package/dist/DraftPreviewIndicator.d.ts +7 -0
  3. package/dist/DraftPreviewIndicator.d.ts.map +1 -0
  4. package/dist/DraftPreviewIndicator.js +19 -0
  5. package/dist/DraftPreviewIndicator.js.map +1 -0
  6. package/dist/StudioBridge.d.ts +16 -0
  7. package/dist/StudioBridge.d.ts.map +1 -0
  8. package/dist/StudioBridge.js +17 -0
  9. package/dist/StudioBridge.js.map +1 -0
  10. package/dist/draft.d.ts +37 -0
  11. package/dist/draft.d.ts.map +1 -0
  12. package/dist/draft.js +45 -0
  13. package/dist/draft.js.map +1 -0
  14. package/dist/drafts.d.ts +94 -0
  15. package/dist/drafts.d.ts.map +1 -0
  16. package/dist/drafts.js +210 -0
  17. package/dist/drafts.js.map +1 -0
  18. package/dist/image-loader/index.d.ts +36 -0
  19. package/dist/image-loader/index.d.ts.map +1 -0
  20. package/dist/image-loader/index.js +27 -0
  21. package/dist/image-loader/index.js.map +1 -0
  22. package/dist/index.d.ts +29 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +29 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/instrumentation/index.d.ts +74 -0
  27. package/dist/instrumentation/index.d.ts.map +1 -0
  28. package/dist/instrumentation/index.js +294 -0
  29. package/dist/instrumentation/index.js.map +1 -0
  30. package/dist/proxy/index.d.ts +82 -0
  31. package/dist/proxy/index.d.ts.map +1 -0
  32. package/dist/proxy/index.js +91 -0
  33. package/dist/proxy/index.js.map +1 -0
  34. package/dist/server/index.d.ts +46 -0
  35. package/dist/server/index.d.ts.map +1 -0
  36. package/dist/server/index.js +64 -0
  37. package/dist/server/index.js.map +1 -0
  38. package/dist/server/slot.d.ts +6 -0
  39. package/dist/server/slot.d.ts.map +1 -0
  40. package/dist/server/slot.js +13 -0
  41. package/dist/server/slot.js.map +1 -0
  42. package/dist/studio.d.ts +30 -0
  43. package/dist/studio.d.ts.map +1 -0
  44. package/dist/studio.js +55 -0
  45. package/dist/studio.js.map +1 -0
  46. package/package.json +53 -22
  47. package/src/DraftPreviewIndicator.tsx +17 -16
  48. package/src/StudioBridge.ts +23 -0
  49. package/src/config/index.cjs +78 -0
  50. package/src/config/index.d.cts +27 -0
  51. package/src/draft.ts +32 -215
  52. package/src/drafts.ts +229 -0
  53. package/src/image-loader/index.ts +62 -0
  54. package/src/index.ts +31 -45
  55. package/src/instrumentation/index.ts +337 -0
  56. package/src/proxy/index.ts +164 -0
  57. package/src/server/index.ts +77 -0
  58. package/src/server/slot.ts +14 -0
  59. package/src/studio.ts +54 -0
  60. package/src/ClientOnlySection.tsx +0 -20
  61. package/src/DecoPageRenderer.test.tsx +0 -37
  62. package/src/DecoPageRenderer.tsx +0 -107
  63. package/src/DecoRootLayout.test.tsx +0 -22
  64. package/src/DecoRootLayout.tsx +0 -82
  65. package/src/DeferredSection.test.tsx +0 -27
  66. package/src/DeferredSection.tsx +0 -59
  67. package/src/DraftPreviewBadge.tsx +0 -16
  68. package/src/SectionRenderer.test.tsx +0 -97
  69. package/src/SectionRenderer.tsx +0 -58
  70. package/src/__conformance__/observability.test.ts +0 -66
  71. package/src/config.cjs +0 -92
  72. package/src/config.d.cts +0 -4
  73. package/src/config.test.ts +0 -86
  74. package/src/createDecoPage.draft.test.tsx +0 -91
  75. package/src/createDecoPage.test.tsx +0 -63
  76. package/src/createDecoPage.tsx +0 -173
  77. package/src/createDecoPreviewPage.test.tsx +0 -71
  78. package/src/createDecoPreviewPage.tsx +0 -108
  79. package/src/draft.test.ts +0 -73
  80. package/src/draftConstants.test.ts +0 -21
  81. package/src/draftConstants.ts +0 -30
  82. package/src/draftMiddleware.test.ts +0 -179
  83. package/src/draftMiddleware.ts +0 -163
  84. package/src/draftShell.test.ts +0 -76
  85. package/src/routeHandlers.test.ts +0 -243
  86. package/src/routeHandlers.ts +0 -227
  87. package/src/setup.test.ts +0 -253
  88. package/src/setup.ts +0 -180
  89. package/tsconfig.json +0 -7
@@ -0,0 +1,337 @@
1
+ /**
2
+ * `@decocms/nextjs/instrumentation`: `http.server.request.duration` for a
3
+ * Next.js site, from the request span Next.js already opens.
4
+ *
5
+ * Next.js measures every request it serves: `BaseServer.handleRequest` opens
6
+ * one root span per request and, when the response is done, sets its status,
7
+ * its method and, when Next routed it, the route's template (`/[slug]/p`,
8
+ * not the path). It creates spans only while a tracer provider is registered
9
+ * through `@opentelemetry/api` (the app's copy, or Next's compiled one when
10
+ * the app has none; both read the same global registration).
11
+ *
12
+ * `register()`, the site's `instrumentation.ts` hook, registers a provider
13
+ * whose only job is that span: when it ends, the request is recorded with
14
+ * `recordServerRequest` from `@decocms/blocks/metrics`, into the CMS's
15
+ * telemetry in the same process. No span is exported anywhere and every other
16
+ * span Next.js asks for is a shared non-recording one. With no
17
+ * `@opentelemetry/api` reachable, on the Edge runtime, or when another
18
+ * provider is already registered, it registers nothing and never throws.
19
+ *
20
+ * A site that registers its own OpenTelemetry SDK (`@vercel/otel`,
21
+ * `NodeSDK`) adds `serverRequestSpanProcessor()` to that SDK's span
22
+ * processors instead.
23
+ *
24
+ * Labels: `http.route` is Next's route template (`unmatched` when Next names
25
+ * none); RSC requests count under their page's route, as the requests they
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
28
+ * `BYPASS`: Next decides its cache (`x-nextjs-cache`) on the response, which
29
+ * the span never sees.
30
+ */
31
+
32
+ import { recordServerRequest } from "@decocms/blocks/metrics";
33
+ import type {
34
+ Context,
35
+ ContextManager,
36
+ Span,
37
+ SpanContext,
38
+ SpanOptions,
39
+ TextMapPropagator,
40
+ Tracer,
41
+ TracerProvider,
42
+ } from "@opentelemetry/api";
43
+ import { DRAFT_HEADER, DRAFT_ROUTE_PREFIX } from "../draft.ts";
44
+
45
+ type Api = typeof import("@opentelemetry/api");
46
+
47
+ /** The span type Next.js gives its one root span per request. */
48
+ const ROOT_SPAN_TYPE = "BaseServer.handleRequest";
49
+ const REGISTERED = Symbol.for("decocms.nextjs.instrumentation");
50
+ /**
51
+ * Under `next start`, the router runs the proxy (`proxy.ts`/`middleware.ts`)
52
+ * through a `handleRequest` of its own, which ends by "bubbling" the proxy's
53
+ * result back to the router (`next.bubble`). That span is the proxy's, not a
54
+ * request: the request it hands on is measured by its own span.
55
+ */
56
+ const BUBBLE = "next.bubble";
57
+
58
+ type Attributes = Record<string, unknown>;
59
+
60
+ interface AsyncLocalStorageLike<T> {
61
+ getStore(): T | undefined;
62
+ run<R>(store: T, fn: (...args: unknown[]) => R, ...args: unknown[]): R;
63
+ disable(): void;
64
+ }
65
+
66
+ /** What `serverRequestSpanProcessor()` reads from an ended span (an SDK's `ReadableSpan`). */
67
+ interface EndedSpan {
68
+ readonly attributes: Attributes;
69
+ /** `[seconds, nanoseconds]`, as OpenTelemetry's `HrTime`. */
70
+ readonly duration: readonly [number, number];
71
+ readonly status?: { readonly code: number };
72
+ }
73
+
74
+ /** An OpenTelemetry SDK `SpanProcessor` that records nothing but the request metric. */
75
+ export interface ServerRequestSpanProcessor {
76
+ onStart(span: unknown, parentContext: unknown): void;
77
+ onEnd(span: EndedSpan): void;
78
+ forceFlush(): Promise<void>;
79
+ shutdown(): Promise<void>;
80
+ }
81
+
82
+ /** OpenTelemetry's `SpanStatusCode.ERROR`. */
83
+ const STATUS_ERROR = 2;
84
+
85
+ /** Records one ended `BaseServer.handleRequest` span. Never throws. */
86
+ function recordRequestSpan(
87
+ attributes: Attributes,
88
+ seconds: number,
89
+ errored: boolean,
90
+ draft: boolean,
91
+ ): void {
92
+ const route = typeof attributes["http.route"] === "string" ? attributes["http.route"] : undefined;
93
+ const code = Number(attributes["http.status_code"]);
94
+ // Next sets the status before the span ends; a request that threw is a 5xx whatever it says.
95
+ const status = errored && !(code >= 500) ? 500 : code || 500;
96
+ // Never rejects; the span's route is already a string, so nothing to wait for.
97
+ void recordServerRequest({
98
+ route,
99
+ method: String(attributes["http.method"] ?? ""),
100
+ status,
101
+ cache: draft || isDraftRoute(route) ? "DRAFT" : "BYPASS",
102
+ seconds,
103
+ });
104
+ }
105
+
106
+ function isDraftRoute(route: string | undefined): boolean {
107
+ if (route === undefined) return false;
108
+ const path = route.replace(/^\/%5Fdraft(?=\/|$)/i, DRAFT_ROUTE_PREFIX);
109
+ return path === DRAFT_ROUTE_PREFIX || path.startsWith(`${DRAFT_ROUTE_PREFIX}/`);
110
+ }
111
+
112
+ /**
113
+ * For a site with its own OpenTelemetry SDK: a span processor that records
114
+ * `http.server.request.duration` from Next's request span and leaves every
115
+ * span to the SDK's other processors and exporters.
116
+ *
117
+ * ```ts
118
+ * registerOTel({ serviceName: "my-store", spanProcessors: ["auto", serverRequestSpanProcessor()] });
119
+ * ```
120
+ */
121
+ export function serverRequestSpanProcessor(): ServerRequestSpanProcessor {
122
+ return {
123
+ onStart() {},
124
+ onEnd(span) {
125
+ try {
126
+ const attributes = span.attributes;
127
+ if (attributes?.["next.span_type"] !== ROOT_SPAN_TYPE || attributes[BUBBLE] === true)
128
+ return;
129
+ const [s, ns] = span.duration;
130
+ recordRequestSpan(attributes, s + ns / 1e9, span.status?.code === STATUS_ERROR, false);
131
+ } catch {
132
+ // A metric never breaks a request.
133
+ }
134
+ },
135
+ forceFlush: () => Promise.resolve(),
136
+ shutdown: () => Promise.resolve(),
137
+ };
138
+ }
139
+
140
+ /**
141
+ * The site's `instrumentation.ts` hook:
142
+ *
143
+ * ```ts
144
+ * // instrumentation.ts
145
+ * export { register } from "@decocms/nextjs/instrumentation";
146
+ * ```
147
+ *
148
+ * Registers the request-metric tracer provider, once per process. Does
149
+ * nothing on the Edge runtime (the Node.js server's request span covers the
150
+ * Edge pages it serves), without `@opentelemetry/api` (the app's or Next's),
151
+ * without `AsyncLocalStorage`, or when another provider is registered (it
152
+ * then says to use `serverRequestSpanProcessor()`). Never throws.
153
+ */
154
+ export async function register(): Promise<void> {
155
+ try {
156
+ const global = globalThis as Record<PropertyKey, unknown>;
157
+ if (global[REGISTERED] === true || typeof global.EdgeRuntime === "string") return;
158
+ const Storage = global.AsyncLocalStorage as
159
+ | (new () => AsyncLocalStorageLike<Context>)
160
+ | undefined;
161
+ if (typeof Storage !== "function") return;
162
+ const api = await loadApi();
163
+ if (api === undefined) return;
164
+ global[REGISTERED] = true;
165
+ const draft = api.createContextKey("decocms.nextjs.draft");
166
+ if (!api.trace.setGlobalTracerProvider(requestTracerProvider(api, draft))) {
167
+ console.warn(
168
+ "[@decocms/nextjs] Another OpenTelemetry tracer provider is registered, so register() added nothing. " +
169
+ "To record http.server.request.duration, add serverRequestSpanProcessor() to that provider's span processors.",
170
+ );
171
+ return;
172
+ }
173
+ // Both may already be registered by something else; Next's spans work with either.
174
+ api.context.setGlobalContextManager(storageContextManager(api, new Storage()));
175
+ api.propagation.setGlobalPropagator(draftPropagator(draft));
176
+ } catch {
177
+ // Instrumentation never stops a server from starting.
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The `@opentelemetry/api` Next.js uses: the app's own when installed (Next's
183
+ * bundlers alias it to Next's compiled copy when it isn't), else Next's
184
+ * compiled copy.
185
+ */
186
+ async function loadApi(): Promise<Api | undefined> {
187
+ try {
188
+ return await import("@opentelemetry/api");
189
+ } catch {
190
+ try {
191
+ return (await import("next/dist/compiled/@opentelemetry/api/index.js")) as Api;
192
+ } catch {
193
+ return undefined;
194
+ }
195
+ }
196
+ }
197
+
198
+ function requestTracerProvider(api: Api, DRAFT: symbol): TracerProvider {
199
+ const IN_REQUEST = api.createContextKey("decocms.nextjs.inRequest");
200
+ const inert = api.trace.wrapSpanContext(api.INVALID_SPAN_CONTEXT);
201
+
202
+ const tracer: Tracer = {
203
+ startSpan(_name: string, options?: SpanOptions, context?: Context): Span {
204
+ const ctx = context ?? api.context.active();
205
+ if (options?.attributes?.["next.span_type"] !== ROOT_SPAN_TYPE) return inert;
206
+ // A request Next serves inside another (a nested handleRequest) is already measured.
207
+ if (ctx.getValue(IN_REQUEST) === true) return inert;
208
+ return new RequestSpan(
209
+ api.INVALID_SPAN_CONTEXT,
210
+ options.attributes,
211
+ ctx.getValue(DRAFT) === true,
212
+ );
213
+ },
214
+ startActiveSpan(name: string, ...rest: unknown[]) {
215
+ const fn = rest[rest.length - 1] as (span: Span) => unknown;
216
+ const options = (rest.length > 1 ? rest[0] : undefined) as SpanOptions | undefined;
217
+ const context = (rest.length > 2 ? rest[1] : undefined) as Context | undefined;
218
+ const span = tracer.startSpan(name, options, context);
219
+ // Only the request span changes the context; every other span costs a call.
220
+ if (span === inert) return fn(span);
221
+ const ctx = api.trace
222
+ .setSpan(context ?? api.context.active(), span)
223
+ .setValue(IN_REQUEST, true);
224
+ return api.context.with(ctx, fn, undefined, span);
225
+ },
226
+ } as Tracer;
227
+
228
+ // Not named NoopTracerProvider: Next checks that name to decide whether tracing is on.
229
+ return { getTracer: () => tracer };
230
+ }
231
+
232
+ /** Marks the request's context when the proxy forwarded an accepted draft. */
233
+ function draftPropagator(DRAFT: symbol): TextMapPropagator {
234
+ return {
235
+ inject() {},
236
+ extract(context, carrier, getter) {
237
+ try {
238
+ const value = getter.get(carrier, DRAFT_HEADER);
239
+ return value !== undefined && value !== "" ? context.setValue(DRAFT, true) : context;
240
+ } catch {
241
+ return context;
242
+ }
243
+ },
244
+ fields: () => [],
245
+ };
246
+ }
247
+
248
+ /** The request span: keeps only the attributes the metric needs and records on `end()`. */
249
+ class RequestSpan implements Span {
250
+ readonly #context: SpanContext;
251
+ readonly #attributes: Attributes = {};
252
+ readonly #start = performance.now();
253
+ readonly #draft: boolean;
254
+ #errored = false;
255
+ #proxy = false;
256
+ #ended = false;
257
+
258
+ constructor(context: SpanContext, attributes: Attributes | undefined, draft: boolean) {
259
+ this.#context = context;
260
+ this.#draft = draft;
261
+ if (attributes) this.setAttributes(attributes);
262
+ }
263
+
264
+ spanContext(): SpanContext {
265
+ return this.#context;
266
+ }
267
+ setAttribute(key: string, value: unknown): this {
268
+ if (key === "http.route" || key === "http.status_code" || key === "http.method") {
269
+ this.#attributes[key] = value;
270
+ } else if (key === BUBBLE && value === true) {
271
+ this.#proxy = true;
272
+ }
273
+ return this;
274
+ }
275
+ setAttributes(attributes: Attributes): this {
276
+ for (const key in attributes) this.setAttribute(key, attributes[key]);
277
+ return this;
278
+ }
279
+ setStatus(status: { code: number }): this {
280
+ if (status?.code === STATUS_ERROR) this.#errored = true;
281
+ return this;
282
+ }
283
+ addEvent(): this {
284
+ return this;
285
+ }
286
+ addLink(): this {
287
+ return this;
288
+ }
289
+ addLinks(): this {
290
+ return this;
291
+ }
292
+ updateName(): this {
293
+ return this;
294
+ }
295
+ recordException(exception: unknown): void {
296
+ // The proxy's own invocation ends with an error marked `bubble`, whatever its type.
297
+ if ((exception as { bubble?: unknown } | null)?.bubble === true) this.#proxy = true;
298
+ }
299
+ isRecording(): boolean {
300
+ return !this.#ended;
301
+ }
302
+ end(): void {
303
+ if (this.#ended) return;
304
+ this.#ended = true;
305
+ if (this.#proxy) return;
306
+ try {
307
+ const seconds = (performance.now() - this.#start) / 1000;
308
+ recordRequestSpan(this.#attributes, seconds, this.#errored, this.#draft);
309
+ } catch {
310
+ // A metric never breaks a request.
311
+ }
312
+ }
313
+ }
314
+
315
+ /** OpenTelemetry's context manager over `AsyncLocalStorage` (what `@opentelemetry/context-async-hooks` does). */
316
+ function storageContextManager(api: Api, storage: AsyncLocalStorageLike<Context>): ContextManager {
317
+ const manager: ContextManager = {
318
+ active: () => storage.getStore() ?? api.ROOT_CONTEXT,
319
+ with(context, fn, thisArg, ...args) {
320
+ const call = thisArg === undefined ? fn : fn.bind(thisArg);
321
+ return storage.run(context, call as (...a: unknown[]) => ReturnType<typeof fn>, ...args);
322
+ },
323
+ bind<T>(context: Context, target: T): T {
324
+ if (typeof target !== "function") return target;
325
+ const fn = target as unknown as (...a: unknown[]) => unknown;
326
+ return function (this: unknown, ...args: unknown[]) {
327
+ return manager.with(context, () => fn.apply(this, args));
328
+ } as unknown as T;
329
+ },
330
+ enable: () => manager,
331
+ disable() {
332
+ storage.disable();
333
+ return manager;
334
+ },
335
+ };
336
+ return manager;
337
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * `@decocms/nextjs/proxy`: draft previews in a Next.js proxy (`proxy.ts`, or
3
+ * `middleware.ts` before Next 16). No React and no block map: pass the proxy
4
+ * a CMS handle made with an empty block map and the app's options, which
5
+ * shares the app's instance (one per process) without pulling the site's
6
+ * sections into the proxy bundle:
7
+ *
8
+ * ```ts
9
+ * // src/cms.proxy.ts
10
+ * export const proxyCms = createCMS({ blocks: {}, ...cmsConfig });
11
+ * ```
12
+ *
13
+ * A site with a proxy of its own calls the pieces (`decideDraft`, then
14
+ * `draftRequestHeaders` on the request it forwards, then `applyDraft` on the
15
+ * response); a site without one exports `draftProxy(...)`.
16
+ *
17
+ * Statically rendered pages can't read a draft, so a drafted request to one
18
+ * is rewritten onto a dynamic draft route (`/_draft/<path>`, mounted as
19
+ * `app/%5Fdraft/...`: a literal `_draft` folder is private in Next and never
20
+ * routes).
21
+ *
22
+ * The proxy measures no requests: it runs before Next routes and renders the
23
+ * page. `http.server.request.duration` comes from Next's own request span
24
+ * (`register` from `@decocms/nextjs/instrumentation`).
25
+ */
26
+ import type { CMS } from "@decocms/blocks";
27
+ import { type NextRequest, NextResponse } from "next/server.js";
28
+ import { DRAFT_HEADER, DRAFT_ROUTE_PREFIX, draftResponseHeaders } from "../draft.ts";
29
+ import {
30
+ allowedDraft,
31
+ draftCookie,
32
+ endsPreview,
33
+ fromDraftLink,
34
+ hasDraftSignal,
35
+ type PreviewPolicy,
36
+ requestLike,
37
+ } from "../drafts.ts";
38
+
39
+ export {
40
+ inferredPreviewHosts,
41
+ type PreviewPolicy,
42
+ previewHostDecision,
43
+ requestLike,
44
+ } from "../drafts.ts";
45
+
46
+ /** What the proxy needs from the CMS: the site's declared preview hosts and their check. */
47
+ export type CMSHandle = Pick<CMS, "draftPointer" | "settings" | "forRelease">;
48
+
49
+ /** What the proxy does with a request's draft state. */
50
+ export interface DraftDecision {
51
+ /** The draft this request renders, after the host check; `null` for the release. */
52
+ pointer: string | null;
53
+ /** The `Set-Cookie` the response carries: storing a draft link's pointer, or ending a preview. */
54
+ setCookie: string | null;
55
+ }
56
+
57
+ export { DRAFT_ROUTE_PREFIX } from "../draft.ts";
58
+
59
+ const INERT: DraftDecision = Object.freeze({ pointer: null, setCookie: null });
60
+
61
+ /**
62
+ * The draft a request renders and the cookie its response sets, under
63
+ * `policy` (the same options the pages pass to `bindDraft`). A request with
64
+ * no `?__draft=` and no draft cookie never reaches the CMS.
65
+ */
66
+ export async function decideDraft(
67
+ cms: CMSHandle,
68
+ request: NextRequest | Request,
69
+ policy: PreviewPolicy = {},
70
+ ): Promise<DraftDecision> {
71
+ const url = new URL(request.url);
72
+ const like = requestLike(request.headers, url.pathname + url.search, url.host);
73
+ if (like === null || !hasDraftSignal(like)) return INERT;
74
+ // Ending a preview is allowed on any host, so a cookie set earlier always goes.
75
+ if (endsPreview(like)) return { pointer: null, setCookie: draftCookie("", ["Max-Age=0"]) };
76
+ const pointer = await allowedDraft(cms, like, policy);
77
+ if (pointer === null) return INERT;
78
+ return {
79
+ pointer,
80
+ setCookie: fromDraftLink(like, pointer) ? draftCookie(encodeURIComponent(pointer)) : null,
81
+ };
82
+ }
83
+
84
+ /**
85
+ * The request headers to forward: the accepted pointer on `x-deco-draft`, so
86
+ * the root layout can bind the draft on the same request (a layout gets no
87
+ * `searchParams`, and on a draft link the cookie is only on the response).
88
+ * A copy that arrives from outside is always removed.
89
+ */
90
+ export function draftRequestHeaders(
91
+ request: NextRequest | Request,
92
+ decision: DraftDecision,
93
+ ): Headers {
94
+ const headers = new Headers(request.headers);
95
+ if (decision.pointer) headers.set(DRAFT_HEADER, decision.pointer);
96
+ else headers.delete(DRAFT_HEADER);
97
+ return headers;
98
+ }
99
+
100
+ /** Whether `pathname` is on the draft route: a site answers a direct hit there with its 404. */
101
+ export function isDraftRoutePath(pathname: string): boolean {
102
+ return pathname === DRAFT_ROUTE_PREFIX || pathname.startsWith(`${DRAFT_ROUTE_PREFIX}/`);
103
+ }
104
+
105
+ /**
106
+ * The rewrite of a drafted request onto the draft route, keeping its path and
107
+ * forwarding the pointer; `null` when the request isn't drafted or is already
108
+ * on the draft route.
109
+ */
110
+ export function rewriteToDraftRoute(
111
+ request: NextRequest,
112
+ decision: DraftDecision,
113
+ ): NextResponse | null {
114
+ if (!decision.pointer) return null;
115
+ const url = request.nextUrl.clone();
116
+ if (isDraftRoutePath(url.pathname)) return null;
117
+ url.pathname = `${DRAFT_ROUTE_PREFIX}${url.pathname}`;
118
+ return NextResponse.rewrite(url, {
119
+ request: { headers: draftRequestHeaders(request, decision) },
120
+ });
121
+ }
122
+
123
+ /**
124
+ * The cookie and the response headers of a draft: `Set-Cookie`, and for a
125
+ * drafted response `Cache-Control: no-store, private`, `Vary: Cookie` and
126
+ * `X-Robots-Tag: noindex, nofollow` (`draftResponseHeaders`). With the
127
+ * pointer in a cookie a draft shares its URL with the published page, so a
128
+ * cache keyed on the URL alone would serve it to visitors.
129
+ */
130
+ export function applyDraft<R extends Response>(response: R, decision: DraftDecision): R {
131
+ if (decision.setCookie) response.headers.append("set-cookie", decision.setCookie);
132
+ for (const [name, value] of Object.entries(draftResponseHeaders(decision.pointer))) {
133
+ response.headers.set(name, value);
134
+ }
135
+ return response;
136
+ }
137
+
138
+ export interface DraftProxyOptions extends PreviewPolicy {
139
+ /**
140
+ * Which drafted requests go to the draft route (a statically rendered
141
+ * page's). Default: every drafted request outside the draft route. Return
142
+ * `false` for a route that is already dynamic and binds the draft itself.
143
+ */
144
+ rewrite?: (request: NextRequest) => boolean;
145
+ }
146
+
147
+ /**
148
+ * The whole draft flow for a site with no proxy of its own:
149
+ * `export const proxy = draftProxy(proxyCms, { site: "my-store" })`.
150
+ */
151
+ export function draftProxy(
152
+ cms: CMSHandle,
153
+ options: DraftProxyOptions = {},
154
+ ): (request: NextRequest) => Promise<NextResponse> {
155
+ const { rewrite = () => true, ...policy } = options;
156
+ return async (request) => {
157
+ const decision = await decideDraft(cms, request, policy);
158
+ const rewritten = rewrite(request) ? rewriteToDraftRoute(request, decision) : null;
159
+ const response =
160
+ rewritten ??
161
+ NextResponse.next({ request: { headers: draftRequestHeaders(request, decision) } });
162
+ return applyDraft(response, decision);
163
+ };
164
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `@decocms/nextjs/server`: draft previews in Server Components (pages,
3
+ * layouts, route handlers). Server only: it reads `next/headers`.
4
+ *
5
+ * A page (and, through the `x-deco-draft` header the proxy forwards, the root
6
+ * layout) binds the request's draft before it reads content, then reads
7
+ * through `contentClient(cms)`:
8
+ *
9
+ * ```tsx
10
+ * export default async function Page({ searchParams }) {
11
+ * await bindDraft(cms, { searchParams: await searchParams, site: "my-store" });
12
+ * const client = contentClient(cms);
13
+ * …
14
+ * return <>{sections}<DraftPreviewIndicator /></>;
15
+ * }
16
+ * ```
17
+ *
18
+ * The host check is the proxy's (`decideDraft`), repeated here with the same
19
+ * options, so a route the proxy doesn't match, or a forged `x-deco-draft`,
20
+ * still gets the release on a host that may not preview.
21
+ */
22
+
23
+ import type { Client, CMS } from "@decocms/blocks";
24
+ import { headers } from "next/headers.js";
25
+ import { DRAFT_HEADER, DRAFT_PARAM } from "../draft.ts";
26
+ import { allowedDraft, isDraftPreviewEnabled, type PreviewPolicy, requestLike } from "../drafts.ts";
27
+ import { activeDraftPointer, draftSlot } from "./slot.ts";
28
+
29
+ export { type PreviewPolicy, requestLike } from "../drafts.ts";
30
+ export { activeDraftPointer };
31
+
32
+ /** A page's `searchParams` prop, awaited. */
33
+ export type SearchParams = Record<string, string | string[] | undefined>;
34
+
35
+ export interface BindDraftOptions extends PreviewPolicy {
36
+ /** The page's own `searchParams` (a layout has none: it reads the proxy's `x-deco-draft`). */
37
+ searchParams?: SearchParams;
38
+ }
39
+
40
+ function firstParam(searchParams: SearchParams | undefined, key: string): string | null {
41
+ const raw = searchParams?.[key];
42
+ return (Array.isArray(raw) ? raw[0] : raw) ?? null;
43
+ }
44
+
45
+ /**
46
+ * Binds the request's draft, if any: the page's own `?__draft=` first (`off`
47
+ * leaves preview), then the pointer the proxy forwarded on `x-deco-draft`,
48
+ * then the draft cookie, each on a host the options allow. Returns whether a
49
+ * draft was bound. With previews off on every host it returns `false`
50
+ * without reading the request, so a static page stays static.
51
+ */
52
+ export async function bindDraft(
53
+ cms: Pick<CMS, "draftPointer" | "settings" | "forRelease">,
54
+ options: BindDraftOptions = {},
55
+ ): Promise<boolean> {
56
+ const { searchParams, ...policy } = options;
57
+ if (!(await isDraftPreviewEnabled(cms, policy))) return false;
58
+ const param = firstParam(searchParams, DRAFT_PARAM);
59
+ if (param === "off") return false;
60
+ const requestHeaders = await headers();
61
+ const forwarded = param ?? requestHeaders.get(DRAFT_HEADER);
62
+ const query = forwarded ? `?${DRAFT_PARAM}=${encodeURIComponent(forwarded)}` : "";
63
+ const request = requestLike(requestHeaders as Headers, `/${query}`);
64
+ const pointer = request ? await allowedDraft(cms, request, policy) : null;
65
+ if (pointer === null) return false;
66
+ draftSlot().pointer = pointer;
67
+ return true;
68
+ }
69
+
70
+ /**
71
+ * The content client for this request: the draft `bindDraft` bound, or the
72
+ * release.
73
+ */
74
+ export function contentClient(cms: Pick<CMS, "forDraft" | "forRelease">): Client {
75
+ const pointer = activeDraftPointer();
76
+ return pointer ? cms.forDraft(pointer) : cms.forRelease();
77
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The draft a request renders, held for the request's Server Components.
3
+ * React's `cache()` gives one slot per request in the React Server
4
+ * Components graph, the scope a draft needs; no `next/headers` here, so the
5
+ * root components can read it without binding anything.
6
+ */
7
+ import { cache } from "react";
8
+
9
+ export const draftSlot = cache((): { pointer: string | null } => ({ pointer: null }));
10
+
11
+ /** The draft pointer bound to this request (`bindDraft`), or `null` when it renders the release. */
12
+ export function activeDraftPointer(): string | null {
13
+ return draftSlot().pointer;
14
+ }
package/src/studio.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Where Deco Studio runs: the origins its site editor frames a site from and
3
+ * talks to it from (`postMessage`). One list, so the Studio bridge
4
+ * (`StudioBridge`), `withDeco()`'s dev origins and a site's own
5
+ * `frame-ancestors` never drift apart. This package's own copy: it never
6
+ * imports `@decocms/tanstack`.
7
+ *
8
+ * No module state and no framework code: safe in a browser bundle.
9
+ */
10
+
11
+ /**
12
+ * Studio's origins, in the CSP source form (`https://*.decocms.com` matches
13
+ * any subdomain). Test an origin with {@link isStudioOrigin}: the list holds a
14
+ * wildcard, so `includes()` isn't the check.
15
+ */
16
+ export const STUDIO_ORIGINS: readonly string[] = Object.freeze([
17
+ "https://studio.decocms.com",
18
+ "https://studio-stg.decocms.com",
19
+ "https://decocms.com",
20
+ "https://*.decocms.com",
21
+ ]);
22
+
23
+ /**
24
+ * The `frame-ancestors` sources a site sends so Studio can show it in the
25
+ * site editor: the site itself, {@link STUDIO_ORIGINS}, and v7's
26
+ * `https://*.deco.studio`. For a site that writes its own CSP; add the
27
+ * loopback origins in local development (`http://localhost:*`), where a
28
+ * Studio runs on the same machine.
29
+ */
30
+ export const STUDIO_FRAME_ANCESTORS: readonly string[] = Object.freeze([
31
+ "'self'",
32
+ ...STUDIO_ORIGINS,
33
+ "https://*.deco.studio",
34
+ ]);
35
+
36
+ /**
37
+ * Whether `origin` (a `MessageEvent.origin`, an `Origin` header) is one of
38
+ * {@link STUDIO_ORIGINS}: `https`, the default port, and `decocms.com` or a
39
+ * name under it.
40
+ */
41
+ export function isStudioOrigin(origin: string | null | undefined): boolean {
42
+ if (typeof origin !== "string" || origin === "") return false;
43
+ let url: URL;
44
+ try {
45
+ url = new URL(origin);
46
+ } catch {
47
+ return false;
48
+ }
49
+ // An origin is the scheme and host alone: `https://decocms.com/x` or one with
50
+ // credentials isn't one.
51
+ if (url.origin !== origin || url.protocol !== "https:" || url.port !== "") return false;
52
+ const host = url.hostname;
53
+ return host === "decocms.com" || host.endsWith(".decocms.com");
54
+ }
@@ -1,20 +0,0 @@
1
- "use client";
2
-
3
- import dynamic from "next/dynamic";
4
- import type { ComponentType } from "react";
5
-
6
- interface ClientOnlySectionProps {
7
- loader: () => Promise<{ default: ComponentType<any> }>;
8
- props: Record<string, unknown>;
9
- }
10
-
11
- const cache = new Map<() => Promise<{ default: ComponentType<any> }>, ComponentType<any>>();
12
-
13
- export function ClientOnlySection({ loader, props }: ClientOnlySectionProps) {
14
- let Dynamic = cache.get(loader);
15
- if (!Dynamic) {
16
- Dynamic = dynamic(loader, { ssr: false });
17
- cache.set(loader, Dynamic);
18
- }
19
- return <Dynamic {...props} />;
20
- }