@alpaca-headless/dynamic-renderings 1.0.4694

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 (69) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +147 -0
  3. package/dist/client/ClientIslandRuntime.d.ts +1 -0
  4. package/dist/client/ClientIslandRuntime.js +12 -0
  5. package/dist/client/DynamicIsland.d.ts +12 -0
  6. package/dist/client/DynamicIsland.js +55 -0
  7. package/dist/client/index.d.ts +2 -0
  8. package/dist/client/index.js +4 -0
  9. package/dist/css-policy/css-policy.d.mts +1 -0
  10. package/dist/css-policy/css-policy.mjs +1 -0
  11. package/dist/handlers/build.d.ts +4 -0
  12. package/dist/handlers/build.js +99 -0
  13. package/dist/handlers/client.d.ts +6 -0
  14. package/dist/handlers/client.js +159 -0
  15. package/dist/handlers/index.d.ts +3 -0
  16. package/dist/handlers/index.js +6 -0
  17. package/dist/next/index.d.ts +5 -0
  18. package/dist/next/index.js +6 -0
  19. package/dist/next/prefix.d.ts +8 -0
  20. package/dist/next/prefix.js +25 -0
  21. package/dist/next/request.d.ts +31 -0
  22. package/dist/next/request.js +33 -0
  23. package/dist/next/routes.d.ts +16 -0
  24. package/dist/next/routes.js +15 -0
  25. package/dist/next/theme.d.ts +8 -0
  26. package/dist/next/theme.js +26 -0
  27. package/dist/protocol/index.d.ts +3 -0
  28. package/dist/protocol/index.js +2 -0
  29. package/dist/protocol/protocol.d.ts +2 -0
  30. package/dist/protocol/protocol.js +1 -0
  31. package/dist/protocol/types.d.ts +1 -0
  32. package/dist/protocol/types.js +1 -0
  33. package/dist/react/DynamicRendering.d.ts +32 -0
  34. package/dist/react/DynamicRendering.js +88 -0
  35. package/dist/react/DynamicTheme.d.ts +5 -0
  36. package/dist/react/DynamicTheme.js +13 -0
  37. package/dist/react/RichText.d.ts +7 -0
  38. package/dist/react/RichText.js +31 -0
  39. package/dist/react/index.d.ts +9 -0
  40. package/dist/react/index.js +8 -0
  41. package/dist/react/renderer.d.ts +45 -0
  42. package/dist/react/renderer.js +41 -0
  43. package/dist/react/to-react.d.ts +42 -0
  44. package/dist/react/to-react.js +112 -0
  45. package/dist/react/trusted.d.ts +3 -0
  46. package/dist/react/trusted.js +31 -0
  47. package/dist/rich-text/index.d.ts +1 -0
  48. package/dist/rich-text/index.js +1 -0
  49. package/dist/rich-text/sanitizeRichText.d.ts +4 -0
  50. package/dist/rich-text/sanitizeRichText.js +99 -0
  51. package/dist/server/backend.d.ts +25 -0
  52. package/dist/server/backend.js +36 -0
  53. package/dist/server/config.d.ts +98 -0
  54. package/dist/server/config.js +49 -0
  55. package/dist/server/engine.d.ts +83 -0
  56. package/dist/server/engine.js +207 -0
  57. package/dist/server/index.d.ts +12 -0
  58. package/dist/server/index.js +8 -0
  59. package/dist/server/page.d.ts +92 -0
  60. package/dist/server/page.js +235 -0
  61. package/dist/server/sanitize-tree.d.ts +28 -0
  62. package/dist/server/sanitize-tree.js +343 -0
  63. package/dist/server/theme.d.ts +14 -0
  64. package/dist/server/theme.js +38 -0
  65. package/next-config/index.cjs +101 -0
  66. package/next-config/index.d.cts +26 -0
  67. package/next-config/rewrites.cjs +25 -0
  68. package/next-config/rewrites.d.cts +3 -0
  69. package/package.json +108 -0
package/LICENSE ADDED
@@ -0,0 +1,8 @@
1
+ Parhelia - AI-Powered Editor for Sitecore
2
+
3
+ Copyright © 2025 Canvas REPLY GmbH. All rights reserved.
4
+
5
+ For full license terms and legal notice, please visit:
6
+ https://parhelia.ai/license
7
+
8
+ This software is proprietary and confidential.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # @alpaca-headless/dynamic-renderings
2
+
3
+ Optional dynamic renderings for Alpaca Headless: React components stored as
4
+ source on Sitecore items, compiled and run by the host itself through
5
+ `@alpaca-headless/dynamic-renderings-engine` (compile threads and QuickJS render threads inside the
6
+ head's process), and turned back into React. Design and threat model:
7
+ `Headless/dynamic-renderings.md` in the Alpaca repository. No Parhelia installation is required.
8
+
9
+ ## Requirements
10
+
11
+ - **React Server Components.** The Alpaca SDK's `Placeholder` is an async server component, so
12
+ every Alpaca head is an RSC app. Render trees become React elements on the server only; the
13
+ browser gets a rendering's client code only where it may run (see [Client code](#client-code)).
14
+ - **Node.js server runtime**, Node 22.13 or later: the engine uses worker threads.
15
+ - **React 19.2 or later** (`>=19.2.0 <20`).
16
+ - **Next.js** for the ready-made adapter (`/next`, `/next/config`). Other RSC frameworks can mount
17
+ the framework-neutral handlers (`/handlers`) at the same paths and must load the engine unbundled.
18
+
19
+ ## Adopting it in a Next.js head
20
+
21
+ 1. Dependencies: `@alpaca-headless/dynamic-renderings` and `@alpaca-headless/dynamic-renderings-engine` (same
22
+ version).
23
+ 2. `src/dynamic.config.ts`, the one configured instance for the page and the routes:
24
+
25
+ ```ts
26
+ import { defineDynamicRenderings } from "@alpaca-headless/dynamic-renderings/server";
27
+ import { nextThemeFile } from "@alpaca-headless/dynamic-renderings/next";
28
+
29
+ export const dynamicConfig = defineDynamicRenderings({
30
+ theme: nextThemeFile("src/app/[[...path]]/theme.css"),
31
+ });
32
+ ```
33
+
34
+ 3. The build route, at exactly this path (the CM calls it when a rendering is checked or saved):
35
+
36
+ ```ts
37
+ // src/app/api/alpaca/renderings/build/route.ts
38
+ import { buildRoute } from "@alpaca-headless/dynamic-renderings/next";
39
+ import { dynamicConfig } from "../../../../../dynamic.config";
40
+ export const { POST } = buildRoute(dynamicConfig);
41
+ ```
42
+
43
+ A root `.gitignore` with a `build/` rule hides it; add
44
+ `!src/app/api/alpaca/renderings/build/` (or the head's path to it).
45
+
46
+ 4. The client bundle route, at exactly this path (pages name it in their bundle URLs):
47
+
48
+ ```ts
49
+ // src/app/api/alpaca/renderings/client/[file]/route.ts
50
+ import { clientRoute } from "@alpaca-headless/dynamic-renderings/next";
51
+ import { dynamicConfig } from "../../../../../../dynamic.config";
52
+ export const { GET } = clientRoute(dynamicConfig);
53
+ ```
54
+
55
+ 5. `next.config.js`:
56
+
57
+ ```js
58
+ const { withDynamicRenderings } = require("@alpaca-headless/dynamic-renderings/next/config");
59
+ module.exports = withDynamicRenderings(nextConfig);
60
+ ```
61
+
62
+ It loads the engine unbundled (`serverExternalPackages`: its threads start from files next to
63
+ it), traces the engine and `src/app/**/theme.css` into standalone output
64
+ (`withDynamicRenderings(nextConfig, { themeFile })` for another location), and keeps a catch-all
65
+ CORS rule off `/api/alpaca/*`, also under a prefix. When a proxy serves the head's pages under a
66
+ path prefix, pass its patterns as `routePrefixes` (e.g. `["/editor/proxy/:host"]`): bundle URLs
67
+ under them are rewritten to the bundle route. The head's own settings are kept.
68
+
69
+ 6. The page:
70
+
71
+ ```tsx
72
+ const renderer = createRenderer(dynamicConfig); // from "@alpaca-headless/dynamic-renderings/react"
73
+
74
+ const request = await readRequest(dynamicConfig, params, searchParams); // before the layout call
75
+ const routeData = await resolveRoute({ path: request.route, searchParams: request.searchParams, ..., apiConfig: request.identity });
76
+ // ...redirects, errors, the head's own edit-mode context override (request.editing)...
77
+ const dynamic = await renderer.attach(context, routeData.result.page, request);
78
+ // render with dynamic.page and dynamic.context, never routeData's:
79
+ {dynamic.head}
80
+ {renderer.frame(dynamic, <Placeholder name="ph-content" content={dynamic.page} context={dynamic.context} />)}
81
+ ```
82
+
83
+ `renderer.frame` puts the tenant's theme tokens in front of the page body. `dynamic.head` holds
84
+ what client code needs on the page. The page must not be served from a page cache while a
85
+ rendering is still being compiled (`dynamic.state.preparing`); reading the request makes the
86
+ route dynamic already.
87
+
88
+ The CM side uses the Alpaca Site Root, its Headless API key and Rendering Host settings,
89
+ and `ALPACA_RENDERING_BUILD_KEY` (or the CM setting `Alpaca.Headless.DynamicRenderings.BuildKey`).
90
+ The same secret must be configured on the CM and host. See `Headless/dynamic-renderings.md`.
91
+ Nothing else runs next to the head.
92
+
93
+ ## Client code
94
+
95
+ A rendering's `Client Code` is imported by its code as `./client`. Each client component the
96
+ server code renders arrives as an island: a first paint the engine rendered in QuickJS (initial
97
+ state, no effects), sanitized like any markup, with the server code's children in its place.
98
+ Whether the browser also loads the real component is decided per page:
99
+
100
+ | Page | Client code |
101
+ | --- | --- |
102
+ | edit mode | never: the first paint only |
103
+ | a sealed request (`requestAuth` recognised it) | every island loads, inside an opaque-origin frame only |
104
+ | anything else | islands of renderings the CM reports as `approved` |
105
+
106
+ A bundle URL is `{prefix}/api/alpaca/renderings/client/{clientHash}-{compiledFormat}.js?id={renderingId}&database={db}`
107
+ (plus `&page={pageId}` when sealed), where `clientHash` is the first 16 hex digits of the SHA-256
108
+ of the LF-normalized client code. The path is kept short because a proxy prefix with a token may
109
+ precede it and the CM limits URL paths to 260 characters. The URL names everything needed to
110
+ build the bundle again, so any process serves it: from its compile cache when the bundle is
111
+ approved, otherwise after asking the CM for that rendering (approved ones only without a page; a
112
+ sealed request asks with its page and its own identity). A client code that no longer matches the
113
+ URL's hash answers 404, and a cached bundle is reused only for exactly the code the CM returned;
114
+ misses are remembered for a minute. Approved bundles are `public, immutable`, sealed ones
115
+ `private, no-store`.
116
+ The bundles take React from `globalThis.__alpaca_dynamic_react`, which `dynamic.head` sets on pages
117
+ that load them.
118
+
119
+ Editor integrations configure the rest in `defineDynamicRenderings`:
120
+
121
+ - `requestAuth(headers)`: returns the CM identity of a sealed request (headers only, no cookies);
122
+ such a page is always a preview.
123
+ - `transformPage(page, { sealed, prefix })`: adjusts the page data before it renders, e.g. media
124
+ URLs a sealed frame cannot load.
125
+ - `editor.clientCodeHintAttribute`: in preview mode, `dynamic.head` lists the renderings with
126
+ client code in `<script type="application/json" {attribute}="{id},...">`.
127
+
128
+ ## Subpaths
129
+
130
+ | Subpath | Contents | Loads in |
131
+ | --- | --- | --- |
132
+ | `/protocol` | limits, node types, allowed imports, compiler format, types | plain Node |
133
+ | `/css-policy` | the stylesheet policy shared with the engine | plain Node |
134
+ | `/rich-text` | the rich-text sanitizer | plain Node |
135
+ | `/server` | `defineDynamicRenderings`, CM client, engine client and compile cache, page preparation, sanitizer | server (`server-only`) |
136
+ | `/react` | `createRenderer`, render-tree conversion, trusted components | server components |
137
+ | `/client` | the island loader and the React global for client code bundles | browser (`"use client"` per file) |
138
+ | `/handlers` | the framework-neutral build and client bundle handlers | server |
139
+ | `/handlers/rewrites` | the CORS exclusion source and the bundle route rewrites | plain Node |
140
+ | `/next` | `readRequest`, `requestIdentity`, `buildRoute`, `clientRoute`, `nextThemeFile` | Next.js server |
141
+ | `/next/config` | `withDynamicRenderings` (CommonJS and ESM) | plain Node |
142
+
143
+ ## Versions
144
+
145
+ The head compiles and runs renderings itself, so the two packages always come from the same
146
+ release: install them at the same version. The build route reports the compiler format
147
+ (`compiledFormat`, 3 since client code), so the CM can tell whether a head runs client code.
@@ -0,0 +1 @@
1
+ export declare function ClientIslandRuntime(): null;
@@ -0,0 +1,12 @@
1
+ "use client";
2
+ // Browser bundles of client code are compiled with react, react-dom and react/jsx-runtime
3
+ // resolved from this global, so they share the host's single React. Set once, at module
4
+ // evaluation, before any island's effect runs. Rendered only on pages that load bundles.
5
+ import * as React from "react";
6
+ import * as ReactDOM from "react-dom";
7
+ import * as jsxRuntime from "react/jsx-runtime";
8
+ const globals = globalThis;
9
+ globals.__alpaca_dynamic_react ??= Object.freeze({ React, ReactDOM, jsxRuntime });
10
+ export function ClientIslandRuntime() {
11
+ return null;
12
+ }
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from "react";
2
+ type Props = {
3
+ src: string;
4
+ exportName: string;
5
+ props: Record<string, unknown>;
6
+ firstPaint: ReactNode;
7
+ mode: "approved" | "sealed";
8
+ children?: ReactNode;
9
+ };
10
+ export declare function isOpaqueOrigin(): boolean;
11
+ export declare function DynamicIsland({ src, exportName, props, firstPaint, mode, children }: Props): import("react").JSX.Element;
12
+ export {};
@@ -0,0 +1,55 @@
1
+ "use client";
2
+ import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime";
3
+ // A component of a rendering's client code. It always shows the first paint the engine rendered
4
+ // (static, sanitized) and replaces it with the real component once the browser bundle loaded.
5
+ // The server passes this only where client code may run: `approved` for a rendering an
6
+ // administrator approved (never in the edit canvas), `sealed` for an editor's isolated preview.
7
+ // A sealed island also checks that it really runs in an opaque-origin document, where its code
8
+ // cannot reach the editor, a CM response or any cookie.
9
+ import { Component, useEffect, useState } from "react";
10
+ class IslandBoundary extends Component {
11
+ state = { failed: false };
12
+ static getDerivedStateFromError() {
13
+ return { failed: true };
14
+ }
15
+ componentDidCatch(error) {
16
+ console.warn("[dynamic-renderings] a client component failed", error);
17
+ }
18
+ render() {
19
+ return this.state.failed ? this.props.fallback : this.props.children;
20
+ }
21
+ }
22
+ export function isOpaqueOrigin() {
23
+ return typeof self !== "undefined" && self.origin === "null";
24
+ }
25
+ export function DynamicIsland({ src, exportName, props, firstPaint, mode, children }) {
26
+ const [Loaded, setLoaded] = useState(null);
27
+ useEffect(() => {
28
+ if (mode !== "approved" && !(mode === "sealed" && isOpaqueOrigin()))
29
+ return;
30
+ // Set by ClientIslandRuntime, which the page renders whenever it loads bundles.
31
+ if (!globalThis.__alpaca_dynamic_react)
32
+ return;
33
+ let cancelled = false;
34
+ // The URL is built by the host, never by the rendering. The bundler must not rewrite it.
35
+ // Absolute, because Chromium does not resolve a root-relative specifier in an opaque-origin
36
+ // document (the sealed frame).
37
+ const url = new URL(src, document.baseURI).href;
38
+ import(/* webpackIgnore: true */ /* turbopackIgnore: true */ url)
39
+ .then((mod) => {
40
+ if (cancelled)
41
+ return;
42
+ const candidate = exportName === "default" ? mod.default : mod[exportName];
43
+ if (typeof candidate === "function" || (candidate && typeof candidate === "object")) {
44
+ setLoaded(() => candidate);
45
+ }
46
+ })
47
+ .catch((err) => console.warn("[dynamic-renderings] could not load client code", err));
48
+ return () => {
49
+ cancelled = true;
50
+ };
51
+ }, [src, exportName, mode]);
52
+ if (!Loaded)
53
+ return _jsx(_Fragment, { children: firstPaint });
54
+ return (_jsx(IslandBoundary, { fallback: firstPaint, children: _jsx(Loaded, { ...props, editing: false, children: children }) }));
55
+ }
@@ -0,0 +1,2 @@
1
+ export { DynamicIsland, isOpaqueOrigin } from "./DynamicIsland.js";
2
+ export { ClientIslandRuntime } from "./ClientIslandRuntime.js";
@@ -0,0 +1,4 @@
1
+ // Client components. This index is deliberately not a client module itself: each file carries
2
+ // its own "use client", so nothing server-callable ends up behind a client boundary.
3
+ export { DynamicIsland, isOpaqueOrigin } from "./DynamicIsland.js";
4
+ export { ClientIslandRuntime } from "./ClientIslandRuntime.js";
@@ -0,0 +1 @@
1
+ export * from "@alpaca-headless/dynamic-renderings-engine/css-policy";
@@ -0,0 +1 @@
1
+ export * from "@alpaca-headless/dynamic-renderings-engine/css-policy";
@@ -0,0 +1,4 @@
1
+ import "server-only";
2
+ import type { DynamicRenderingsConfig } from "../server/config.js";
3
+ /** POST: compile one rendering against this host's theme and try it once. */
4
+ export declare function handleBuild(config: DynamicRenderingsConfig, request: Request): Promise<Response>;
@@ -0,0 +1,99 @@
1
+ import "server-only";
2
+ import { timingSafeEqual } from "node:crypto";
3
+ import { compileRendering, moduleOf, renderTenant } from "../server/engine.js";
4
+ import { combineThemeHash, loadTheme } from "../server/theme.js";
5
+ import { protocol } from "../protocol/protocol.js";
6
+ // The CM asks this host to check a rendering before or when it is saved (the agent tools report
7
+ // the answer). The request carries the shared build key; the host compiles against its own theme
8
+ // and renders the result once with empty fields, in a context of its own. This route is not
9
+ // reachable through the CM's proxies: withDynamicRenderings rewrites only the client bundle
10
+ // route under a proxy prefix, and the head's catch-all page answers everything else there.
11
+ //
12
+ // compiledFormat tells the CM what this host's compiler supports: 3 and later run client code.
13
+ function authorized(config, request) {
14
+ const expected = config.buildKey;
15
+ const given = request.headers.get("x-alpaca-build-key");
16
+ if (!expected || !given)
17
+ return false;
18
+ const a = Buffer.from(expected);
19
+ const b = Buffer.from(given);
20
+ return a.length === b.length && timingSafeEqual(a, b);
21
+ }
22
+ function failure(status, file, message) {
23
+ return Response.json({ ok: false, compiledFormat: protocol.compiledFormat, diagnostics: [{ severity: "error", file, message }] }, { status });
24
+ }
25
+ /** POST: compile one rendering against this host's theme and try it once. */
26
+ export async function handleBuild(config, request) {
27
+ if (!config.buildKey)
28
+ return new Response(null, { status: 404 });
29
+ if (!authorized(config, request))
30
+ return new Response(null, { status: 401 });
31
+ let body;
32
+ try {
33
+ body = await request.json();
34
+ }
35
+ catch {
36
+ return Response.json({ error: "Body is not JSON." }, { status: 400 });
37
+ }
38
+ if (typeof body.renderingId !== "string" || typeof body.code !== "string") {
39
+ return Response.json({ error: "renderingId and code are required." }, { status: 400 });
40
+ }
41
+ let theme;
42
+ try {
43
+ theme = await loadTheme(config);
44
+ }
45
+ catch (err) {
46
+ return failure(500, "theme.css", `The theme file was not found or could not be read: ${err instanceof Error ? err.message : String(err)}`);
47
+ }
48
+ const started = Date.now();
49
+ const renderingId = body.renderingId.replace(/[{}]/g, "").toLowerCase();
50
+ const kind = typeof body.kind === "string" ? body.kind.trim().toLowerCase() : undefined;
51
+ const clientCode = typeof body.clientCode === "string" && body.clientCode.trim() ? body.clientCode : "";
52
+ const themeTokens = typeof body.themeTokens === "string" ? body.themeTokens : "";
53
+ const themeHash = combineThemeHash(theme.hash, themeTokens);
54
+ if (kind === "layout") {
55
+ return Response.json({
56
+ ok: false,
57
+ compiledFormat: protocol.compiledFormat,
58
+ themeHash,
59
+ diagnostics: [{ severity: "error", file: "rendering.tsx", message: "Layouts are not supported." }],
60
+ ms: Date.now() - started,
61
+ });
62
+ }
63
+ const compiled = await compileRendering(config, {
64
+ renderingId,
65
+ kind,
66
+ name: typeof body.name === "string" ? body.name : undefined,
67
+ code: body.code,
68
+ clientCode,
69
+ themeCss: theme.css,
70
+ themeTokens,
71
+ themeHash,
72
+ });
73
+ if (!compiled.ok) {
74
+ return Response.json({ ok: false, compiledFormat: protocol.compiledFormat, themeHash, diagnostics: compiled.diagnostics, ms: Date.now() - started }, { status: compiled.transient ? 503 : 200 });
75
+ }
76
+ // One trial render with empty fields, in a fresh context that is discarded afterwards. Real
77
+ // pages carry field values this trial does not have, so a failure here is a warning; that
78
+ // includes the first paints of the client components it renders.
79
+ const diagnostics = [];
80
+ const [trial] = await renderTenant(config, "__check__", [{ key: "check", buildId: compiled.buildId, dto: { id: renderingId, language: "en", editing: false, fields: {}, placeholders: [] } }], { [compiled.buildId]: moduleOf(compiled) }, { fresh: true });
81
+ if (trial && !trial.ok) {
82
+ const file = /\(client\.tsx\)/.test(trial.message) ? "client.tsx" : "rendering.tsx";
83
+ diagnostics.push({ severity: "warning", file, message: `With empty fields the rendering fails (${trial.code}): ${trial.message}` });
84
+ }
85
+ if (compiled.cssRejected)
86
+ diagnostics.push({ severity: "error", file: "styles.css", message: compiled.cssRejected });
87
+ const ok = !diagnostics.some((d) => d.severity === "error");
88
+ return Response.json({
89
+ ok,
90
+ compiledFormat: protocol.compiledFormat,
91
+ themeHash,
92
+ compiledServer: ok ? compiled.server : undefined,
93
+ compiledClient: ok ? (compiled.client?.browser ?? null) : undefined,
94
+ clientExports: ok ? (compiled.client?.exports ?? []) : [],
95
+ css: ok ? compiled.css : undefined,
96
+ diagnostics,
97
+ ms: Date.now() - started,
98
+ });
99
+ }
@@ -0,0 +1,6 @@
1
+ import "server-only";
2
+ import type { DynamicRenderingsConfig } from "../server/config.js";
3
+ /** GET: the browser bundle of one rendering's client code. */
4
+ export declare function handleClientBundle(config: DynamicRenderingsConfig, request: Request, file: string): Promise<Response>;
5
+ /** For tests: forget remembered misses. */
6
+ export declare function clearClientBundleMisses(): void;
@@ -0,0 +1,159 @@
1
+ import "server-only";
2
+ import { createHash } from "node:crypto";
3
+ import { fetchSources } from "../server/backend.js";
4
+ import { clientSourceHash, compileRendering, lookupClientBundle, rememberClientBundle } from "../server/engine.js";
5
+ import { combineThemeHash, loadTheme } from "../server/theme.js";
6
+ import { normalizeId } from "../server/page.js";
7
+ import { protocol } from "../protocol/protocol.js";
8
+ // The browser bundle of a rendering's client code:
9
+ // GET /api/alpaca/renderings/client/{clientHash}-{compiledFormat}.js?id={renderingId}&database=...[&page=...]
10
+ // The path is short on purpose: behind the sealed preview's proxy prefix, which holds a token, it
11
+ // must stay within the CM's URL length limit (the query has a limit of its own). The URL names
12
+ // everything needed to build the bundle again, so any head process serves it, also after its
13
+ // compile cache evicted it or it restarted:
14
+ // 1. an approved bundle in this process's cache is served at once;
15
+ // 2. otherwise the CM is asked for that one rendering: without a page it answers only for
16
+ // approved renderings; a sealed request asks with its page and its own identity, and the
17
+ // CM decides whether that preview may read it;
18
+ // 3. the returned client code must hash to the URL's (the rendering may have changed since the
19
+ // page was rendered; its first paint stays), and outside a sealed request it must be approved;
20
+ // a cached bundle is reused only for exactly that code (its full hash), else it is rebuilt;
21
+ // 4. it is compiled, and approved bundles are served as immutable: the URL names the source and
22
+ // the compiler format, so its content never changes.
23
+ // Unapproved bundles only ever reach sealed requests, and never from the cache alone. Misses are
24
+ // remembered for a minute, so made-up URLs cannot keep the CM busy.
25
+ const RENDERING_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
26
+ const BUNDLE_FILE = /^([0-9a-f]{16})-(\d{1,6})\.js$/;
27
+ const MISS_MS = 60_000;
28
+ const MAX_MISSES = 10_000;
29
+ const misses = new Map();
30
+ function missed(key) {
31
+ const at = misses.get(key);
32
+ if (at === undefined)
33
+ return false;
34
+ if (Date.now() - at < MISS_MS)
35
+ return true;
36
+ misses.delete(key);
37
+ return false;
38
+ }
39
+ function rememberMiss(key) {
40
+ if (misses.size >= MAX_MISSES) {
41
+ const now = Date.now();
42
+ for (const [k, at] of misses)
43
+ if (now - at >= MISS_MS)
44
+ misses.delete(k);
45
+ // Still full: the oldest go first (a Map iterates in insertion order).
46
+ for (const k of misses.keys()) {
47
+ if (misses.size < MAX_MISSES)
48
+ break;
49
+ misses.delete(k);
50
+ }
51
+ }
52
+ misses.set(key, Date.now());
53
+ }
54
+ function notFound() {
55
+ return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
56
+ }
57
+ function unavailable() {
58
+ return new Response(null, { status: 503, headers: { "cache-control": "no-store", "retry-after": "5" } });
59
+ }
60
+ function serve(code, sealed) {
61
+ return new Response(code, {
62
+ status: 200,
63
+ headers: {
64
+ "content-type": "text/javascript; charset=utf-8",
65
+ "x-content-type-options": "nosniff",
66
+ "cache-control": sealed ? "private, no-store" : "public, max-age=31536000, immutable",
67
+ },
68
+ });
69
+ }
70
+ /** The caller's own Sitecore credentials, as the page forwards them. */
71
+ function forwardedIdentity(config, request) {
72
+ const headers = new Headers();
73
+ for (const name of ["cookie", "authorization"]) {
74
+ const value = request.headers.get(name);
75
+ if (value)
76
+ headers.set(name, value);
77
+ }
78
+ return { apiKey: config.apiKey, headers };
79
+ }
80
+ /** GET: the browser bundle of one rendering's client code. */
81
+ export async function handleClientBundle(config, request, file) {
82
+ const url = new URL(request.url);
83
+ const renderingId = normalizeId(url.searchParams.get("id") ?? "");
84
+ const match = BUNDLE_FILE.exec(file);
85
+ if (!RENDERING_ID.test(renderingId) || !match || Number(match[2]) !== protocol.compiledFormat)
86
+ return notFound();
87
+ const hash = match[1];
88
+ const database = url.searchParams.get("database") || undefined;
89
+ const auth = config.requestAuth?.(request.headers) ?? null;
90
+ const sealed = auth !== null;
91
+ const pageId = sealed ? url.searchParams.get("page") || undefined : undefined;
92
+ const cached = lookupClientBundle(renderingId, hash);
93
+ if (cached?.approved)
94
+ return serve(cached.code, sealed);
95
+ const authHeaders = sealed ? new Headers(auth.headers) : null;
96
+ const missKey = [
97
+ renderingId,
98
+ file,
99
+ database ?? "",
100
+ pageId ?? "",
101
+ authHeaders ? createHash("sha256").update(JSON.stringify([...authHeaders])).digest("hex").slice(0, 16) : "",
102
+ ].join("|");
103
+ if (missed(missKey))
104
+ return notFound();
105
+ let response;
106
+ try {
107
+ const identity = authHeaders ? { apiKey: config.apiKey, headers: authHeaders } : forwardedIdentity(config, request);
108
+ response = await fetchSources(config, identity, { pageId, ids: [renderingId], database, mode: sealed ? "preview" : undefined });
109
+ }
110
+ catch (err) {
111
+ console.warn("[dynamic-renderings] sources call for a client bundle failed", err);
112
+ return unavailable();
113
+ }
114
+ if (response.status >= 500)
115
+ return unavailable();
116
+ const source = response.renderings.find((r) => normalizeId(r.id) === renderingId);
117
+ const code = source?.clientCode ?? "";
118
+ const sourceHash = clientSourceHash(code);
119
+ if (!source || !code.trim() || !sourceHash.startsWith(hash) || (!sealed && source.approved !== true)) {
120
+ rememberMiss(missKey);
121
+ return notFound();
122
+ }
123
+ const approved = source.approved === true;
124
+ if (cached && cached.sourceHash === sourceHash) {
125
+ rememberClientBundle(renderingId, { hash, sourceHash, browser: cached.code }, approved);
126
+ return serve(cached.code, sealed);
127
+ }
128
+ let theme;
129
+ try {
130
+ theme = await loadTheme(config);
131
+ }
132
+ catch (err) {
133
+ console.error("[dynamic-renderings] a client bundle cannot be built without the theme", err);
134
+ return unavailable();
135
+ }
136
+ const tokens = response.theme?.tokens ?? "";
137
+ const compiled = await compileRendering(config, {
138
+ renderingId,
139
+ kind: source.kind,
140
+ name: source.name,
141
+ code: source.code,
142
+ clientCode: code,
143
+ themeCss: theme.css,
144
+ themeTokens: tokens,
145
+ themeHash: combineThemeHash(theme.hash, tokens),
146
+ });
147
+ if (!compiled.ok && compiled.transient)
148
+ return unavailable();
149
+ if (!compiled.ok || !compiled.client) {
150
+ rememberMiss(missKey);
151
+ return notFound();
152
+ }
153
+ rememberClientBundle(renderingId, compiled.client, approved);
154
+ return serve(compiled.client.browser, sealed);
155
+ }
156
+ /** For tests: forget remembered misses. */
157
+ export function clearClientBundleMisses() {
158
+ misses.clear();
159
+ }
@@ -0,0 +1,3 @@
1
+ import "server-only";
2
+ export { handleBuild } from "./build.js";
3
+ export { handleClientBundle, clearClientBundleMisses } from "./client.js";
@@ -0,0 +1,6 @@
1
+ // Framework-neutral route handlers (Web Request/Response). Mount them at exactly these paths:
2
+ // POST /api/alpaca/renderings/build, which the CM calls, and
3
+ // GET /api/alpaca/renderings/client/{file}?id={renderingId}&..., which the page's bundle URLs name.
4
+ import "server-only";
5
+ export { handleBuild } from "./build.js";
6
+ export { handleClientBundle, clearClientBundleMisses } from "./client.js";
@@ -0,0 +1,5 @@
1
+ import "server-only";
2
+ export { readRequest, requestIdentity, splitProxyPrefix, normalizeRouteSearchParams } from "./request.js";
3
+ export type { DynamicRequest, RouteSearchParams } from "./request.js";
4
+ export { buildRoute, clientRoute } from "./routes.js";
5
+ export { nextThemeFile } from "./theme.js";
@@ -0,0 +1,6 @@
1
+ // Next.js adapter (server). The next.config.js wrapper lives in "./next/config", which must stay
2
+ // loadable from ordinary Node and therefore imports none of this.
3
+ import "server-only";
4
+ export { readRequest, requestIdentity, splitProxyPrefix, normalizeRouteSearchParams } from "./request.js";
5
+ export { buildRoute, clientRoute } from "./routes.js";
6
+ export { nextThemeFile } from "./theme.js";
@@ -0,0 +1,8 @@
1
+ export type RouteSearchParams = URLSearchParams | Record<string, string | string[] | undefined>;
2
+ /** The sc_* aliases the Alpaca SDK expects under their short names. */
3
+ export declare function normalizeRouteSearchParams(searchParams: RouteSearchParams): Record<string, string | string[] | undefined>;
4
+ /** Plain Alpaca routes have no editor proxy prefix. An editor can supply a request adapter. */
5
+ export declare function splitProxyPrefix(path: string[] | undefined): {
6
+ route: string | string[];
7
+ prefix: string;
8
+ };
@@ -0,0 +1,25 @@
1
+ // Pure helpers of the Next adapter (no Next or SDK imports), shared by readRequest and tests.
2
+ function getSearchParamValue(searchParams, key) {
3
+ const value = typeof searchParams.get === "function"
4
+ ? searchParams.get(key)
5
+ : searchParams[key];
6
+ if (value === null)
7
+ return undefined;
8
+ return Array.isArray(value) ? value[0] : value;
9
+ }
10
+ /** The sc_* aliases the Alpaca SDK expects under their short names. */
11
+ export function normalizeRouteSearchParams(searchParams) {
12
+ const entries = typeof searchParams.entries === "function"
13
+ ? Object.fromEntries(searchParams.entries())
14
+ : { ...searchParams };
15
+ const normalized = { ...entries };
16
+ normalized.mode ??= getSearchParamValue(searchParams, "sc_mode");
17
+ normalized.itemid ??= getSearchParamValue(searchParams, "sc_itemid");
18
+ normalized.lang ??= getSearchParamValue(searchParams, "sc_lang");
19
+ normalized.version ??= getSearchParamValue(searchParams, "sc_version");
20
+ return normalized;
21
+ }
22
+ /** Plain Alpaca routes have no editor proxy prefix. An editor can supply a request adapter. */
23
+ export function splitProxyPrefix(path) {
24
+ return { route: path?.length ? path : "/", prefix: "" };
25
+ }
@@ -0,0 +1,31 @@
1
+ import "server-only";
2
+ import type { DynamicRenderingsConfig } from "../server/config.js";
3
+ import type { BackendIdentity } from "../server/backend.js";
4
+ import type { DynamicRequestInfo } from "../react/renderer.js";
5
+ import { normalizeRouteSearchParams, splitProxyPrefix, type RouteSearchParams } from "./prefix.js";
6
+ export { normalizeRouteSearchParams, splitProxyPrefix, type RouteSearchParams };
7
+ type Awaitable<T> = T | Promise<T>;
8
+ export type DynamicRequest = DynamicRequestInfo & {
9
+ /** The route after any host-provided request adapter. */
10
+ route: string | string[];
11
+ /** Search params with the sc_* aliases filled in; a sealed request is always a preview. */
12
+ searchParams: Record<string, string | string[] | undefined>;
13
+ /** Whether the request is in edit mode. */
14
+ editing: boolean;
15
+ sealed: boolean;
16
+ };
17
+ /**
18
+ * Step 4 alone: the CM identity of this request. In a sealed request (the config's requestAuth
19
+ * recognised it) that is exactly the headers requestAuth returned, and no cookie; otherwise the
20
+ * forwarded Sitecore credentials. For heads that resolve their route in a cached function and
21
+ * need the identity there; readRequest() uses the same function. Reading request headers also
22
+ * opts the route out of static rendering.
23
+ */
24
+ export declare function requestIdentity(config: DynamicRenderingsConfig): Promise<{
25
+ identity: BackendIdentity;
26
+ sealed: boolean;
27
+ }>;
28
+ /** Splits the proxy prefix, applies the search-param aliases, builds the CM identity and decides edit mode. */
29
+ export declare function readRequest(config: DynamicRenderingsConfig, params: Awaitable<{
30
+ path?: string[];
31
+ }>, searchParams: Awaitable<RouteSearchParams>): Promise<DynamicRequest>;