@alpaca-headless/dynamic-renderings
v1.0.4697
Published
Optional Alpaca Headless dynamic renderings: source client, safe React rendering, client code and Next.js integration.
Readme
@alpaca-headless/dynamic-renderings
Optional dynamic renderings for Alpaca Headless: React components stored as
source on Sitecore items, compiled and run by the host itself through
@alpaca-headless/dynamic-renderings-engine (compile threads and QuickJS render threads inside the
head's process), and turned back into React. Design and threat model:
Headless/dynamic-renderings.md in the Alpaca repository. No Parhelia installation is required; a head
the Parhelia editor serves adds the /parhelia subpaths (see below).
Requirements
- React Server Components. The Alpaca SDK's
Placeholderis an async server component, so every Alpaca head is an RSC app. Render trees become React elements on the server only; the browser gets a rendering's client code only where it may run (see Client code). - Node.js server runtime, Node 22.13 or later: the engine uses worker threads.
- React 19.2 or later (
>=19.2.0 <20). - Next.js for the ready-made adapter (
/next,/next/config). Other RSC frameworks can mount the framework-neutral handlers (/handlers) at the same paths and must load the engine unbundled.
Adopting it in a Next.js head
Dependencies:
@alpaca-headless/dynamic-renderingsand@alpaca-headless/dynamic-renderings-engine(same version).src/dynamic.config.ts, the one configured instance for the page and the routes:import { defineDynamicRenderings } from "@alpaca-headless/dynamic-renderings/server"; import { nextThemeFile } from "@alpaca-headless/dynamic-renderings/next"; export const dynamicConfig = defineDynamicRenderings({ theme: nextThemeFile("src/app/[[...path]]/theme.css"), });The build route, at exactly this path (the CM calls it when a rendering is checked or saved):
// src/app/api/alpaca/renderings/build/route.ts import { buildRoute } from "@alpaca-headless/dynamic-renderings/next"; import { dynamicConfig } from "../../../../../dynamic.config"; export const { POST } = buildRoute(dynamicConfig);A root
.gitignorewith abuild/rule hides it; add!src/app/api/alpaca/renderings/build/(or the head's path to it).The client bundle route, at exactly this path (pages name it in their bundle URLs):
// src/app/api/alpaca/renderings/client/[file]/route.ts import { clientRoute } from "@alpaca-headless/dynamic-renderings/next"; import { dynamicConfig } from "../../../../../../dynamic.config"; export const { GET } = clientRoute(dynamicConfig);next.config.js:const { withDynamicRenderings } = require("@alpaca-headless/dynamic-renderings/next/config"); module.exports = withDynamicRenderings(nextConfig);It loads the engine unbundled (
serverExternalPackages: its threads start from files next to it), traces the engine andsrc/app/**/theme.cssinto standalone output (withDynamicRenderings(nextConfig, { themeFile })for another location), and keeps a catch-all CORS rule off/api/alpaca/*, also under a prefix. When a proxy serves the head's pages under a path prefix, pass its patterns asroutePrefixes(e.g.["/editor/proxy/:host"]): bundle URLs under them are rewritten to the bundle route. The head's own settings are kept.The page:
const renderer = createRenderer(dynamicConfig); // from "@alpaca-headless/dynamic-renderings/react" const request = await readRequest(dynamicConfig, params, searchParams); // before the layout call const routeData = await resolveRoute({ path: request.route, searchParams: request.searchParams, ..., apiConfig: request.identity }); // ...redirects, errors, the head's own edit-mode context override (request.editing)... const dynamic = await renderer.attach(context, routeData.result.page, request); // render with dynamic.page and dynamic.context, never routeData's: {dynamic.head} {renderer.frame(dynamic, <Placeholder name="ph-content" content={dynamic.page} context={dynamic.context} />)}renderer.frameputs the tenant's theme tokens in front of the page body.dynamic.headholds what client code needs on the page. The page must not be served from a page cache while a rendering is still being compiled (dynamic.state.preparing); reading the request makes the route dynamic already.
The CM side uses the Alpaca Site Root, its Headless API key and Rendering Host settings,
and ALPACA_RENDERING_BUILD_KEY (or the CM setting Alpaca.Headless.DynamicRenderings.BuildKey).
The same secret must be configured on the CM and host. See Headless/dynamic-renderings.md.
Nothing else runs next to the head.
Client code
A rendering's Client Code is imported by its code as ./client. Each client component the
server code renders arrives as an island: a first paint the engine rendered in QuickJS (initial
state, no effects), sanitized like any markup, with the server code's children in its place.
Whether the browser also loads the real component is decided per page:
| Page | Client code |
| --- | --- |
| a sealed request (requestAuth recognised it) | every island loads, inside an opaque-origin frame only |
| anything else, edit mode included | islands of renderings the CM reports as approved |
A loaded component always gets editing: false, in the edit canvas too; the first paint renders
with editing: true.
A bundle URL is {prefix}/api/alpaca/renderings/client/{clientHash}-{compiledFormat}.js?id={renderingId}&database={db}
(plus &page={pageId} when sealed), where clientHash is the first 16 hex digits of the SHA-256
of the LF-normalized client code. The path is kept short because a proxy prefix with a token may
precede it and the CM limits URL paths to 260 characters. The URL names everything needed to
build the bundle again, so any process serves it: from its compile cache when the bundle is
approved, otherwise after asking the CM for that rendering (approved ones only without a page; a
sealed request asks with its page and its own identity). A client code that no longer matches the
URL's hash answers 404, and a cached bundle is reused only for exactly the code the CM returned;
misses are remembered for a minute. Approved bundles are public, immutable, sealed ones
private, no-store.
The bundles take React from globalThis.__alpaca_dynamic_react, which dynamic.head sets on pages
that load them.
Editor integrations configure the rest in defineDynamicRenderings:
requestAuth(headers): returns the CM identity of a sealed request (headers only, no cookies); such a page is always a preview.transformPage(page, { sealed, prefix }): adjusts the page data before it renders, e.g. media URLs a sealed frame cannot load.editor.clientCodeHintAttribute: in preview mode,dynamic.headlists the renderings with client code in<script type="application/json" {attribute}="{id},...">.
Heads the Parhelia editor serves
Everything Parhelia-specific lives under /parhelia: the editor's markers, the CM's proxy
prefixes (/parhelia/proxy/{name}/..., /parhelia/preview/{token}/{name}/...) and the sealed
"Run client code" preview (its token as the request's identity, and its media). Use its
defineDynamicRenderings in place of the one from /server, and its withDynamicRenderings:
// src/dynamic.config.ts
import { defineDynamicRenderings } from "@alpaca-headless/dynamic-renderings/parhelia";
// src/app/api/parhelia/media/[code]/[...path]/route.ts
import { previewMediaRoute } from "@alpaca-headless/dynamic-renderings/parhelia/next";
export const { GET } = previewMediaRoute(dynamicConfig);// next.config.js
const { withDynamicRenderings } = require("@alpaca-headless/dynamic-renderings/parhelia/next/config");Everything else (readRequest, buildRoute, clientRoute, createRenderer) comes from the
subpaths below as in any head.
Subpaths
| Subpath | Contents | Loads in |
| --- | --- | --- |
| /protocol | limits, node types, allowed imports, compiler format, types | plain Node |
| /css-policy | the stylesheet policy shared with the engine | plain Node |
| /rich-text | the rich-text sanitizer | plain Node |
| /server | defineDynamicRenderings, CM client, engine client and compile cache, page preparation, sanitizer | server (server-only) |
| /react | createRenderer, render-tree conversion, trusted components | server components |
| /client | the island loader and the React global for client code bundles | browser ("use client" per file) |
| /handlers | the framework-neutral build and client bundle handlers | server |
| /handlers/rewrites | the CORS exclusion source and the bundle route rewrites | plain Node |
| /next | readRequest, requestIdentity, buildRoute, clientRoute, nextThemeFile | Next.js server |
| /next/config | withDynamicRenderings (CommonJS and ESM) | plain Node |
| /parhelia | the Parhelia editor preset: defineDynamicRenderings, the preview token's requestAuth, rewriteMediaUrls, splitProxyPrefix, handlePreviewMedia | server |
| /parhelia/next | previewMediaRoute | Next.js server |
| /parhelia/next/config | withDynamicRenderings with the Parhelia CM's proxy prefixes and the preview media rewrite | plain Node |
Versions
The head compiles and runs renderings itself, so the two packages always come from the same
release: install them at the same version. The build route reports the compiler format
(compiledFormat, 3 since client code), so the CM can tell whether a head runs client code.
