Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions .agents/skills/deco-nextjs-site/references/studio-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ layout from being cached.

## What it does

- **The bridge script** answers Studio's scroll-to-section and rerender
messages, only from a parent window at one of Studio's origins. Outside a
frame it answers no message and only listens for the "." shortcut.
- **The shortcut script** listens for the "." shortcut, only outside a frame.
It answers no message: v8 only, v7's framed handler (scroll-to-section,
rerender) is gone, so no script on the page compares the window with `top`
or `parent`, and a test runner that rewrites "frame-busting" code (Cypress)
serves the page as is.
- **The Studio channel** runs on every page, production included (about
0.7 kB gzipped). Framed by a trusted origin, it posts `{ type: "deco:ready" }`
and imports a module only when the answer `{ type: "deco:load", url }`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ What else it mounts:
after hydration. Work that must follow hydration waits for it.
- `NavigationProgress` and `StableOutlet` ([`router-and-navigation.md`](./router-and-navigation.md)).
- An inline script that publishes the request's draft and CDN segment token to `window`.
- `StudioBridge`, `StudioChannel` and `DraftBadge` from `@decocms/blocks/preview` (below).
- The "." edit shortcut, and `StudioChannel` and `DraftBadge` from `@decocms/blocks/preview` (below).

## The head

Expand All @@ -76,18 +76,18 @@ import { DecoPreview } from "@decocms/tanstack/react";
```

`DecoPreview` takes `site`, `editShortcut`, `nonce` and `studioOrigins`. It renders the draft
script, `StudioBridge`, `StudioChannel` and `DraftBadge`. `StudioBridge` alone leaves draft pages
without the badge. You then own `<HeadContent />`, `<Scripts />`, `lang`, the events bus and the
script, the "." shortcut, `StudioChannel` and `DraftBadge`. You then own `<HeadContent />`, `<Scripts />`, `lang`, the events bus and the
progress bar.

## Studio inside the page

- **`StudioBridge`**: in Studio's frame it talks to Studio. Outside it, "." opens the current page
in Studio's editor: the page whose route returns `pathTemplate` (and optionally `pageId`) in its
loader data, else Studio picks it from the URL.
- **The "." shortcut**: outside Studio's frame, "." opens the current page in Studio's editor: the
page whose route returns `pathTemplate` (and optionally `pageId`) in its loader data, else Studio
picks it from the URL. In a frame it does nothing (v8 only: no v7 `scrollToComponent` /
`editor::rerender` handler).
- **`StudioChannel`**: on every page, production included, about 0.7 kB gzipped. When a trusted
Studio origin frames the page, it loads Studio's editor modules from that origin. Otherwise it
does nothing. The bridge runs no script Studio sends.
does nothing. It is the only way Studio's editor reaches the page.
- **`DraftBadge`**: shown on a draft page outside Studio's frame. A draft link that can't be read
anymore shows a notice and the release.

Expand All @@ -98,8 +98,7 @@ from `@decocms/tanstack`). Each entry must be an exact `http(s)` origin; anythin
The worker puts the same list into the CSP. If you set `studioOrigins` on `DecoDocument`, set the
same list on `createDecoWorker`; `DECO_STUDIO_ORIGIN` sets both.

When Studio asks the page to scroll to a section, `StudioBridge` looks for the element whose
`data-manifest-key` equals the id Studio sends, else the element with that `id`. How to render
Studio's editor modules find the page's sections by their `data-manifest-key`. How to render
sections: `deco-site-authoring`.

## Security headers
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Opinionated by design: the behaviour every Deco TanStack site should have is fix
| `@decocms/tanstack/server` | `src/server/index.ts` — `contentClient(cms, request?)` (`cms.client({ version, visitor, experiments, campaigns })` from the worker's `x-deco-version`, `x-deco-visitor` and `x-deco-assignments`: the page decides nothing again), `requestVersion`, `requestVisitor`, `requestAssignments`, `markPrivate`, `isServerFnCall`. Server only |
| `@decocms/tanstack/start` | `src/start/index.ts` — `decoServerFnFetch` for `createStart({ serverFns: { fetch } })`. Browser-safe |
| `@decocms/tanstack/router` | `src/router/index.ts` — `createDecoRouter`, `decoParseSearch`/`decoStringifySearch` (keep `?q=`, v7 #646), `PAGE_STATUS_HEADER`. Isomorphic |
| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts `StudioBridge`, `StudioChannel` (its `studioOrigins` prop, else `studioOrigins()` of `src/studio.ts`: `DECO_STUDIO_ORIGIN` comma-separated, else `DEFAULT_STUDIO_ORIGINS`) and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`; `Image`, `Picture`, `Source` (v7's props and markup) with `ImageUrlProvider` for the site's `ImageUrl` from `@decocms/blocks/image`; no platform knowledge (without a provider, `DECO_IMAGE_URL`: every source through the Deco CDN), the site lists its platform's helper (`@decocms/apps-vtex/image`, …) itself. `tests/tanstack-image-markup.golden.test.ts` holds v7's markup byte for byte (250 cases: no width and Nuvemshop images included, the latter with a store's own provider) with those helpers, outside the package since it depends on no `@decocms/apps-*`. Browser-safe |
| `@decocms/tanstack/react` | `src/react/index.ts` — `DecoDocument` (mounts the "." shortcut (`src/studioShortcut.ts`, the same file as `@decocms/nextjs`'s; v8 only, no v7 framed handler, Cypress-safe: `tests/studio-shortcut.test.ts`), `StudioChannel` (its `studioOrigins` prop, else `studioOrigins()` of `src/studio.ts`: `DECO_STUDIO_ORIGIN` comma-separated, else `DEFAULT_STUDIO_ORIGINS`) and `DraftBadge` from `@decocms/blocks/preview`, publishes the `__cseg` token and the draft), `NavigationProgress`, `StableOutlet`; `Image`, `Picture`, `Source` (v7's props and markup) with `ImageUrlProvider` for the site's `ImageUrl` from `@decocms/blocks/image`; no platform knowledge (without a provider, `DECO_IMAGE_URL`: every source through the Deco CDN), the site lists its platform's helper (`@decocms/apps-vtex/image`, …) itself. `tests/tanstack-image-markup.golden.test.ts` holds v7's markup byte for byte (250 cases: no width and Nuvemshop images included, the latter with a store's own provider) with those helpers, outside the package since it depends on no `@decocms/apps-*`. Browser-safe |
| `@decocms/tanstack/vite` | `src/vite/index.ts` — `decoTanstack({ buildHash })` (= `decoContent()` from `@decocms/blocks/vite`, `__BUILD_HASH__`, a browser stub for `node:async_hooks`, `resolve.dedupe`), `gitBuildHash` |
| `@decocms/tanstack/scope` | `src/scope/server.ts` / `browser.ts` — the request scope the worker hands the render (`AsyncLocalStorage`). Conditional export: `workerd` and `node` before `browser`, because a Workers build turns `browser` on too; `src/__tests__/exports.test.ts` resolves it under those conditions |

Expand All @@ -117,7 +117,7 @@ Small helpers for any Next.js App Router app, with no hosting assumptions (no CD
|---|---|
| `@decocms/nextjs/proxy` | `src/proxy/index.ts` — `decideDraft(cms, request)` (the CMS's preview hosts, `cms.readVersion`, `src/drafts.ts`), `draftRequestHeaders` (`x-deco-version`; forged `x-deco-version`/`x-deco-visitor`/`x-deco-assignments` removed), `rewriteToDraftRoute` (`/_draft`, with the visitor and the assignments when given), `applyDraft` (cookie, `draftResponseHeaders`), `isDraftRoutePath`, `decideVisitor` (`readVisitor`; the cookie on a first visit only, never from a preview; reads no content), `visitorRequestHeaders` (`x-deco-visitor`), `decideAssignments` (every test and campaign, decided once: `cms.client({ visitor, ...version })`), `assignmentsRequestHeaders` (`x-deco-assignments`), `applyVisitor`, the assignments route (`rewriteToAssignmentsRoute`, `assignmentsSegment`: every name with its state), `draftProxy` (all of it). `DraftDecision.version` is `cms.readVersion`'s shape (blocks' `parseVersion` after the binding's host check). No React, no `next/headers`, no block map (`src/__tests__/exports.test.ts`) |
| `@decocms/nextjs/server` | `src/server/index.ts` — `bindDraft(cms, { searchParams })` (param, then `x-deco-version`, then cookie; the same host check), `activeDraftPointer`, `contentClient(cms)` (`cms.client({ version, experiments, campaigns })` with what the proxy decided), `assignedClient(cms, segment)` (async: the assignments route's fully decided client, a test at 0% kept off); `bindDraft` also binds the proxy's `x-deco-visitor` and `x-deco-assignments` (for a preview always; on the current release only when it saves an `experiments` or `campaigns` block, so a static page stays static, and then a test at 0% stays off whatever the header says). The draft lives in React's `cache()` (`server/slot.ts`), so no conditional export is needed |
| `@decocms/nextjs` | `src/index.ts` — `DraftPreviewIndicator` (renders `DraftBadge` for the bound draft), `DraftBadge`, `StudioBridge` (`src/StudioBridge.ts`: blocks' bridge with `trustedOrigins` set to the package's `STUDIO_ORIGINS`, followed by blocks' `StudioChannel` for `studioOrigins(props.studioOrigins)`), `withStudioCsp` (`src/csp.ts`: the site's own CSP with Studio's origins in `script-src` and `frame-ancestors`; the binding sends no CSP itself), and the package's own draft names and Studio origins: `DRAFT_PARAM`/`DRAFT_COOKIE`/`VERSION_HEADER`, `draftResponseHeaders` (`src/draft.ts`), `VISITOR_HEADER` (`src/visitor.ts`), `ASSIGNMENTS_HEADER` (`src/assignments.ts`), `STUDIO_ORIGINS`/`STUDIO_FRAME_ANCESTORS`/`isStudioOrigin`/`STUDIO_ORIGIN`/`DEFAULT_STUDIO_ORIGINS`/`studioOrigins` (`src/studio.ts`). The same values as `@decocms/tanstack`'s, never imported from it (`exports.test.ts`) |
| `@decocms/nextjs` | `src/index.ts` — `DraftPreviewIndicator` (renders `DraftBadge` for the bound draft), `DraftBadge`, `StudioBridge` (`src/StudioBridge.ts`, v8 only: the "." shortcut, `src/studioShortcut.ts` (no v7 framed handler, so nothing a Cypress frame-busting rewrite matches: `tests/studio-shortcut.test.ts`), followed by blocks' `StudioChannel` for `studioOrigins(props.studioOrigins)`), `withStudioCsp` (`src/csp.ts`: the site's own CSP with Studio's origins in `script-src` and `frame-ancestors`; the binding sends no CSP itself), and the package's own draft names and Studio origins: `DRAFT_PARAM`/`DRAFT_COOKIE`/`VERSION_HEADER`, `draftResponseHeaders` (`src/draft.ts`), `VISITOR_HEADER` (`src/visitor.ts`), `ASSIGNMENTS_HEADER` (`src/assignments.ts`), `STUDIO_ORIGINS`/`STUDIO_FRAME_ANCESTORS`/`isStudioOrigin`/`STUDIO_ORIGIN`/`DEFAULT_STUDIO_ORIGINS`/`studioOrigins` (`src/studio.ts`). The same values as `@decocms/tanstack`'s, never imported from it (`exports.test.ts`) |
| `@decocms/nextjs/config` | `src/config/index.cjs` (+ `index.d.cts`), plain CommonJS shipped from `src` — `withDeco(nextConfig, { draftHeaders, studioOrigins })`: v7's draft header rules first, `STUDIO_ORIGINS` in `allowedDevOrigins`; no rewrites, no image rules. It repeats the package's names (`src/draft.ts`, `src/studio.ts`), held equal by `config.test.ts` |
| `@decocms/nextjs/instrumentation` | `src/instrumentation/index.ts` — `register()` (the site's `instrumentation.ts`: a minimal global tracer provider whose only job is Next's root `BaseServer.handleRequest` span, recorded with `recordServerRequest` from `@decocms/blocks/metrics`; exports no span, non-recording for every other span; skips the proxy's own bubbled `handleRequest`; no-op on Edge, without `@opentelemetry/api` (the app's, else Next's compiled copy) or when another provider is registered) and `serverRequestSpanProcessor()` for a site with its own OpenTelemetry SDK. `@opentelemetry/api` is an optional peer |
| `@decocms/nextjs/image-loader` | `src/image-loader/index.ts` — `createDecoImageLoader(imageUrl, { fallback })`, `nextImageOptimizer`. Platform-neutral: the site composes its platform's helpers (`@decocms/apps-vtex/image`, …) into the `ImageUrl`; the package depends on no `@decocms/apps-*`. `tests/nextjs-image-loader.golden.test.ts` runs the next/image golden URLs of blocks and apps-vtex through that composition (no changed case but the PO-approved fix-2 ones, marked `before`/`reason` in the fixture) |
Expand Down
8 changes: 4 additions & 4 deletions examples/nextjs-binding-smoke/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,10 @@ try {
check("a published page is not noindex", !page.res.headers.has("x-robots-tag"));
check("the layout mounts the Studio bridge", page.body.includes('data-site="binding-smoke"'));
check(
"the bridge answers Studio's origins (the package's STUDIO_ORIGINS)",
page.body.includes(
'data-origins="[&quot;https://studio.decocms.com&quot;,&quot;https://studio-stg.decocms.com&quot;,&quot;https://decocms.com&quot;,&quot;https://*.decocms.com&quot;]"',
),
"the bridge is v8 only: the Studio channel, and no v7 framed handler",
page.body.includes("data-deco-studio-channel") &&
!page.body.includes("scrollToComponent") &&
!page.body.includes("window.self!==window.top"),
page.body.match(/data-origins="[^"]*"/)?.[0],
);
check(
Expand Down
2 changes: 1 addition & 1 deletion packages/nextjs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ import { StudioBridge } from "@decocms/nextjs";
</body>
```

Studio's origins are the package's own (`STUDIO_ORIGINS`), so the bridge takes only the site's name. v7's "." edit shortcut is on by default (`editShortcut={false}` turns it off), and `nonce` is optional.
Studio's origins are the package's own, so the bridge takes only the site's name. v7's "." edit shortcut is on by default (`editShortcut={false}` turns it off), and `nonce` is optional. The bridge is v8 only: v7's framed handler (`scrollToComponent`, `editor::rerender`) is gone, so no script on the page compares the window with `top` or `parent`, and a test runner that rewrites "frame-busting" code in the HTML it proxies (Cypress's `modifyObstructiveCode`) serves the page byte for byte.

The bridge also mounts the Studio channel on every page, production included: a small inline script (about 0.7 kB gzipped) through which Studio loads its own editor code (the section wireframe, the page swap after a save) into the page when it frames it. It is the only way Studio runs code in the page: the bridge evaluates no script Studio sends. The page trusts a list of exact Studio origins: `studioOrigins` when given, else the `DECO_STUDIO_ORIGIN` environment variable, comma-separated (a local Studio, such as `http://localhost:3000`), else Studio's production and staging origins (`DEFAULT_STUDIO_ORIGINS`). Framed, the page posts `{ type: "deco:ready" }` with each of them as the target origin (only a parent at one of them receives it) and imports a module only when Studio's answer, `{ type: "deco:load", url }`, comes from exactly one of them and names a URL on the origin of that message; not framed, or framed by any other origin, it does nothing. A site with a Content Security Policy passes it through `withStudioCsp(directives)`, which adds the same list to `script-src` (and a `frame-ancestors` with Studio's, when the policy has none): `headers: [{ source: "/:path*", headers: [{ key: "Content-Security-Policy", value: withStudioCsp("script-src 'self' 'unsafe-inline'; object-src 'none'").join("; ") }] }]`, or the same in the site's proxy. The inline scripts take the site's `nonce`. The message and module types are in `@decocms/blocks/protocol`.

Expand Down
68 changes: 47 additions & 21 deletions packages/nextjs/src/StudioBridge.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,43 @@
/**
* `StudioBridge` of `@decocms/blocks/preview` with Studio's origins filled in
* (`STUDIO_ORIGINS`, `./studio.ts`): the package owns where Studio runs, so a
* site passes only its name. With it, the Studio channel (`StudioChannel`):
* on every page, production included, framed by one of Studio's origins
* (`studioOrigins` / `DECO_STUDIO_ORIGIN` / Studio's production and staging
* origins, each exact) it loads the editor's own modules from that origin;
* otherwise it does nothing. Two inline scripts and no hooks, so a Server
* Component renders it as is. The site's CSP allows the same origins with
* `withStudioCsp`.
* What a Next.js site renders for Deco Studio's site editor: the Studio
* channel (`StudioChannel` of `@decocms/blocks/preview`) and v7's "." edit
* shortcut (`./studioShortcut.ts`). On every page, production included,
* framed by one of Studio's origins (`studioOrigins` / `DECO_STUDIO_ORIGIN` /
* Studio's production and staging origins, each exact), the channel loads
* the editor's own modules from that origin; otherwise it does nothing. Not
* framed, "." opens the page in Studio's editor.
*
* v8 only: v7's framed handler (`scrollToComponent`, `editor::rerender`) is
* gone, so no script on the page compares the window with `top` or `parent`
* in a form a test runner's "frame-busting" rewrite matches (Cypress's
* `modifyObstructiveCode`, which would change the length of the React Server
* Components payload that carries the script). Two inline scripts and no
* hooks, so a Server Component renders it as is. The site's CSP allows the
* same origins with `withStudioCsp`.
*/
import {
StudioBridge as BridgeScript,
type StudioBridgeProps as BridgeScriptProps,
StudioChannel,
} from "@decocms/blocks/preview";
import { StudioChannel, type StudioPageInfo } from "@decocms/blocks/preview";
import { createElement, Fragment, type ReactNode } from "react";
import { STUDIO_ORIGINS, studioOrigins } from "./studio.ts";
import { studioOrigins } from "./studio.ts";
import { StudioShortcut } from "./studioShortcut.ts";

/** `site`, and optionally `editShortcut`, `page` and `nonce`; Studio's origins are the package's. */
export interface StudioBridgeProps extends Omit<BridgeScriptProps, "trustedOrigins"> {
/** `site`, and optionally `editShortcut`, `page`, `nonce` and `studioOrigins`. */
export interface StudioBridgeProps {
/** The site's name in Deco (the "." shortcut sends it to Studio). */
site: string;
/**
* v7's editor shortcut: on a page that isn't framed, "." (or Ctrl+Shift+E)
* opens it in Studio, and Ctrl or Cmd+"." opens it in a new tab. On by
* default, as in v7; `false` turns it off.
*/
editShortcut?: boolean;
/**
* The page being shown, when the layout knows it: the "." shortcut sends
* its path template (and id). A `StudioPage` rendered by the page wins (a
* Next.js root layout doesn't re-render per page).
*/
page?: StudioPageInfo;
/** The CSP nonce for the inline scripts, when the site sends one. */
nonce?: string;
/**
* The Studio origins the Studio channel trusts, each an exact origin.
* Default: `DECO_STUDIO_ORIGIN` (comma-separated), else Studio's production
Expand All @@ -30,13 +49,20 @@ export interface StudioBridgeProps extends Omit<BridgeScriptProps, "trustedOrigi

/**
* Lets Studio's site editor select and edit sections on the page when Studio
* frames it. Render it once, in the root layout's `<body>`.
* frames it, and "." open the page in Studio when it doesn't. Render it once,
* in the root layout's `<body>`.
*/
export function StudioBridge({ studioOrigins: origins, ...props }: StudioBridgeProps): ReactNode {
export function StudioBridge({
site,
editShortcut,
page,
nonce,
studioOrigins: origins,
}: StudioBridgeProps): ReactNode {
return createElement(
Fragment,
null,
createElement(BridgeScript, { ...props, trustedOrigins: STUDIO_ORIGINS }),
createElement(StudioChannel, { studioOrigins: studioOrigins(origins), nonce: props.nonce }),
createElement(StudioShortcut, { site, editShortcut, page, nonce }),
createElement(StudioChannel, { studioOrigins: studioOrigins(origins), nonce }),
);
}
Loading
Loading