@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
@@ -0,0 +1,99 @@
1
+ import sanitizeHtml from "sanitize-html";
2
+ // Allowlist for rich-text field HTML before it is rendered.
3
+ //
4
+ // The Alpaca SDK renders rich text with dangerouslySetInnerHTML, and in the
5
+ // Parhelia editor this page is served through the CM proxy (same origin as the
6
+ // editor), so any script in a field would run with the editing user's session.
7
+ // Field values come from every write path (editor, agents, imports, Content
8
+ // Editor, serialized items), so they are filtered here, where they reach the DOM.
9
+ //
10
+ // Every Alpaca head carries an identical copy of this file, because each head
11
+ // installs and deploys on its own; hosts/playground-site/scripts/
12
+ // test-rich-text-sanitizer.mjs tests it and checks that the copies match.
13
+ // The editor bridge applies the same policy to values it writes into the canvas
14
+ // (frontend/editor-bridge/host/rich-text-sanitizer.ts); change both together.
15
+ const ALLOWED_TAGS = [
16
+ "a", "abbr", "b", "blockquote", "br", "caption", "cite", "code", "col",
17
+ "colgroup", "dd", "del", "dfn", "div", "dl", "dt", "em", "figcaption",
18
+ "figure", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "i", "img", "ins", "kbd",
19
+ "li", "mark", "ol", "p", "pre", "q", "s", "samp", "small", "span", "strong",
20
+ "sub", "sup", "table", "tbody", "td", "tfoot", "th", "thead", "time", "tr",
21
+ "u", "ul", "wbr",
22
+ ];
23
+ const TABLE_CELL_ATTRIBUTES = [
24
+ "colspan", "rowspan", "headers", "scope", "align", "valign", "width", "height",
25
+ ];
26
+ const ALLOWED_ATTRIBUTES = {
27
+ "*": ["class", "id", "title", "lang", "dir", "style", "aria-*"],
28
+ // data-internal / data-item-id / data-querystring mark internal links for the
29
+ // editor (RichTextInternalLinkTagger / RichTextLinkNormalizer on the CM).
30
+ a: ["href", "target", "rel", "data-internal", "data-item-id", "data-querystring"],
31
+ img: ["src", "alt", "width", "height", "loading"],
32
+ table: ["border", "cellpadding", "cellspacing", "width", "align"],
33
+ tr: ["align", "valign"],
34
+ td: TABLE_CELL_ATTRIBUTES,
35
+ th: TABLE_CELL_ATTRIBUTES,
36
+ col: ["span", "width"],
37
+ colgroup: ["span", "width"],
38
+ ol: ["start", "reversed", "type"],
39
+ li: ["value"],
40
+ time: ["datetime"],
41
+ del: ["cite", "datetime"],
42
+ ins: ["cite", "datetime"],
43
+ blockquote: ["cite"],
44
+ q: ["cite"],
45
+ };
46
+ // Formatting only: nothing that positions content over the page (an overlay
47
+ // inside the editor canvas) and no value that loads a resource.
48
+ const STYLE_PROPERTIES = [
49
+ "color", "background-color", "font-family", "font-size", "font-style",
50
+ "font-weight", "letter-spacing", "line-height", "text-align",
51
+ "text-decoration", "text-decoration-line", "text-decoration-color",
52
+ "text-decoration-style", "text-indent", "text-transform", "vertical-align",
53
+ "white-space", "direction", "margin", "margin-top", "margin-right",
54
+ "margin-bottom", "margin-left", "padding", "padding-top", "padding-right",
55
+ "padding-bottom", "padding-left", "border", "border-top", "border-right",
56
+ "border-bottom", "border-left", "border-color", "border-style",
57
+ "border-width", "border-collapse", "border-spacing", "border-radius",
58
+ "width", "height", "min-width", "max-width", "min-height", "max-height",
59
+ "list-style-type", "float", "clear", "display",
60
+ ];
61
+ const SAFE_STYLE_VALUE = /^(?![\s\S]*(?:url\s*\(|image-set\s*\(|image\s*\(|expression\s*\(|javascript:|vbscript:|@import|behavior|binding|\\))[\s\S]*$/i;
62
+ // Leading underscores are what page scripts use for globals (__NEXT_DATA__,
63
+ // __PARHELIA_EDITING_BRIDGE_RUNTIME__); an id there would shadow them.
64
+ const SAFE_ID = /^[A-Za-z][\w:.-]*$/;
65
+ const OPTIONS = {
66
+ allowedTags: ALLOWED_TAGS,
67
+ allowedAttributes: ALLOWED_ATTRIBUTES,
68
+ allowedSchemes: ["http", "https", "mailto", "tel"],
69
+ allowedSchemesByTag: { img: ["http", "https", "data"] },
70
+ allowedSchemesAppliedToAttributes: ["href", "src", "cite"],
71
+ allowProtocolRelative: true,
72
+ allowedStyles: {
73
+ "*": Object.fromEntries(STYLE_PROPERTIES.map((name) => [name, [SAFE_STYLE_VALUE]])),
74
+ },
75
+ // Dropped together with their content, not unwrapped into text.
76
+ nonTextTags: [
77
+ "script", "style", "textarea", "option", "noscript", "xmp", "noembed",
78
+ "noframes", "iframe", "template", "title",
79
+ ],
80
+ transformTags: {
81
+ "*": (tagName, attribs) => {
82
+ if (attribs.id === undefined || SAFE_ID.test(attribs.id))
83
+ return { tagName, attribs };
84
+ const rest = { ...attribs };
85
+ delete rest.id;
86
+ return { tagName, attribs: rest };
87
+ },
88
+ },
89
+ };
90
+ /** Filters rich-text HTML down to the formatting markup above. */
91
+ export function sanitizeRichTextHtml(html) {
92
+ return sanitizeHtml(html, OPTIONS);
93
+ }
94
+ /** Same for a field value, which may be missing or not a string. */
95
+ export function sanitizeRichTextValue(value) {
96
+ if (value === null || value === undefined)
97
+ return value;
98
+ return sanitizeRichTextHtml(typeof value === "string" ? value : String(value));
99
+ }
@@ -0,0 +1,25 @@
1
+ import "server-only";
2
+ import type { SourcesResponse } from "../protocol/types.js";
3
+ import type { DynamicRenderingsConfig } from "./config.js";
4
+ export type BackendIdentity = {
5
+ apiKey: string;
6
+ /** Forwarded Cookie / Authorization headers, or a sealed request's own headers. */
7
+ headers: Headers;
8
+ };
9
+ export type SourcesQuery = {
10
+ /** With a page: the page's renderings the caller may read, and its tenant's theme. */
11
+ pageId?: string;
12
+ ids: string[];
13
+ database?: string;
14
+ mode?: string;
15
+ };
16
+ /**
17
+ * The source of every requested dynamic rendering. With a page, those of the page's tenant that
18
+ * the caller may read, plus the tenant's theme tokens; without one (a bundle rebuilt after a
19
+ * cache miss), approved renderings only. On a publishing target the CM returns approved
20
+ * renderings only either way. `status` is the CM's answer, for callers that must tell a refusal
21
+ * from an outage.
22
+ */
23
+ export declare function fetchSources(config: DynamicRenderingsConfig, identity: BackendIdentity, { pageId, ids, database, mode }: SourcesQuery): Promise<SourcesResponse & {
24
+ status: number;
25
+ }>;
@@ -0,0 +1,36 @@
1
+ import "server-only";
2
+ async function getJson(config, route, identity) {
3
+ const headers = new Headers(identity.headers);
4
+ headers.set("x-api-key", identity.apiKey);
5
+ headers.set("accept", "application/json");
6
+ const response = await fetch(config.layoutServiceUrl() + route, { headers, redirect: "manual", cache: "no-store" });
7
+ if (response.status !== 200)
8
+ return { status: response.status };
9
+ const type = response.headers.get("content-type") || "";
10
+ if (!type.includes("json"))
11
+ return { status: 502 };
12
+ return { status: 200, data: (await response.json()) };
13
+ }
14
+ /**
15
+ * The source of every requested dynamic rendering. With a page, those of the page's tenant that
16
+ * the caller may read, plus the tenant's theme tokens; without one (a bundle rebuilt after a
17
+ * cache miss), approved renderings only. On a publishing target the CM returns approved
18
+ * renderings only either way. `status` is the CM's answer, for callers that must tell a refusal
19
+ * from an outage.
20
+ */
21
+ export async function fetchSources(config, identity, { pageId, ids, database, mode }) {
22
+ const query = new URLSearchParams();
23
+ if (pageId)
24
+ query.set("page", pageId);
25
+ query.set("ids", ids.join(","));
26
+ if (database)
27
+ query.set("database", database);
28
+ if (mode)
29
+ query.set("mode", mode);
30
+ const { status, data } = await getJson(config, `/alpaca/headless/renderings/sources?${query}`, identity);
31
+ if (status !== 200 || !data || !Array.isArray(data.renderings)) {
32
+ console.warn(`[dynamic-renderings] sources for ${pageId ? `page ${pageId}` : `rendering ${ids.join(",")}`} answered ${status}; dynamic components are not shown.`);
33
+ return { renderings: [], status: status === 200 ? 502 : status };
34
+ }
35
+ return { ...data, status };
36
+ }
@@ -0,0 +1,98 @@
1
+ import { protocol } from "../protocol/protocol.js";
2
+ /**
3
+ * Where the theme a head compiles rendering CSS against comes from. `load()` returns the CSS
4
+ * text, or throws when it cannot be read: a missing theme is an error, never empty CSS, so a
5
+ * packaging mistake cannot look healthy.
6
+ */
7
+ export type ThemeSource = {
8
+ /** Where the theme lives, for diagnostics. */
9
+ describe: string;
10
+ load(): Promise<string>;
11
+ };
12
+ export declare class ThemeUnavailableError extends Error {
13
+ constructor(message: string);
14
+ }
15
+ /** For a head without design tokens: says so explicitly, instead of a missing file. */
16
+ export declare function noTheme(): ThemeSource;
17
+ /** A theme given as text, e.g. imported at build time. */
18
+ export declare function inlineTheme(css: string, describe?: string): ThemeSource;
19
+ /** The engine's threads. Defaults come from the protocol limits. */
20
+ export type EngineSettings = {
21
+ /** Render threads, one per active tenant. Default 4. */
22
+ renderThreads?: number;
23
+ /** Compile threads. Default 2. */
24
+ compileThreads?: number;
25
+ };
26
+ /** Editor integration is supplied by the host; the runtime does not depend on an editor. */
27
+ export type EditorMarkers = {
28
+ nonceAttribute: string;
29
+ slotAttribute: string;
30
+ /**
31
+ * In preview mode (not edit, not sealed) the page lists the renderings on it that have client
32
+ * code: `<script type="application/json" {attribute}="{id},{id}">`.
33
+ */
34
+ clientCodeHintAttribute?: string;
35
+ };
36
+ export type RequestRoute = {
37
+ route: string | string[];
38
+ prefix: string;
39
+ editing: boolean;
40
+ editorFrame: boolean;
41
+ };
42
+ export type RequestAdapter = (path: string[] | undefined, search: Record<string, string | string[] | undefined>) => RequestRoute;
43
+ /**
44
+ * Recognises a sealed request: one that reaches the head through an editor's isolated preview.
45
+ * The returned headers are then its whole CM identity (no cookies are forwarded), the page is
46
+ * always a preview, and every client component on it loads its code.
47
+ */
48
+ export type RequestAuth = (headers: Headers) => {
49
+ headers: HeadersInit;
50
+ } | null;
51
+ /** Adjusts the page data before anything renders it, e.g. media URLs in a sealed request. */
52
+ export type PageTransform = <P>(page: P, info: {
53
+ sealed: boolean;
54
+ prefix: string;
55
+ }) => P;
56
+ export type DynamicRenderingsOptions = {
57
+ editor?: EditorMarkers;
58
+ requestAdapter?: RequestAdapter;
59
+ requestAuth?: RequestAuth;
60
+ transformPage?: PageTransform;
61
+ /** Required: the tokens renderings compile against. */
62
+ theme: ThemeSource;
63
+ /** Shared secret the CM presents to the build route. Default: ALPACA_RENDERING_BUILD_KEY. */
64
+ buildKey?: string;
65
+ /** API key for the CM calls. Default: ALPACA_HEADLESS_API_KEY, then API_KEY. */
66
+ apiKey?: string;
67
+ /**
68
+ * Tenants under this path are sandbox users: their reference fields are fenced to their own
69
+ * root. Unset for plain site heads.
70
+ */
71
+ sandboxRoot?: string;
72
+ /** Absolute media hosts allowed in `src`. Default: ALPACA_MEDIA_HOSTS, split on commas. */
73
+ mediaHosts?: string[];
74
+ /** The CM origin. Default: RUNTIME_LAYOUT_SERVICE_URL, then LAYOUT_SERVICE_URL, then http://localhost:5002, read per call. */
75
+ layoutServiceUrl?: string;
76
+ engine?: EngineSettings;
77
+ };
78
+ export type DynamicRenderingsConfig = {
79
+ readonly editor?: Readonly<EditorMarkers>;
80
+ readonly requestAdapter?: RequestAdapter;
81
+ readonly requestAuth?: RequestAuth;
82
+ readonly transformPage?: PageTransform;
83
+ readonly theme: ThemeSource;
84
+ readonly buildKey: string;
85
+ readonly apiKey: string;
86
+ readonly sandboxRoot?: string;
87
+ readonly mediaHosts: readonly string[];
88
+ /** Resolved per call, because heads may set the layout service URL at request time. */
89
+ layoutServiceUrl(): string;
90
+ readonly limits: {
91
+ readonly [K in keyof typeof protocol.limits]: number;
92
+ };
93
+ readonly engine: Readonly<EngineSettings>;
94
+ };
95
+ /** The one configured instance a head uses for its page and its build route. */
96
+ export declare function defineDynamicRenderings(options: DynamicRenderingsOptions): DynamicRenderingsConfig;
97
+ /** True when the tenant is a sandbox user (only with a configured sandbox root). */
98
+ export declare function isSandboxTenant(config: Pick<DynamicRenderingsConfig, "sandboxRoot">, tenant: string): boolean;
@@ -0,0 +1,49 @@
1
+ import { protocol } from "../protocol/protocol.js";
2
+ export class ThemeUnavailableError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "ThemeUnavailableError";
6
+ }
7
+ }
8
+ /** For a head without design tokens: says so explicitly, instead of a missing file. */
9
+ export function noTheme() {
10
+ return { describe: "no theme", load: async () => "" };
11
+ }
12
+ /** A theme given as text, e.g. imported at build time. */
13
+ export function inlineTheme(css, describe = "inline theme") {
14
+ return { describe, load: async () => css };
15
+ }
16
+ function env(name) {
17
+ return typeof process !== "undefined" ? process.env[name]?.trim() || undefined : undefined;
18
+ }
19
+ /** The one configured instance a head uses for its page and its build route. */
20
+ export function defineDynamicRenderings(options) {
21
+ if (!options?.theme || typeof options.theme.load !== "function") {
22
+ throw new Error("defineDynamicRenderings: `theme` is required (use nextThemeFile(...) or noTheme()).");
23
+ }
24
+ const sandboxRoot = options.sandboxRoot?.trim() || undefined;
25
+ return Object.freeze({
26
+ theme: options.theme,
27
+ // Read per use: heads may set these variables after the config module was evaluated (a head
28
+ // that defaults its API key on the first request, for example).
29
+ get buildKey() {
30
+ return options.buildKey ?? env("ALPACA_RENDERING_BUILD_KEY") ?? "";
31
+ },
32
+ get apiKey() {
33
+ return options.apiKey ?? env("ALPACA_HEADLESS_API_KEY") ?? env("API_KEY") ?? "";
34
+ },
35
+ sandboxRoot: sandboxRoot ? (sandboxRoot.endsWith("/") ? sandboxRoot : `${sandboxRoot}/`) : undefined,
36
+ mediaHosts: Object.freeze((options.mediaHosts ?? (env("ALPACA_MEDIA_HOSTS") ?? "").split(",")).map((h) => h.trim().toLowerCase()).filter(Boolean)),
37
+ layoutServiceUrl: () => (options.layoutServiceUrl ?? env("RUNTIME_LAYOUT_SERVICE_URL") ?? env("LAYOUT_SERVICE_URL") ?? "http://localhost:5002").replace(/\/+$/, ""),
38
+ editor: options.editor ? Object.freeze({ ...options.editor }) : undefined,
39
+ requestAdapter: options.requestAdapter,
40
+ requestAuth: options.requestAuth,
41
+ transformPage: options.transformPage,
42
+ limits: protocol.limits,
43
+ engine: Object.freeze({ ...(options.engine ?? {}) }),
44
+ });
45
+ }
46
+ /** True when the tenant is a sandbox user (only with a configured sandbox root). */
47
+ export function isSandboxTenant(config, tenant) {
48
+ return Boolean(config.sandboxRoot) && tenant.toLowerCase().startsWith(config.sandboxRoot.toLowerCase());
49
+ }
@@ -0,0 +1,83 @@
1
+ import "server-only";
2
+ import type { Diagnostic, Engine, EngineModuleSource } from "@alpaca-headless/dynamic-renderings-engine";
3
+ import type { ComponentDto, RenderResult } from "../protocol/types.js";
4
+ import type { DynamicRenderingsConfig } from "./config.js";
5
+ export type CompiledClient = {
6
+ /** The browser ES module. */
7
+ browser: string;
8
+ /** The CommonJS module the render threads run for first paints. */
9
+ node: string;
10
+ exports: string[];
11
+ /** clientHash() of the client code: its name in bundle URLs. */
12
+ hash: string;
13
+ /** clientSourceHash() of the client code: what a rebuilt bundle is checked against. */
14
+ sourceHash: string;
15
+ };
16
+ export type CompiledRendering = {
17
+ ok: true;
18
+ buildId: string;
19
+ server: string;
20
+ client?: CompiledClient;
21
+ css: string | null;
22
+ cssRejected?: string;
23
+ } | {
24
+ ok: false;
25
+ buildId: string;
26
+ diagnostics: Diagnostic[];
27
+ transient?: boolean;
28
+ };
29
+ export type ClientBundle = {
30
+ code: string;
31
+ sourceHash: string;
32
+ approved: boolean;
33
+ };
34
+ export declare function getEngine(config: DynamicRenderingsConfig): Promise<Engine>;
35
+ /** SHA-256 of the LF-normalized source, as the CM computes it. */
36
+ export declare function sourceHash(code: string): string;
37
+ /** SHA-256 of the client code's LF-normalized text. */
38
+ export declare function clientSourceHash(clientCode: string): string;
39
+ /**
40
+ * The client code's name in bundle URLs: the first 16 hex digits of clientSourceHash(). Short,
41
+ * because the URL sits behind a proxy prefix that holds a token; a rebuilt bundle is checked
42
+ * against the full hash.
43
+ */
44
+ export declare function clientHash(clientCode: string): string;
45
+ export type CompileInput = {
46
+ renderingId: string;
47
+ kind?: string;
48
+ name?: string;
49
+ code: string;
50
+ clientCode?: string | null;
51
+ themeCss: string;
52
+ themeTokens?: string | null;
53
+ /** combineThemeHash(theme file hash, tokens). */
54
+ themeHash: string;
55
+ };
56
+ /** The compiled rendering, from the cache or a compile thread. Concurrent calls share one compile. */
57
+ export declare function compileRendering(config: DynamicRenderingsConfig, input: CompileInput): Promise<CompiledRendering>;
58
+ /** What the render threads need of a compiled rendering. */
59
+ export declare function moduleOf(compiled: Extract<CompiledRendering, {
60
+ ok: true;
61
+ }>): EngineModuleSource;
62
+ /** Keeps a browser bundle, by rendering and client hash, with the approval the CM reported for it just now. */
63
+ export declare function rememberClientBundle(renderingId: string, client: Pick<CompiledClient, "hash" | "sourceHash" | "browser">, approved: boolean): void;
64
+ export declare function lookupClientBundle(renderingId: string, hash: string): ClientBundle | undefined;
65
+ export type TenantComponent = {
66
+ key: string;
67
+ buildId: string;
68
+ dto: ComponentDto;
69
+ };
70
+ /** Renders the components of one tenant in that tenant's render thread. */
71
+ export declare function renderTenant(config: DynamicRenderingsConfig, tenant: string, components: TenantComponent[], modules: Record<string, EngineModuleSource>, options?: {
72
+ deadlineMs?: number;
73
+ fresh?: boolean;
74
+ }): Promise<RenderResult[]>;
75
+ /** For tests: forget cached compiles and bundles (the engine keeps running). */
76
+ export declare function clearCompileCache(): void;
77
+ /** Cache figures, for diagnostics. */
78
+ export declare function compileCacheStats(): {
79
+ entries: number;
80
+ bytes: number;
81
+ inflight: number;
82
+ bundles: number;
83
+ };
@@ -0,0 +1,207 @@
1
+ import "server-only";
2
+ import { createHash } from "node:crypto";
3
+ import { verifyScopedCss } from "../css-policy/css-policy.mjs";
4
+ // The host compiles and runs renderings itself, through @alpaca-headless/dynamic-renderings-engine:
5
+ // compile threads and QuickJS render threads inside this process (the main thread never runs
6
+ // rendering code). The engine is an external package for Next (see withDynamicRenderings), so
7
+ // its thread files load from disk instead of being bundled.
8
+ //
9
+ // Compiled renderings are cached per process, keyed by rendering id, source hashes and theme hash
10
+ // (the stylesheet embeds the rendering id, so identical source in two items is two entries). The
11
+ // cache is byte-counted and evicts the least recently used entry. Failures that say something
12
+ // about the source are cached like successes; transient ones (queue full, deadline, a stopped
13
+ // thread) are not.
14
+ //
15
+ // Browser bundles of client code are kept apart, by rendering id and client hash, with the
16
+ // approval the CM last reported for them: the bundle route serves them from there.
17
+ const MAX_CACHE_BYTES = 128 * 1024 * 1024;
18
+ const MAX_BUNDLE_BYTES = 32 * 1024 * 1024;
19
+ // Next can evaluate this module more than once in one process (dev reloads, separate route
20
+ // bundles). One engine per process: the holder lives on globalThis.
21
+ const HOLDER = Symbol.for("parhelia.dynamic-renderings.engine");
22
+ function holder() {
23
+ const g = globalThis;
24
+ const h = (g[HOLDER] ??= { entries: new Map(), bytes: 0, inflight: new Map(), bundles: new Map(), bundleBytes: 0 });
25
+ // A holder an earlier version of this module created in the same process.
26
+ h.bundles ??= new Map();
27
+ h.bundleBytes ??= 0;
28
+ return h;
29
+ }
30
+ // The engine starts its threads from files next to its own module, so no bundler may touch it:
31
+ // the import stays a runtime import, which Node resolves from the app's node_modules. Marking the
32
+ // package external (serverExternalPackages) is not enough on its own, because Next bundles
33
+ // linked packages that resolve outside node_modules anyway.
34
+ const ENGINE_PACKAGE = "@alpaca-headless/dynamic-renderings-engine";
35
+ export function getEngine(config) {
36
+ const h = holder();
37
+ h.engine ??= import(/* webpackIgnore: true */ /* turbopackIgnore: true */ ENGINE_PACKAGE).then((m) => m.createEngine({ renderThreads: config.engine.renderThreads, compileThreads: config.engine.compileThreads }));
38
+ return h.engine;
39
+ }
40
+ const sha256Hex = (text) => createHash("sha256").update(text).digest("hex");
41
+ /** SHA-256 of the LF-normalized source, as the CM computes it. */
42
+ export function sourceHash(code) {
43
+ return sha256Hex(code.replace(/\r\n/g, "\n"));
44
+ }
45
+ /** SHA-256 of the client code's LF-normalized text. */
46
+ export function clientSourceHash(clientCode) {
47
+ return sha256Hex(clientCode.replace(/\r\n?/g, "\n"));
48
+ }
49
+ /**
50
+ * The client code's name in bundle URLs: the first 16 hex digits of clientSourceHash(). Short,
51
+ * because the URL sits behind a proxy prefix that holds a token; a rebuilt bundle is checked
52
+ * against the full hash.
53
+ */
54
+ export function clientHash(clientCode) {
55
+ return clientSourceHash(clientCode).slice(0, 16);
56
+ }
57
+ function sizeOf(value) {
58
+ if (!value.ok)
59
+ return JSON.stringify(value.diagnostics).length;
60
+ return value.server.length + (value.css?.length ?? 0) + (value.client ? value.client.browser.length + value.client.node.length : 0);
61
+ }
62
+ function remember(key, value) {
63
+ const h = holder();
64
+ const bytes = sizeOf(value);
65
+ const previous = h.entries.get(key);
66
+ if (previous) {
67
+ h.entries.delete(key);
68
+ h.bytes -= previous.bytes;
69
+ }
70
+ h.entries.set(key, { value, bytes });
71
+ h.bytes += bytes;
72
+ while (h.bytes > MAX_CACHE_BYTES && h.entries.size > 1) {
73
+ const [oldest, entry] = h.entries.entries().next().value;
74
+ h.entries.delete(oldest);
75
+ h.bytes -= entry.bytes;
76
+ }
77
+ }
78
+ function lookup(key) {
79
+ const h = holder();
80
+ const entry = h.entries.get(key);
81
+ if (!entry)
82
+ return undefined;
83
+ h.entries.delete(key);
84
+ h.entries.set(key, entry);
85
+ return entry.value;
86
+ }
87
+ function toCompiled(buildId, renderingId, clientCode, result, cssBytes) {
88
+ if (!result.ok || typeof result.compiledServer !== "string") {
89
+ return { ok: false, buildId, diagnostics: result.diagnostics ?? [], transient: result.transient };
90
+ }
91
+ const client = typeof result.compiledClient === "string" && typeof result.compiledClientNode === "string"
92
+ ? {
93
+ browser: result.compiledClient,
94
+ node: result.compiledClientNode,
95
+ exports: result.clientExports ?? [],
96
+ hash: clientHash(clientCode),
97
+ sourceHash: clientSourceHash(clientCode),
98
+ }
99
+ : undefined;
100
+ const css = result.css || null;
101
+ if (css) {
102
+ // Defense in depth: the compiler scoped the stylesheet; check it again before it is ever inserted.
103
+ const verdict = verifyScopedCss(css, renderingId, cssBytes);
104
+ if (!verdict.ok) {
105
+ return { ok: true, buildId, server: result.compiledServer, client, css: null, cssRejected: verdict.errors.map((e) => e.message).join("; ") };
106
+ }
107
+ }
108
+ return { ok: true, buildId, server: result.compiledServer, client, css };
109
+ }
110
+ /** The compiled rendering, from the cache or a compile thread. Concurrent calls share one compile. */
111
+ export function compileRendering(config, input) {
112
+ const clientCode = input.clientCode?.trim() ? input.clientCode : "";
113
+ const key = `${input.renderingId}:${sourceHash(input.code)}:${clientCode ? sourceHash(clientCode) : "-"}:${input.themeHash}`;
114
+ const cached = lookup(key);
115
+ if (cached)
116
+ return Promise.resolve(cached);
117
+ const h = holder();
118
+ let flight = h.inflight.get(key);
119
+ if (!flight) {
120
+ const buildId = sha256Hex(key).slice(0, 32);
121
+ flight = getEngine(config)
122
+ .then((engine) => engine.compile({
123
+ renderingId: input.renderingId,
124
+ kind: input.kind,
125
+ name: input.name,
126
+ code: input.code,
127
+ clientCode,
128
+ themeCss: input.themeCss,
129
+ themeTokens: input.themeTokens ?? "",
130
+ }))
131
+ .then((result) => {
132
+ const compiled = toCompiled(buildId, input.renderingId, clientCode, result, config.limits.cssBytes);
133
+ if (!(compiled.ok === false && compiled.transient))
134
+ remember(key, compiled);
135
+ return compiled;
136
+ })
137
+ .catch((err) => ({
138
+ ok: false,
139
+ buildId,
140
+ transient: true,
141
+ diagnostics: [{ severity: "error", file: "build", message: `The engine is not available: ${err instanceof Error ? err.message : String(err)}` }],
142
+ }))
143
+ .finally(() => h.inflight.delete(key));
144
+ h.inflight.set(key, flight);
145
+ }
146
+ return flight;
147
+ }
148
+ /** What the render threads need of a compiled rendering. */
149
+ export function moduleOf(compiled) {
150
+ return compiled.client
151
+ ? { server: compiled.server, client: compiled.client.node, clientExports: compiled.client.exports }
152
+ : { server: compiled.server };
153
+ }
154
+ const bundleKey = (renderingId, hash) => `${renderingId}:${hash}`;
155
+ /** Keeps a browser bundle, by rendering and client hash, with the approval the CM reported for it just now. */
156
+ export function rememberClientBundle(renderingId, client, approved) {
157
+ const h = holder();
158
+ const key = bundleKey(renderingId, client.hash);
159
+ const previous = h.bundles.get(key);
160
+ if (previous) {
161
+ h.bundles.delete(key);
162
+ h.bundleBytes -= previous.code.length;
163
+ }
164
+ h.bundles.set(key, { code: client.browser, sourceHash: client.sourceHash, approved });
165
+ h.bundleBytes += client.browser.length;
166
+ while (h.bundleBytes > MAX_BUNDLE_BYTES && h.bundles.size > 1) {
167
+ const [oldest, entry] = h.bundles.entries().next().value;
168
+ h.bundles.delete(oldest);
169
+ h.bundleBytes -= entry.code.length;
170
+ }
171
+ }
172
+ export function lookupClientBundle(renderingId, hash) {
173
+ const h = holder();
174
+ const key = bundleKey(renderingId, hash);
175
+ const entry = h.bundles.get(key);
176
+ if (!entry)
177
+ return undefined;
178
+ h.bundles.delete(key);
179
+ h.bundles.set(key, entry);
180
+ return entry;
181
+ }
182
+ /** Renders the components of one tenant in that tenant's render thread. */
183
+ export async function renderTenant(config, tenant, components, modules, options = {}) {
184
+ if (components.length === 0)
185
+ return [];
186
+ try {
187
+ const engine = await getEngine(config);
188
+ return (await engine.renderPage({ tenant, components, sources: modules, deadlineMs: options.deadlineMs, fresh: options.fresh }));
189
+ }
190
+ catch (err) {
191
+ const message = err instanceof Error ? err.message : "The engine is not available.";
192
+ return components.map((c) => ({ key: c.key, ok: false, code: "render-error", message }));
193
+ }
194
+ }
195
+ /** For tests: forget cached compiles and bundles (the engine keeps running). */
196
+ export function clearCompileCache() {
197
+ const h = holder();
198
+ h.entries.clear();
199
+ h.bytes = 0;
200
+ h.bundles.clear();
201
+ h.bundleBytes = 0;
202
+ }
203
+ /** Cache figures, for diagnostics. */
204
+ export function compileCacheStats() {
205
+ const h = holder();
206
+ return { entries: h.entries.size, bytes: h.bytes, inflight: h.inflight.size, bundles: h.bundles.size };
207
+ }
@@ -0,0 +1,12 @@
1
+ import "server-only";
2
+ export { defineDynamicRenderings, noTheme, inlineTheme, isSandboxTenant, ThemeUnavailableError } from "./config.js";
3
+ export type { DynamicRenderingsConfig, DynamicRenderingsOptions, EngineSettings, ThemeSource, EditorMarkers, RequestAdapter, RequestRoute, RequestAuth, PageTransform, } from "./config.js";
4
+ export { fetchSources } from "./backend.js";
5
+ export type { BackendIdentity, SourcesQuery } from "./backend.js";
6
+ export { prepareDynamicPage, prepareTheme, emptyDynamicPage, normalizeId, toDto } from "./page.js";
7
+ export type { DynamicPageState, LayoutPage, LayoutComponent, PreparedTheme, ClientMode } from "./page.js";
8
+ export { sanitizeTree, sanitizeProps, DENIED_DATA_ATTRIBUTE, isDeniedClass, ALLOWED_TAGS } from "./sanitize-tree.js";
9
+ export type { SanitizeOptions } from "./sanitize-tree.js";
10
+ export { loadTheme, hashThemeCss, combineThemeHash } from "./theme.js";
11
+ export { getEngine, compileRendering, renderTenant, sourceHash, clientHash, clientSourceHash, moduleOf, rememberClientBundle, lookupClientBundle, clearCompileCache, compileCacheStats, } from "./engine.js";
12
+ export type { CompiledRendering, CompiledClient, ClientBundle, CompileInput, TenantComponent } from "./engine.js";
@@ -0,0 +1,8 @@
1
+ // Server-only: the configuration, the CM client, page preparation and the engine client.
2
+ import "server-only";
3
+ export { defineDynamicRenderings, noTheme, inlineTheme, isSandboxTenant, ThemeUnavailableError } from "./config.js";
4
+ export { fetchSources } from "./backend.js";
5
+ export { prepareDynamicPage, prepareTheme, emptyDynamicPage, normalizeId, toDto } from "./page.js";
6
+ export { sanitizeTree, sanitizeProps, DENIED_DATA_ATTRIBUTE, isDeniedClass, ALLOWED_TAGS } from "./sanitize-tree.js";
7
+ export { loadTheme, hashThemeCss, combineThemeHash } from "./theme.js";
8
+ export { getEngine, compileRendering, renderTenant, sourceHash, clientHash, clientSourceHash, moduleOf, rememberClientBundle, lookupClientBundle, clearCompileCache, compileCacheStats, } from "./engine.js";