diff --git a/.changeset/first-class-generated-ui-renderers.md b/.changeset/first-class-generated-ui-renderers.md new file mode 100644 index 0000000..06c97ce --- /dev/null +++ b/.changeset/first-class-generated-ui-renderers.md @@ -0,0 +1,12 @@ +--- +"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 the built-in DOMPurify renderer at `gruntend-sdk/renderer/dom-purify` with an exact markup policy and direct `DocumentFragment` commits. +- 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. DOMPurify is the only built-in browser renderer; applications can implement the renderer interface for other targets or commit strategies. diff --git a/README.md b/README.md index 9991fdb..3f26ad6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Gruntend lets an app expose a small capability surface to an LLM, receive a Java defineTools() app capability surface generateCodePlan LLM → JavaScript plan runCodePlan scoped execution through handlers -ui-runtime html tagged templates → delegated browser UI +generated UI compiler → renderer → delegated browser UI ``` ## Install @@ -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,39 @@ 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()`. +Applications with a different target or commit strategy can implement the `GeneratedUiRenderer` interface. Gruntend provides DOMPurify as its only built-in browser renderer. + +See [docs/GENERATED_UI.md](docs/GENERATED_UI.md) for the compiler, renderer, DOM session, and security-boundary architecture. + +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 +340,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 +361,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 +383,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/GENERATED_UI.md b/docs/GENERATED_UI.md new file mode 100644 index 0000000..466de6f --- /dev/null +++ b/docs/GENERATED_UI.md @@ -0,0 +1,49 @@ +# Generated UI architecture + +Generated UI is one domain with three small responsibilities: + +```text +src/ui/ + compiler.ts + policy.ts + renderer.ts + renderers/ + dom-session.ts + dom-purify.ts + react/ · solid/ · svelte/ · vue/ + index.ts +``` + +## Compiler + +`compiler.ts` captures `html` tagged templates as strings and values, classifies each interpolation, escapes text and attributes, converts event functions into delegated handler identifiers, and validates the resulting tags and attributes against `policy.ts`. + +The compiler returns a `GeneratedUiFrame` containing inert HTML and the handler closures referenced by that HTML. It does not access the browser DOM. + +## Renderer contract + +`renderer.ts` defines `GeneratedUiRenderer` and `GeneratedUiRenderSession`. Applications select a renderer when mounting a generated UI. The renderer remains fixed for that session while `session.update(nextUi)` changes the generated UI value. + +The interface is the extension point for non-DOM targets or application-specific commit strategies. Gruntend does not infer or allow generated code to select a renderer. + +## Browser renderer + +`renderers/dom-purify.ts` is the only built-in browser renderer. It sanitizes the compiled frame with the shared generated-UI policy, receives a `DocumentFragment`, and commits that fragment with `replaceChildren()`. + +`renderers/dom-session.ts` contains the browser lifecycle shared by that renderer: delegated events, restricted event payloads, handler execution, rerendering, stale-action protection, updates, and cleanup. + +There is no built-in direct-`innerHTML` renderer and no legacy mounting shortcut. Applications that deliberately need a different strategy implement `GeneratedUiRenderer` explicitly. + +## Boundaries + +These boundaries solve different problems: + +| Boundary | Responsibility | +| ------------------------- | ----------------------------------------------------------------- | +| TypeScript renderer types | Match renderers with valid host targets | +| UI compiler and policy | Reject unsafe template structure and encode handler closures | +| DOMPurify renderer | Sanitize the final browser markup before insertion | +| Code-plan executor | Evaluate generated JavaScript with the selected trust profile | +| Tool handlers | Enforce permissions, persistence rules, and application authority | + +Renderer typing is not a security boundary at runtime. DOMPurify does not sandbox generated JavaScript, and executor isolation does not authorize tool calls. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 9a49a46..889421d 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-purify","gruntend-sdk/runtime","gruntend-sdk/tool","gruntend-sdk/ui","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 @@ >