From c43e31e5e98412eb057a144443b2624cc9d39f12 Mon Sep 17 00:00:00 2001 From: d2du Date: Tue, 14 Jul 2026 23:46:48 +0530 Subject: [PATCH 1/3] add first-class generated UI renderers --- .../first-class-generated-ui-renderers.md | 13 + README.md | 42 ++- docs/RELEASE.md | 2 +- examples/sveltekit/README.md | 4 +- examples/sveltekit/src/lib/agent/handlers.ts | 26 +- examples/sveltekit/src/routes/+page.svelte | 57 ++-- .../sveltekit/src/routes/agent/+page.svelte | 14 +- package.json | 13 + pnpm-lock.yaml | 10 + src/renderer.ts | 41 +++ src/renderers/dom-purify.ts | 76 +++++ src/renderers/dom-runtime.ts | 303 ++++++++++++++++++ src/renderers/dom.ts | 18 ++ src/ui-policy.ts | 144 +++++++++ src/ui-runtime.ts | 147 +-------- src/ui/dom.ts | 259 ++------------- src/ui/react/GeneratedUi.tsx | 18 +- src/ui/solid/GeneratedUi.tsx | 17 +- src/ui/svelte/GeneratedUi.svelte | 18 +- src/ui/vue/GeneratedUi.vue | 17 +- tests/renderer-conformance.ts | 162 ++++++++++ tests/renderers.test.ts | 91 ++++++ tests/ui-dom.test.ts | 14 + tests/ui-runtime.test.ts | 10 + .../docs/guides/framework-adapters.mdx | 20 +- .../src/content/docs/guides/generated-ui.mdx | 33 +- website/src/content/docs/reference/api.mdx | 34 ++ 27 files changed, 1137 insertions(+), 466 deletions(-) create mode 100644 .changeset/first-class-generated-ui-renderers.md create mode 100644 src/renderer.ts create mode 100644 src/renderers/dom-purify.ts create mode 100644 src/renderers/dom-runtime.ts create mode 100644 src/renderers/dom.ts create mode 100644 src/ui-policy.ts create mode 100644 tests/renderer-conformance.ts create mode 100644 tests/renderers.test.ts diff --git a/.changeset/first-class-generated-ui-renderers.md b/.changeset/first-class-generated-ui-renderers.md new file mode 100644 index 0000000..0ed494b --- /dev/null +++ b/.changeset/first-class-generated-ui-renderers.md @@ -0,0 +1,13 @@ +--- +"gruntend-sdk": minor +--- + +Add first-class generated UI renderers so applications explicitly choose how a compiled generated-UI frame is committed to its target. + +- Export renderer and session contracts from `gruntend-sdk/renderer`. +- Add a recommended DOMPurify renderer at `gruntend-sdk/renderer/dom-purify` with an exact markup policy and direct `DocumentFragment` commits. +- Add the compiled DOM renderer at `gruntend-sdk/renderer/dom` for controlled integrations. +- Route Svelte, React, Vue, and Solid adapters through the selected renderer and add shared render and action lifecycle callbacks. +- Add renderer conformance, sanitization, event delegation, and lifecycle coverage. + +Framework adapters now require a `renderer` prop. Create a renderer once for the mounted component lifetime and pass it alongside the `GeneratedUi` value. The compatibility `mountGeneratedUi()` helper remains available from `gruntend-sdk/ui/dom`. diff --git a/README.md b/README.md index 9991fdb..385ee92 100644 --- a/README.md +++ b/README.md @@ -287,9 +287,10 @@ See [docs/RELEASE.md](docs/RELEASE.md) for the full release checklist. ```ts import { createGeneratedUi, createHtmlTag } from "gruntend-sdk/ui"; -import { mountGeneratedUi } from "gruntend-sdk/ui/dom"; +import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify"; const html = createHtmlTag(); +const renderer = createDomPurifyGeneratedUiRenderer(); const result = await gruntend.runCodePlan(plan.code, { input: plan.input, @@ -298,29 +299,37 @@ const result = await gruntend.runCodePlan(plan.code, { }); const ui = createGeneratedUi(result.result).unwrap(); -const frame = ui.render().unwrap(); +const session = renderer.mount(rootElement, ui, { + onError: console.error, +}); +``` -// frame.html is safe compiled HTML. -// frame.handlers maps delegated handler ids to generated functions. +`GeneratedUiRenderer` is a first-class object strategy. A renderer is selected when a UI session mounts and remains fixed for that session; `session.update(nextUi)` changes only the generated UI value, and `session.destroy()` tears down the mounted UI. The model does not select renderers. -mountGeneratedUi(rootElement, ui); -``` +The DOMPurify renderer is the recommended browser renderer. It uses an exact DOMPurify dependency, a Gruntend-specific tag and attribute allowlist, disables arbitrary `data-*` attributes and unknown protocols, fails closed when DOMPurify is unavailable, and commits the sanitized `DocumentFragment` directly with `replaceChildren()`. + +Renderer sanitization and plan-code isolation are separate boundaries. DOMPurify hardens generated markup; it does not sandbox generated JavaScript or reduce the authority of registered tool handlers. Select an isolated executor independently when the plan code is not trusted. -Framework adapters are thin wrappers over the same DOM primitive. They do not own Gruntend state; pass the `GeneratedUi` returned by `createGeneratedUi()`. +For controlled integrations that need the existing compiled-HTML sink, `createDomGeneratedUiRenderer()` is available from `gruntend-sdk/renderer/dom`. The compatibility helper `mountGeneratedUi()` remains available from `gruntend-sdk/ui/dom`. + +Framework adapters are thin wrappers over the selected renderer. They do not own Gruntend state; pass both the renderer and the `GeneratedUi` returned by `createGeneratedUi()`. ### Svelte ```svelte console.error(error)} /> ``` @@ -329,13 +338,17 @@ Framework adapters are thin wrappers over the same DOM primitive. They do not ow ```tsx import type { GeneratedUi as GeneratedUiModel } from "gruntend-sdk/ui"; +import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify"; import { GeneratedUi } from "gruntend-sdk/ui/react"; +const renderer = createDomPurifyGeneratedUiRenderer(); + export function AgentResult({ ui }: { ui: GeneratedUiModel }) { return ( ); @@ -346,13 +359,17 @@ export function AgentResult({ ui }: { ui: GeneratedUiModel }) { ```tsx import type { GeneratedUi as GeneratedUiModel } from "gruntend-sdk/ui"; +import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify"; import { GeneratedUi } from "gruntend-sdk/ui/solid"; +const renderer = createDomPurifyGeneratedUiRenderer(); + export function AgentResult(props: { ui: GeneratedUiModel }) { return ( ); @@ -364,9 +381,11 @@ export function AgentResult(props: { ui: GeneratedUiModel }) { ```vue ``` -The Svelte, React, and Solid adapters accept `onError`, `onRender`, `onActionStart`, and `onActionEnd` callbacks. The Vue adapter emits `error`, `render`, `action-start`, and `action-end` events. The consuming app supplies its framework dependency; Gruntend keeps these adapters as optional subpath exports. +The Svelte, React, and Solid adapters require a `renderer` and accept `onError`, `onRender`, `onActionStart`, and `onActionEnd` callbacks. The Vue adapter requires `renderer` and emits `error`, `render`, `action-start`, and `action-end` events. Treat the renderer prop as immutable for the mounted component lifetime. The consuming app supplies its framework dependency; Gruntend keeps these adapters as optional subpath exports. Framework adapter exports are source-backed through explicit `types` and `import` export conditions for now, so each host app can compile its own framework format. Core runtime exports such as `gruntend-sdk/client`, `gruntend-sdk/code-plan`, `gruntend-sdk/tool`, and `gruntend-sdk/ui` are published from `dist`. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 9a49a46..4defb2b 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -49,7 +49,7 @@ cd "$tmp_dir" npm init -y npm install ./gruntend-sdk-0.1.0.tgz node --input-type=module -e 'import { createGruntendClient } from "gruntend-sdk/client"; import { createJailJsCodePlanExecutor } from "gruntend-sdk/executor/jailjs"; import { defineTools } from "gruntend-sdk/tool"; console.log(Boolean(createGruntendClient({ tools: defineTools({}), executor: createJailJsCodePlanExecutor() }).registry));' -node --input-type=module -e 'await Promise.all(["gruntend-sdk/client","gruntend-sdk/code-plan","gruntend-sdk/generate","gruntend-sdk/registry","gruntend-sdk/runtime","gruntend-sdk/tool","gruntend-sdk/ui","gruntend-sdk/ui/dom","gruntend-sdk/ui-runtime"].map((id) => import(id))); console.log("core subpath imports ok");' +node --input-type=module -e 'await Promise.all(["gruntend-sdk/client","gruntend-sdk/code-plan","gruntend-sdk/generate","gruntend-sdk/registry","gruntend-sdk/renderer","gruntend-sdk/renderer/dom","gruntend-sdk/renderer/dom-purify","gruntend-sdk/runtime","gruntend-sdk/tool","gruntend-sdk/ui","gruntend-sdk/ui/dom","gruntend-sdk/ui-runtime"].map((id) => import(id))); console.log("core subpath imports ok");' ``` Framework adapter exports (`gruntend-sdk/ui/react`, `gruntend-sdk/ui/solid`, `gruntend-sdk/ui/svelte`, and `gruntend-sdk/ui/vue`) are source-backed and should be verified by the consuming framework build. diff --git a/examples/sveltekit/README.md b/examples/sveltekit/README.md index 3ec3bca..9b982ba 100644 --- a/examples/sveltekit/README.md +++ b/examples/sveltekit/README.md @@ -9,7 +9,7 @@ This example has: - normal app routes for browsing that data - a Gruntend tool namespace over app-owned remote handlers - a chat-style agent route with a mocked code-plan generator -- tagged-template message islands rendered through `gruntend-sdk/ui` and `gruntend-sdk/ui/svelte` +- tagged-template message islands mounted through an explicitly selected strict DOMPurify renderer and `gruntend-sdk/ui/svelte` - a per-run browser executor selector: JailJS by default or lazily initialized QuickJS/WASM ## Run @@ -64,7 +64,7 @@ The agent route is mocked on purpose and returns deterministic Gruntend code pla On the overview route, choose **JailJS · controlled** or **QuickJS · isolated** before running a task. The selection applies to the complete plan and its generated UI closures. QuickJS is loaded only when first selected. -The chat transcript renders generated UI returned from code plans as native JavaScript plus the Gruntend `html` tagged template. Event handlers use function interpolation, for example `onclick=${handler}`. The UI compiler rewrites those handlers to inert delegated attributes such as `data-gr-click="h0"`, so the browser never receives real inline JavaScript. The selectable menu prompt demonstrates local generated component state plus app tool calls for duplicated items. +The chat transcript renders generated UI returned from code plans as native JavaScript plus the Gruntend `html` tagged template. Event handlers use function interpolation, for example `onclick=${handler}`. The UI compiler rewrites those handlers to inert delegated attributes such as `data-gr-click="h0"`, and the page selects `createDomPurifyGeneratedUiRenderer()` once for each mounted UI session. The renderer applies the Gruntend allowlist through DOMPurify and inserts the returned fragment directly, so the browser receives neither real inline JavaScript nor unsanitized generated markup. The selectable menu prompt demonstrates local generated component state plus app tool calls for duplicated items. ## Switching back to a real LLM later diff --git a/examples/sveltekit/src/lib/agent/handlers.ts b/examples/sveltekit/src/lib/agent/handlers.ts index dbdaa99..87cfa93 100644 --- a/examples/sveltekit/src/lib/agent/handlers.ts +++ b/examples/sveltekit/src/lib/agent/handlers.ts @@ -35,16 +35,13 @@ import type { appTools } from "./tools"; type MenusOutput = { readonly menus: Menu[] }; type MenuOutput = { readonly menu: Menu }; type MenuItemsOutput = { readonly items: MenuItem[] }; -type MenuItemOutput = { readonly item: MenuItem }; type CustomersOutput = { readonly customers: Customer[] }; type OrdersOutput = { readonly orders: Order[] }; -type OrderOutput = { readonly order: Order }; type PaymentsOutput = { readonly payments: Payment[] }; type ReservationsOutput = { readonly reservations: Reservation[] }; type TablesOutput = { readonly tables: RestaurantTable[] }; type ShiftsOutput = { readonly shifts: Shift[] }; type UsersOutput = { readonly users: User[] }; -type UserOutput = { readonly user: User }; type BrowserHandlerOptions = { readonly canMutate: () => boolean; @@ -67,7 +64,7 @@ export function createBrowserHandlers( "menus.create": async ({ input, ok, err }) => runMutation( options, - () => createMenuCommand(toPlainInput(input)) as Promise, + async () => await createMenuCommand(toPlainInput(input)), ok, err, ), @@ -83,8 +80,7 @@ export function createBrowserHandlers( "menu.item.create": async ({ input, ok, err }) => runMutation( options, - () => - createMenuItemCommand(toPlainInput(input)) as Promise, + async () => await createMenuItemCommand(toPlainInput(input)), ok, err, ), @@ -92,10 +88,7 @@ export function createBrowserHandlers( "menu.item.duplicate": async ({ input, ok, err }) => runMutation( options, - () => - duplicateMenuItemCommand( - toPlainInput(input), - ) as Promise, + async () => await duplicateMenuItemCommand(toPlainInput(input)), ok, err, ), @@ -103,8 +96,7 @@ export function createBrowserHandlers( "menu.item.update": async ({ input, ok, err }) => runMutation( options, - () => - updateMenuItemCommand(toPlainInput(input)) as Promise, + async () => await updateMenuItemCommand(toPlainInput(input)), ok, err, ), @@ -112,8 +104,7 @@ export function createBrowserHandlers( "menu.item.delete": async ({ input, ok, err }) => runMutation( options, - () => - deleteMenuItemCommand(toPlainInput(input)) as Promise, + async () => await deleteMenuItemCommand(toPlainInput(input)), ok, err, ), @@ -127,7 +118,7 @@ export function createBrowserHandlers( "orders.create": async ({ input, ok, err }) => runMutation( options, - () => createOrderCommand(toPlainInput(input)) as Promise, + async () => await createOrderCommand(toPlainInput(input)), ok, err, ), @@ -135,8 +126,7 @@ export function createBrowserHandlers( "orders.status.update": async ({ input, ok, err }) => runMutation( options, - () => - updateOrderStatusCommand(toPlainInput(input)) as Promise, + async () => await updateOrderStatusCommand(toPlainInput(input)), ok, err, ), @@ -167,7 +157,7 @@ export function createBrowserHandlers( "users.create": async ({ input, ok, err }) => runMutation( options, - () => createUserCommand(toPlainInput(input)) as Promise, + async () => await createUserCommand(toPlainInput(input)), ok, err, ), diff --git a/examples/sveltekit/src/routes/+page.svelte b/examples/sveltekit/src/routes/+page.svelte index ca22599..870bd49 100644 --- a/examples/sveltekit/src/routes/+page.svelte +++ b/examples/sveltekit/src/routes/+page.svelte @@ -31,6 +31,7 @@ getUsers, } from "$lib/remote/example.remote"; import type { GeneratedCodePlan } from "gruntend-sdk/generate"; + import { createDomPurifyGeneratedUiRenderer } from "gruntend-sdk/renderer/dom-purify"; import type { RuntimeEvent } from "gruntend-sdk/runtime"; import { createGeneratedUi, @@ -183,6 +184,7 @@ ]); const taggedHtml = createHtmlTag(); + const generatedUiRenderer = createDomPurifyGeneratedUiRenderer(); const menusResponse = $derived(getMenusWithItems().current); const ordersResponse = $derived(getOrders().current); const usersResponse = $derived(getUsers().current); @@ -197,7 +199,7 @@ let executorChoice = $state("jailjs"); let activeExecutorId = $state(""); let suggestionLoading = $state(false); - let state = $state("idle"); + let runState = $state("idle"); let resultUi = $state(); let resultTitle = $state(""); let errorMessage = $state(""); @@ -220,7 +222,7 @@ function loadingProgressLabel() { if (suggestionLoading) return "Generating a task suggestion"; - if (state === "planning") return "Generating the code plan"; + if (runState === "planning") return "Generating the code plan"; return runtimeActivity; } @@ -231,7 +233,11 @@ } async function generateTaskSuggestion() { - if (suggestionLoading || state === "planning" || state === "running") { + if ( + suggestionLoading || + runState === "planning" || + runState === "running" + ) { return; } @@ -243,7 +249,7 @@ draft: startingPrompt.trim(), }); if ("error" in suggestion) { - toast.error(suggestion.error); + toast.error(suggestion.error ?? "Task suggestion failed"); return; } if (prompt !== startingPrompt) { @@ -279,7 +285,7 @@ } async function runTask(task: string) { - if (state === "planning" || state === "running") return; + if (runState === "planning" || runState === "running") return; prompt = task; const selectedExecutorChoice = executorChoice; @@ -287,7 +293,7 @@ selectedExecutorChoice === "quickjs-browser" ? getQuickJsBrowserExecutor() : Promise.resolve(jailJsExecutor); - state = "planning"; + runState = "planning"; resultUi = undefined; resultTitle = "Understanding your request"; errorMessage = ""; @@ -320,7 +326,7 @@ 2, ); - state = "running"; + runState = "running"; runtimeActivity = selectedExecutorChoice === "quickjs-browser" ? "Initializing the QuickJS/WASM executor" @@ -366,9 +372,9 @@ } resultUi = ui.value; - state = "done"; + runState = "done"; } catch (caught) { - state = "error"; + runState = "error"; errorMessage = readErrorMessage(caught); debugDetails = [ debugDetails, @@ -606,15 +612,17 @@ >