From af3259e13e54f76e39076e73e5018c94e028556f Mon Sep 17 00:00:00 2001 From: Error Date: Sun, 19 Jul 2026 20:23:10 +0800 Subject: [PATCH] feat: add managed browser host API --- README.md | 188 ++++++++++++-------- README.zh-CN.md | 186 +++++++++++++++----- demo/main.js | 114 ++++-------- demo/styles.css | 2 +- dist/browser.d.ts | 57 ++++++ dist/browser.d.ts.map | 1 + dist/browser.js | 303 ++++++++++++++++++++++++++++++++ dist/browser.js.map | 1 + dist/index.d.ts | 1 + dist/index.d.ts.map | 2 +- dist/index.js | 1 + dist/index.js.map | 2 +- dist/runtime.d.ts | 4 +- dist/runtime.d.ts.map | 2 +- dist/runtime.js | 6 +- dist/runtime.js.map | 2 +- dist/sandbox.d.ts | 4 + dist/sandbox.d.ts.map | 1 + dist/sandbox.js | 4 + dist/sandbox.js.map | 1 + package.json | 4 + src/browser.ts | 390 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/runtime.ts | 6 +- src/sandbox.ts | 3 + tests/browser.test.mjs | 213 ++++++++++++++++++++++ 26 files changed, 1294 insertions(+), 205 deletions(-) create mode 100644 dist/browser.d.ts create mode 100644 dist/browser.d.ts.map create mode 100644 dist/browser.js create mode 100644 dist/browser.js.map create mode 100644 dist/sandbox.d.ts create mode 100644 dist/sandbox.d.ts.map create mode 100644 dist/sandbox.js create mode 100644 dist/sandbox.js.map create mode 100644 src/browser.ts create mode 100644 src/sandbox.ts create mode 100644 tests/browser.test.mjs diff --git a/README.md b/README.md index 73200a6..13046fe 100644 --- a/README.md +++ b/README.md @@ -3,67 +3,110 @@ [![CI](https://github.com/Error-Zhang/VelarOS-HTML-Artifacts/actions/workflows/ci.yml/badge.svg)](https://github.com/Error-Zhang/VelarOS-HTML-Artifacts/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-111111.svg)](./LICENSE) -A dependency-light TypeScript library for turning incremental model output into live, sandboxed HTML artifacts. +Turn an incremental model text stream into a live, sandboxed HTML interface. -It contains two deliberately separate layers: +VelarOS HTML Artifacts is a dependency-free browser runtime with a small TypeScript API. It owns the protocol parser, iframe lifecycle, patch transport, bounded height negotiation, link validation, and cleanup so an application does not have to assemble those pieces itself. -- A streaming protocol parser that emits renderer-neutral artifact and patch events. -- A sandbox runtime that builds iframe documents, applies HTML/CSS/JavaScript patches, reports runtime failures, measures natural content size, and hands host actions back through `postMessage`. +[中文文档](./README.zh-CN.md) · [Live demo](https://error-zhang.github.io/VelarOS-HTML-Artifacts/) -The project was designed and built by [Error-Zhang](https://github.com/Error-Zhang) as part of VelarOS Desktop, then separated behind a public, host-agnostic boundary. The original commit history is preserved. +## Install -[中文文档](./README.zh-CN.md) +The first registry release is being prepared. Until `@velaros/html-artifacts` is available on npm, install the tagged GitHub release: -## Why this exists +```bash +npm install Error-Zhang/VelarOS-HTML-Artifacts#v0.1.0 +``` -Streaming HTML is not the same as assigning partial text to `innerHTML`. A model can stop in the middle of a tag, CSS rule, script, or UTF-8 payload. At the same time, the host must keep untrusted output isolated, avoid transcript layout oscillation, and preserve a useful error trail. +The repository versions `dist/`, so npm, pnpm, Bun, and Yarn can consume the Git dependency without running a build script. -VelarOS HTML Artifacts handles those seams without depending on React, Electron, an agent loop, or a particular model provider. +## Quick start -```mermaid -flowchart LR - M["Incremental model output"] --> P["Protocol parser"] - P --> E["Artifact and patch events"] - E --> H["Host adapter"] - H --> I["Sandboxed iframe runtime"] - I --> R["Resize, error, link, and prompt events"] - R --> H -``` +```ts +import { mountHtmlArtifact } from '@velaros/html-artifacts' -## Install +const container = document.querySelector('#preview') +if (!container) throw new Error('Missing #preview') -Until the first npm publication, pin the GitHub repository or a release commit: +const artifact = mountHtmlArtifact(container, { + maxHeight: 720, + onPrompt: (prompt) => sendToModel(prompt), + onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + onError: (error) => console.error(error.phase, error.message), +}) -```bash -npm install github:Error-Zhang/VelarOS-HTML-Artifacts +await artifact.consume(modelTextStream) + +// When the surrounding view is destroyed: +artifact.dispose() ``` -The compiled `dist/` directory is versioned so Git dependencies also work in package managers that do not run dependency `prepare` scripts. +`modelTextStream` may be an `AsyncIterable` from any model provider. The library does not depend on React, Electron, an agent loop, or a particular API client. -The npm package name is reserved in the manifest as `@velaros/html-artifacts`. +The mount call creates one `sandbox="allow-scripts"` iframe inside the target element. Generated code stays inside that iframe. Capabilities such as opening a link or sending a new prompt are explicit callbacks owned by the host. -## Protocol parser +## Manual streaming -```ts -import { - applyHtmlArtifactProtocolChunk, - createHtmlArtifactProtocolStreamState, - finalizeHtmlArtifactProtocol, -} from '@velaros/html-artifacts/protocol' +Use `consume()` for the common case. Use `write()` and `finish()` when your transport already has its own stream loop: -const state = createHtmlArtifactProtocolStreamState({ enabled: true }) +```ts +const artifact = mountHtmlArtifact(container, { maxHeight: 720 }) for await (const chunk of modelTextStream) { - const events = applyHtmlArtifactProtocolChunk(state, chunk) - for (const event of events) renderEvent(event) + artifact.write(chunk) } +artifact.finish() -for (const event of finalizeHtmlArtifactProtocol(state)) { - renderEvent(event) -} +// Reuse the same iframe for a later response. +artifact.reset() + +// Remove the iframe and every host listener. +artifact.dispose() +``` + +Chunks may end in the middle of a tag, CSS rule, script, or Base64 payload. Only safe protocol boundaries are emitted to the iframe runtime. + +## Host callbacks + +```ts +const artifact = mountHtmlArtifact(container, { + onMarkdown(text) { + appendToTranscript(text) + }, + onPrompt(prompt) { + sendToModel(prompt) + }, + onLink(url) { + openTrustedUrl(url) + }, + onMessage(payload) { + handleArtifactMessage(payload) + }, + onEvent(event) { + recordProtocolEvent(event) + }, + onError(error) { + reportArtifactError(error) + }, +}) ``` -The v1 wire format is intentionally small: +HTTP and HTTPS are the only link protocols allowed by default. Invalid or active URLs are reported through `onError` and never reach `onLink`. + +## Stable sizing + +```ts +const artifact = mountHtmlArtifact(container, { + initialHeight: 360, + minHeight: 1, + maxHeight: 720, +}) +``` + +The iframe runtime publishes a deduplicated absolute content height. The host applies that exact value without adding padding or feeding viewport growth back into the measurement. `maxHeight` is enforced in both the iframe runtime and the browser host; taller or viewport-coupled documents scroll inside the sandbox instead of growing the outer page forever. + +## Artifact protocol + +The v1 wire format is deliberately small: ```html @@ -74,47 +117,58 @@ The v1 wire format is intentionally small: ``` -The parser emits HTML only at closed-element boundaries, CSS only at complete rule boundaries, and scripts only after their patch closes. Use `encoding="base64"` when a payload itself contains protocol closing tags. +Use `encoding="base64"` when a patch payload itself contains protocol closing tags. + +## Advanced APIs -## Sandboxed runtime +Most applications should only use `mountHtmlArtifact()` from the package root. Two lower-level entry points are available for custom hosts: ```ts +// Renderer-neutral incremental parser. +import { + applyHtmlArtifactProtocolChunk, + createHtmlArtifactProtocolStreamState, + finalizeHtmlArtifactProtocol, +} from '@velaros/html-artifacts/protocol' + +// iframe document, sizing, and URL primitives. import { buildHtmlArtifactShellDocument, normalizeHtmlArtifactExternalUrl, resolveHtmlArtifactFrameFit, -} from '@velaros/html-artifacts/runtime' +} from '@velaros/html-artifacts/sandbox' +``` -const iframe = document.createElement('iframe') -iframe.setAttribute('sandbox', 'allow-scripts') -iframe.srcdoc = buildHtmlArtifactShellDocument({ - maxReportedHeight: 1200, -}) +`@velaros/html-artifacts/runtime` remains as a compatibility alias for `./sandbox`. -const safeUrl = normalizeHtmlArtifactExternalUrl(candidateUrl) -const fit = resolveHtmlArtifactFrameFit({ - fallbackHeight: 360, - maxViewportWidth: 720, - naturalHeight: 900, - naturalWidth: 1200, -}) -``` +## API + +### `mountHtmlArtifact(target, options?)` + +Mounts a managed artifact iframe and returns an `HtmlArtifactController`. -The host owns the iframe sandbox attribute and must validate both `event.source` and every message payload. The runtime's default link policy accepts only explicit HTTP and HTTPS URLs. +Important options: -Height negotiation is one-way: the runtime reports a deduplicated absolute target and the host applies that value without adding padding. Reports are capped at `maxReportedHeight` (default `1200`); taller or viewport-coupled content remains scrollable inside the sandbox instead of growing the surrounding page forever. +- `initialHeight`, `minHeight`, `maxHeight`: bounded iframe sizing. +- `sandbox`: iframe sandbox tokens; defaults to `allow-scripts`. +- `designCss`, `rootId`, `title`, `className`: host presentation hooks. +- `protocolLimits`: resource limits for untrusted or unexpectedly large streams. +- `onMarkdown`, `onPrompt`, `onLink`, `onMessage`, `onEvent`, `onError`: host callbacks. -## Design boundaries +Controller methods: -This repository owns reusable mechanics, not product policy. It intentionally excludes: +- `consume(stream)`: consume an iterable of text chunks and return the latest protocol snapshot. +- `write(chunk)`: parse and render one chunk. +- `finish()`: flush an interrupted final chunk. +- `getSnapshot(id?)`: read the latest parser snapshot. +- `reset()`: clear parser and iframe content while keeping the mount alive. +- `dispose()`: remove the iframe and all listeners. -- model system prompts and tool-selection instructions; -- chat state, persistence, retry policy, or agent orchestration; -- Electron IPC and Desktop-specific events; -- theme tokens, toolbars, fullscreen layers, or other product UI; -- Widget, memory, permission, or VelarOS Kernel implementation details. +## Design boundary -Hosts can provide their own bridge message names, CSS, root id, sandbox policy, and UI adapter. +This repository owns reusable streaming and sandbox mechanics. It intentionally does not include model prompts, chat state, agent orchestration, Electron IPC, product UI, permissions, Widget, memory, or VelarOS Kernel internals. + +The public repository is the source of truth. Generic fixes land here first; products consume an exact released version and keep only their product-specific adapter. ## Development @@ -124,11 +178,7 @@ npm run check npm run demo ``` -`npm run check` runs type checking, the Node test suite, a production demo build, and a package dry run. - -## Release model - -The public repository is the source of truth. VelarOS Desktop consumes an exact released commit or package version; Desktop-specific adapters remain private. Generic fixes land here first, then flow downstream through a dependency update. +`npm run check` runs TypeScript validation, Node tests, a production demo build, and a package dry run. ## Security diff --git a/README.zh-CN.md b/README.zh-CN.md index 50b36db..3acf437 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,85 +1,173 @@ # VelarOS HTML Artifacts -一个低依赖的 TypeScript 开源库,用于把模型的增量文本输出转换成实时、隔离的 HTML Artifact。 +把模型的增量文本流直接变成实时、隔离的 HTML 界面。 -它包含两个明确分离的层次: +VelarOS HTML Artifacts 是一个零运行时依赖的浏览器库。它统一负责协议解析、iframe 生命周期、补丁传输、受限高度协商、链接校验和资源清理,接入方不再需要自己拼装 `postMessage` 和流式渲染逻辑。 -- 流式协议解析器:把不完整的模型输出转换为与渲染框架无关的 Artifact/Patch 事件。 -- 沙箱运行时:构建 iframe 文档、应用 HTML/CSS/JavaScript 增量补丁、上报错误与自然尺寸,并通过 `postMessage` 把宿主动作交还给外层应用。 +[English](./README.md) · [在线演示](https://error-zhang.github.io/VelarOS-HTML-Artifacts/) -本项目由 [Error-Zhang](https://github.com/Error-Zhang) 在构建 VelarOS Desktop 的过程中设计和实现,随后从产品代码中解耦为宿主无关的公共库。仓库保留了原始提交历史。 +## 安装 -## 它解决什么问题 +首个 npm registry 版本正在准备中。在 `@velaros/html-artifacts` 正式发布前,请安装带版本标签的 GitHub Release: -流式 HTML 不能简单理解为“不断设置 `innerHTML`”。模型可能停在半个标签、半条 CSS 规则、半段脚本或 UTF-8/Base64 数据中;宿主还必须同时保证不可信代码隔离、聊天布局不振荡、错误可以定位。 +```bash +npm install Error-Zhang/VelarOS-HTML-Artifacts#v0.1.0 +``` -本库集中处理这些边界,但不依赖 React、Electron、Agent Loop 或特定模型服务。 +仓库会提交编译后的 `dist/`,npm、pnpm、Bun 和 Yarn 消费 Git 依赖时不需要额外执行构建脚本。 -## 安装 +## 快速开始 -首次发布 npm 之前,可以固定 GitHub 仓库或具体提交: +```ts +import { mountHtmlArtifact } from '@velaros/html-artifacts' -```bash -npm install github:Error-Zhang/VelarOS-HTML-Artifacts +const container = document.querySelector('#preview') +if (!container) throw new Error('Missing #preview') + +const artifact = mountHtmlArtifact(container, { + maxHeight: 720, + onPrompt: (prompt) => sendToModel(prompt), + onLink: (url) => window.open(url, '_blank', 'noopener,noreferrer'), + onError: (error) => console.error(error.phase, error.message), +}) + +await artifact.consume(modelTextStream) + +// 外层页面销毁时: +artifact.dispose() ``` -仓库会提交编译后的 `dist/`,因此即使包管理器不会执行 Git 依赖的 `prepare` 脚本,也能直接消费固定提交。 +`modelTextStream` 可以是任意模型服务返回的 `AsyncIterable`。本库不依赖 React、Electron、Agent Loop 或某一家模型 API。 -清单中预留的 npm 包名是 `@velaros/html-artifacts`。 +`mountHtmlArtifact()` 会在目标元素内创建一个 `sandbox="allow-scripts"` 的 iframe。生成代码只能在沙箱内运行;打开链接、继续提问等能力必须由宿主通过显式回调授权。 -## 使用方式 +## 手动接入已有流循环 -协议解析: +普通场景直接使用 `consume()`。如果应用已经有自己的流读取逻辑,可以改用 `write()` 和 `finish()`: ```ts -import { - applyHtmlArtifactProtocolChunk, - createHtmlArtifactProtocolStreamState, - finalizeHtmlArtifactProtocol, -} from '@velaros/html-artifacts/protocol' - -const state = createHtmlArtifactProtocolStreamState({ enabled: true }) +const artifact = mountHtmlArtifact(container, { maxHeight: 720 }) for await (const chunk of modelTextStream) { - for (const event of applyHtmlArtifactProtocolChunk(state, chunk)) { - renderEvent(event) - } + artifact.write(chunk) } +artifact.finish() -for (const event of finalizeHtmlArtifactProtocol(state)) { - renderEvent(event) -} +// 下一次回答复用同一个 iframe。 +artifact.reset() + +// 移除 iframe 和全部宿主监听器。 +artifact.dispose() ``` -沙箱文档: +Chunk 可以停在半个 HTML 标签、CSS 规则、脚本或 Base64 数据中;只有完整、安全的协议边界才会进入 iframe。 + +## 宿主回调 ```ts -import { buildHtmlArtifactShellDocument } from '@velaros/html-artifacts/runtime' +const artifact = mountHtmlArtifact(container, { + onMarkdown(text) { + appendToTranscript(text) + }, + onPrompt(prompt) { + sendToModel(prompt) + }, + onLink(url) { + openTrustedUrl(url) + }, + onMessage(payload) { + handleArtifactMessage(payload) + }, + onEvent(event) { + recordProtocolEvent(event) + }, + onError(error) { + reportArtifactError(error) + }, +}) +``` + +链接默认只允许 HTTP 和 HTTPS。无效或主动执行型 URL 会通过 `onError` 上报,不会进入 `onLink`。 -const iframe = document.createElement('iframe') -iframe.setAttribute('sandbox', 'allow-scripts') -iframe.srcdoc = buildHtmlArtifactShellDocument({ - maxReportedHeight: 1200, +## 稳定高度 + +```ts +const artifact = mountHtmlArtifact(container, { + initialHeight: 360, + minHeight: 1, + maxHeight: 720, }) ``` -高度协商是单向的:运行时只回传去重后的绝对目标高度,宿主原样应用,不再额外累加 padding。回传高度受 `maxReportedHeight` 限制(默认 `1200`);更高或与 viewport 耦合的内容会留在沙箱内滚动,不会无限撑高外层页面。 +iframe 运行时只上报去重后的绝对内容高度,宿主直接应用,不再累加 padding,也不会把 viewport 增长反馈回测量过程。`maxHeight` 会同时在 iframe 内部和浏览器宿主层生效;超高内容或 `100vh` 等 viewport 耦合页面会留在沙箱内滚动,不会无限撑高外层界面。 + +## Artifact 协议 + +v1 协议保持有意的小规模: + +```html + +
+

Hello

+ #app { padding: 24px; } + console.log('ready') +
+``` + +当 Patch 内容本身包含协议闭合标签时,可以使用 `encoding="base64"`。 -## 开源与私有边界 +## 高级 API -公共仓库只维护可复用机制,不承载 VelarOS 产品策略。以下内容继续由 Desktop 私有层负责: +绝大多数应用只需要从包根入口使用 `mountHtmlArtifact()`。需要自定义宿主时,再使用下面两个底层入口: -- 模型系统提示词与工具选择策略; -- 会话状态、持久化、中止恢复与 Agent 调度; -- Electron IPC、Desktop 事件和权限策略; -- 品牌主题、工具栏、全屏层等产品 UI; -- Widget、Memory、Policy 与 VelarOS Kernel 的内部实现。 +```ts +// 与渲染器无关的增量协议解析器。 +import { + applyHtmlArtifactProtocolChunk, + createHtmlArtifactProtocolStreamState, + finalizeHtmlArtifactProtocol, +} from '@velaros/html-artifacts/protocol' + +// iframe 文档、高度适配与 URL 安全原语。 +import { + buildHtmlArtifactShellDocument, + normalizeHtmlArtifactExternalUrl, + resolveHtmlArtifactFrameFit, +} from '@velaros/html-artifacts/sandbox' +``` -Widget 和 HTML Artifact 可以共同消费本库的通用 iframe runtime,但本库不知道 Widget 的存在,因此不存在反向依赖。 +`@velaros/html-artifacts/runtime` 会作为 `./sandbox` 的兼容别名保留。 -## 维护方式 +## API -公开仓库是唯一源码。通用修复先在这里合入并发布版本,VelarOS Desktop 再升级到准确版本;Desktop 适配层不复制本仓库源码。 +### `mountHtmlArtifact(target, options?)` + +在目标元素内挂载一个受管理的 Artifact iframe,返回 `HtmlArtifactController`。 + +主要选项: + +- `initialHeight`、`minHeight`、`maxHeight`:有界 iframe 高度。 +- `sandbox`:iframe sandbox token,默认只有 `allow-scripts`。 +- `designCss`、`rootId`、`title`、`className`:宿主展示接口。 +- `protocolLimits`:不可信或异常超长流的资源上限。 +- `onMarkdown`、`onPrompt`、`onLink`、`onMessage`、`onEvent`、`onError`:宿主回调。 + +Controller 方法: + +- `consume(stream)`:消费文本 Chunk 迭代器并返回最近的协议快照。 +- `write(chunk)`:解析并渲染一个 Chunk。 +- `finish()`:结束流并刷新不完整的尾部内容。 +- `getSnapshot(id?)`:读取最近的解析器快照。 +- `reset()`:清空协议状态和 iframe 内容,但保留挂载实例。 +- `dispose()`:移除 iframe 与全部监听器。 + +## 开源边界与维护方式 + +本仓库只维护可复用的流式协议与沙箱机制,不包含模型提示词、聊天状态、Agent 调度、Electron IPC、产品 UI、权限、Widget、Memory 或 VelarOS Kernel 内部实现。 + +公共仓库是唯一源码。通用修复先在这里合入;产品只消费准确发布版本,并在私有仓库保留自己的适配层。 + +## 开发 ```bash npm install @@ -87,6 +175,12 @@ npm run check npm run demo ``` +`npm run check` 会执行 TypeScript 检查、Node 测试、生产 Demo 构建和 npm 包 dry run。 + +## 安全 + +生成 HTML 属于不可信输入。修改沙箱、脚本、URL 或消息桥前,请先阅读 [SECURITY.md](./SECURITY.md)。 + ## 许可证 MIT © 2026 Error-Zhang diff --git a/demo/main.js b/demo/main.js index 19d810a..57d6687 100644 --- a/demo/main.js +++ b/demo/main.js @@ -1,19 +1,4 @@ -import { - applyHtmlArtifactProtocolChunk, - buildHtmlArtifactShellDocument, - createHtmlArtifactProtocolStreamState, - HTML_ARTIFACT_WHEEL_MESSAGE_TYPE, -} from '../dist/index.js' - -const bridgeMessages = { - render: 'demo-artifact-render', - patch: 'demo-artifact-patch', - resize: 'demo-artifact-resize', - sendPrompt: 'demo-artifact-prompt', - openLink: 'demo-artifact-link', - generic: 'demo-artifact-message', - error: 'demo-artifact-error', -} +import { mountHtmlArtifact } from '../dist/index.js' const defaultSample = `
@@ -38,64 +23,32 @@ const frameHost = document.querySelector('#frame-host') const log = document.querySelector('#protocol-log') const runButton = document.querySelector('#run') const status = document.querySelector('#status') -let iframe = null let generation = 0 function wait(milliseconds) { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)) } -function post(payload) { - iframe?.contentWindow?.postMessage(payload, '*') -} - -function makeFrame() { - frameHost.replaceChildren() - iframe = document.createElement('iframe') - iframe.title = 'Streaming HTML artifact preview' - iframe.setAttribute('sandbox', 'allow-scripts') - iframe.srcdoc = buildHtmlArtifactShellDocument({ - bridgeMessages, - designCss: 'html{background:transparent}body{padding:4px}', - maxReportedHeight: 720, - rootId: 'demo-artifact-root', - }) - frameHost.append(iframe) - return new Promise((resolve) => iframe.addEventListener('load', resolve, { once: true })) -} - -window.addEventListener('message', (event) => { - if (!iframe || event.source !== iframe.contentWindow || !event.data) return - - if (event.data.type === bridgeMessages.resize && event.data.rendered) { - const reportedHeight = Number(event.data.naturalHeight ?? event.data.height) - if (Number.isFinite(reportedHeight) && reportedHeight > 0) { - // The runtime already includes document padding in its measured height. - // Re-adding it here creates a resize feedback loop between host and iframe. - const nextHeight = Math.max(240, Math.ceil(reportedHeight)) - if (Math.round(iframe.getBoundingClientRect().height) !== nextHeight) { - iframe.style.height = `${nextHeight}px` - } +const artifact = mountHtmlArtifact(frameHost, { + title: 'Streaming HTML artifact preview', + initialHeight: 240, + maxHeight: 720, + rootId: 'demo-artifact-root', + designCss: 'html{background:transparent}body{padding:4px}', + onEvent(event) { + if (event.type === 'artifact-close') { + status.textContent = 'Complete — interaction stays inside the sandbox' } - } else if (event.data.type === bridgeMessages.error) { - status.textContent = `Runtime report: ${event.data.message}` - } else if (event.data.type === HTML_ARTIFACT_WHEEL_MESSAGE_TYPE) { - window.scrollBy({ top: Number(event.data.deltaY) || 0 }) - } + }, + onError(error) { + status.textContent = `${error.phase} report: ${error.message}` + }, + onLink(url) { + window.open(url, '_blank', 'noopener,noreferrer') + }, }) -async function replay() { - generation += 1 - const activeGeneration = generation - runButton.disabled = true - status.textContent = 'Preparing sandbox…' - log.textContent = '' - await makeFrame() - if (activeGeneration !== generation) return - - const state = createHtmlArtifactProtocolStreamState({ enabled: true }) - status.textContent = 'Streaming model output…' - +async function* streamSample(activeGeneration) { for (let offset = 0; offset < sample.length; ) { if (activeGeneration !== generation) return const width = 4 + ((offset * 7) % 17) @@ -103,20 +56,29 @@ async function replay() { offset += width log.textContent += chunk log.scrollTop = log.scrollHeight - - for (const event of applyHtmlArtifactProtocolChunk(state, chunk)) { - if (event.type === 'artifact-update') { - post({ type: bridgeMessages.render, html: event.html, patches: [] }) - } else if (event.type === 'artifact-patch') { - post({ type: bridgeMessages.patch, patches: [event.patch] }) - } else if (event.type === 'artifact-close') { - status.textContent = 'Complete — interaction stays inside the sandbox' - } - } + yield chunk await wait(16) } +} + +async function replay() { + generation += 1 + const activeGeneration = generation + runButton.disabled = true + status.textContent = 'Preparing sandbox…' + log.textContent = '' - runButton.disabled = false + try { + await artifact.ready + if (activeGeneration !== generation) return + artifact.reset() + status.textContent = 'Streaming model output…' + await artifact.consume(streamSample(activeGeneration)) + } catch (error) { + status.textContent = error instanceof Error ? error.message : 'Unable to replay stream' + } finally { + if (activeGeneration === generation) runButton.disabled = false + } } runButton.addEventListener('click', replay) diff --git a/demo/styles.css b/demo/styles.css index 5349ccb..8ae3903 100644 --- a/demo/styles.css +++ b/demo/styles.css @@ -43,7 +43,7 @@ main { max-width: 1180px; margin: 0 auto; padding: 78px 28px 100px; } .preview-pane { border-right: 1px solid #dfe2d9; background: radial-gradient(circle at 50% 20%, rgba(209,225,207,.5), transparent 55%); } .pane-label { margin: 0 0 18px; } #frame-host { max-width: 560px; margin: 54px auto 0; } -#frame-host iframe { display: block; width: 100%; min-height: 240px; border: 0; background: transparent; } +#frame-host iframe { background: transparent; } #protocol-log { height: 480px; margin: 0; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; color: #697067; font: 12px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; scrollbar-width: thin; } .facts { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin-top: 64px; border: 1px solid #d8dbd2; background: #d8dbd2; } diff --git a/dist/browser.d.ts b/dist/browser.d.ts new file mode 100644 index 0000000..8581ac3 --- /dev/null +++ b/dist/browser.d.ts @@ -0,0 +1,57 @@ +import { type HtmlArtifactProtocolEvent, type HtmlArtifactProtocolLimits, type HtmlArtifactSnapshot } from './protocol.js'; +export type HtmlArtifactHostErrorPhase = 'host' | 'protocol' | 'runtime' | 'security'; +export interface HtmlArtifactHostError { + message: string; + phase: HtmlArtifactHostErrorPhase; + cause?: unknown; + patchId?: string; + patchType?: string; +} +export interface MountHtmlArtifactOptions { + /** Accessible title applied to the generated iframe. */ + title?: string; + /** Optional class name applied to the generated iframe. */ + className?: string; + /** iframe sandbox tokens. Defaults to the deliberately small `allow-scripts`. */ + sandbox?: string; + /** Height used before the runtime publishes its first measurement. */ + initialHeight?: number; + /** Smallest height the host will apply after a runtime measurement. */ + minHeight?: number; + /** Hard height cap. Taller content scrolls inside the iframe. */ + maxHeight?: number; + /** Product-neutral CSS injected before generated artifact styles. */ + designCss?: string; + /** Root element id inside the iframe shell. */ + rootId?: string; + /** Protocol resource limits for hostile or unexpectedly large streams. */ + protocolLimits?: HtmlArtifactProtocolLimits; + /** URL protocols handed to `onLink`. Defaults to HTTP and HTTPS. */ + allowedLinkProtocols?: readonly string[]; + onMarkdown?: (text: string) => void; + onPrompt?: (prompt: string) => void; + onLink?: (url: string) => void; + onMessage?: (payload: unknown) => void; + onWheel?: (deltaX: number, deltaY: number) => void; + onEvent?: (event: HtmlArtifactProtocolEvent) => void; + onError?: (error: HtmlArtifactHostError) => void; +} +export interface HtmlArtifactController { + readonly iframe: HTMLIFrameElement; + /** Resolves after the sandbox shell has loaded and queued render events have been delivered. */ + readonly ready: Promise; + write(chunk: string): HtmlArtifactProtocolEvent[]; + finish(): HtmlArtifactProtocolEvent[]; + consume(chunks: AsyncIterable | Iterable): Promise; + getSnapshot(artifactId?: string): HtmlArtifactSnapshot | null; + reset(): void; + dispose(): void; +} +/** + * Mount one streaming HTML artifact surface into a DOM element. + * + * The controller owns protocol parsing, iframe creation, sandbox transport, height negotiation, + * action validation, and cleanup. Callers only feed model text and handle explicit capabilities. + */ +export declare function mountHtmlArtifact(target: HTMLElement, options?: MountHtmlArtifactOptions): HtmlArtifactController; +//# sourceMappingURL=browser.d.ts.map \ No newline at end of file diff --git a/dist/browser.d.ts.map b/dist/browser.d.ts.map new file mode 100644 index 0000000..9f97a7a --- /dev/null +++ b/dist/browser.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,yBAAyB,EAC9B,KAAK,0BAA0B,EAE/B,KAAK,oBAAoB,EAC1B,MAAM,eAAe,CAAA;AAStB,MAAM,MAAM,0BAA0B,GAAG,MAAM,GAAG,UAAU,GAAG,SAAS,GAAG,UAAU,CAAA;AAErF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAA;IACf,KAAK,EAAE,0BAA0B,CAAA;IACjC,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,wBAAwB;IACvC,wDAAwD;IACxD,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,iFAAiF;IACjF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,sEAAsE;IACtE,aAAa,CAAC,EAAE,MAAM,CAAA;IACtB,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,iEAAiE;IACjE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,qEAAqE;IACrE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,0EAA0E;IAC1E,cAAc,CAAC,EAAE,0BAA0B,CAAA;IAC3C,oEAAoE;IACpE,oBAAoB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACxC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IACnC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IACnC,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAA;IACtC,OAAO,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAA;IAClD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,yBAAyB,KAAK,IAAI,CAAA;IACpD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAA;CACjD;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,MAAM,EAAE,iBAAiB,CAAA;IAClC,gGAAgG;IAChG,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC,iBAAiB,CAAC,CAAA;IAC1C,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,yBAAyB,EAAE,CAAA;IACjD,MAAM,IAAI,yBAAyB,EAAE,CAAA;IACrC,OAAO,CAAC,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,GAAG,QAAQ,CAAC,MAAM,CAAC,GAAG,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC,CAAA;IAC/F,WAAW,CAAC,UAAU,CAAC,EAAE,MAAM,GAAG,oBAAoB,GAAG,IAAI,CAAA;IAC7D,KAAK,IAAI,IAAI,CAAA;IACb,OAAO,IAAI,IAAI,CAAA;CAChB;AA6CD;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,WAAW,EACnB,OAAO,GAAE,wBAA6B,GACrC,sBAAsB,CA2QxB"} \ No newline at end of file diff --git a/dist/browser.js b/dist/browser.js new file mode 100644 index 0000000..59052e8 --- /dev/null +++ b/dist/browser.js @@ -0,0 +1,303 @@ +import { applyHtmlArtifactProtocolChunk, createHtmlArtifactProtocolStreamState, DEFAULT_HTML_ARTIFACT_HEIGHT, finalizeHtmlArtifactProtocol, } from './protocol.js'; +import { normalizeHtmlArtifactExternalUrl } from './security.js'; +import { buildHtmlArtifactShellDocument, DEFAULT_HTML_ARTIFACT_MAX_REPORTED_HEIGHT, HTML_ARTIFACT_WHEEL_MESSAGE_TYPE, } from './shell.js'; +let nextBridgeId = 0; +function createBridgeId() { + const cryptoApi = globalThis.crypto; + if (cryptoApi && typeof cryptoApi.randomUUID === 'function') { + return cryptoApi.randomUUID(); + } + nextBridgeId += 1; + return `${Date.now().toString(36)}-${nextBridgeId.toString(36)}`; +} +function createBridgeMessages() { + const prefix = `velaros:html-artifact:${createBridgeId()}`; + return { + render: `${prefix}:render`, + patch: `${prefix}:patch`, + resize: `${prefix}:resize`, + sendPrompt: `${prefix}:prompt`, + openLink: `${prefix}:link`, + generic: `${prefix}:message`, + error: `${prefix}:error`, + }; +} +function normalizeDimension(value, fallback) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.max(1, Math.ceil(value)) + : fallback; +} +function readMessagePayload(value) { + if (!value || typeof value !== 'object') + return null; + const type = value.type; + return typeof type === 'string' ? value : null; +} +function readString(value) { + return typeof value === 'string' ? value : ''; +} +/** + * Mount one streaming HTML artifact surface into a DOM element. + * + * The controller owns protocol parsing, iframe creation, sandbox transport, height negotiation, + * action validation, and cleanup. Callers only feed model text and handle explicit capabilities. + */ +export function mountHtmlArtifact(target, options = {}) { + if (!target || typeof target.replaceChildren !== 'function') { + throw new TypeError('mountHtmlArtifact target must be an HTMLElement'); + } + const minHeight = normalizeDimension(options.minHeight, 1); + const maxHeight = Math.max(minHeight, normalizeDimension(options.maxHeight, DEFAULT_HTML_ARTIFACT_MAX_REPORTED_HEIGHT)); + const initialHeight = Math.min(maxHeight, Math.max(minHeight, normalizeDimension(options.initialHeight, DEFAULT_HTML_ARTIFACT_HEIGHT))); + let bridgeMessages = createBridgeMessages(); + let inboundBridgeTypes = createInboundBridgeTypes(bridgeMessages); + const iframe = document.createElement('iframe'); + let shellDocument = createShellDocument(bridgeMessages); + let state = createHtmlArtifactProtocolStreamState({ + enabled: true, + limits: options.protocolLimits, + }); + let latestArtifactId = null; + let disposed = false; + let consuming = false; + let frameReady = false; + let settleReady = () => undefined; + const pendingMessages = []; + const ready = new Promise((resolve) => { + settleReady = resolve; + }); + function createInboundBridgeTypes(messages) { + return new Set([ + messages.resize, + messages.sendPrompt, + messages.openLink, + messages.generic, + messages.error, + ]); + } + function createShellDocument(messages) { + return buildHtmlArtifactShellDocument({ + bridgeMessages: messages, + designCss: options.designCss, + maxReportedHeight: maxHeight, + rootId: options.rootId, + }); + } + function reportError(error) { + try { + options.onError?.(error); + } + catch { + // Error callbacks are an application boundary and must not destabilize the stream runtime. + } + } + function invoke(callback, callbackName, ...args) { + if (!callback) + return; + try { + callback(...args); + } + catch (cause) { + reportError({ + phase: 'host', + message: `${callbackName} callback failed`, + cause, + }); + } + } + function assertActive() { + if (disposed) + throw new Error('HTML artifact controller has been disposed'); + } + function post(payload) { + if (!frameReady) { + pendingMessages.push(payload); + return; + } + const frameWindow = iframe.contentWindow; + if (!frameWindow) { + reportError({ phase: 'host', message: 'HTML artifact iframe is not available' }); + return; + } + frameWindow.postMessage(payload, '*'); + } + function dispatch(events) { + for (const event of events) { + if (event.type === 'markdown') { + invoke(options.onMarkdown, 'onMarkdown', event.text); + } + else if (event.type === 'artifact-open') { + latestArtifactId = event.artifact.id; + } + else if (event.type === 'artifact-update') { + latestArtifactId = event.artifact.id; + post({ type: bridgeMessages.render, html: event.html, patches: [] }); + } + else if (event.type === 'artifact-patch') { + latestArtifactId = event.artifact.id; + post({ type: bridgeMessages.patch, patches: [event.patch] }); + } + else if (event.type === 'artifact-diagnostic') { + reportError({ + phase: 'protocol', + message: event.diagnostic.message, + patchId: event.diagnostic.patchId, + patchType: event.diagnostic.patchType, + }); + } + else if (event.type === 'artifact-close') { + latestArtifactId = event.artifact.id; + } + invoke(options.onEvent, 'onEvent', event); + } + return events; + } + function getSnapshot(artifactId = latestArtifactId ?? '') { + const snapshot = state.artifactsById[artifactId]; + return snapshot ? { ...snapshot } : null; + } + function applyReportedHeight(payload) { + const candidate = Number(payload.naturalHeight ?? payload.height); + if (!Number.isFinite(candidate) || candidate <= 0) + return; + const height = Math.min(maxHeight, Math.max(minHeight, Math.ceil(candidate))); + if (Math.round(iframe.getBoundingClientRect().height) !== height) { + iframe.style.height = `${height}px`; + } + } + function handleMessage(event) { + if (disposed) + return; + const payload = readMessagePayload(event.data); + if (!payload) + return; + const sourceMatches = event.source === iframe.contentWindow; + const isPrivateOpaqueMessage = event.origin === 'null' && inboundBridgeTypes.has(payload.type); + if (!sourceMatches && !isPrivateOpaqueMessage) + return; + if (payload.type === bridgeMessages.resize) { + applyReportedHeight(payload); + } + else if (payload.type === bridgeMessages.sendPrompt) { + invoke(options.onPrompt, 'onPrompt', readString(payload.prompt)); + } + else if (payload.type === bridgeMessages.openLink) { + const url = normalizeHtmlArtifactExternalUrl(payload.url, { + allowedProtocols: options.allowedLinkProtocols, + }); + if (url) { + invoke(options.onLink, 'onLink', url); + } + else { + reportError({ phase: 'security', message: 'Blocked an invalid artifact URL' }); + } + } + else if (payload.type === bridgeMessages.generic) { + invoke(options.onMessage, 'onMessage', payload.payload); + } + else if (payload.type === bridgeMessages.error) { + reportError({ + phase: 'runtime', + message: readString(payload.message) || 'Artifact runtime error', + patchId: readString(payload.patchId) || undefined, + patchType: readString(payload.patchType) || undefined, + }); + } + else if (payload.type === HTML_ARTIFACT_WHEEL_MESSAGE_TYPE && sourceMatches) { + const deltaX = Number(payload.deltaX) || 0; + const deltaY = Number(payload.deltaY) || 0; + if (options.onWheel) { + invoke(options.onWheel, 'onWheel', deltaX, deltaY); + } + else { + window.scrollBy({ left: deltaX, top: deltaY }); + } + } + } + function handleLoad() { + if (disposed) + return; + frameReady = true; + const frameWindow = iframe.contentWindow; + if (frameWindow) { + for (const payload of pendingMessages.splice(0)) { + frameWindow.postMessage(payload, '*'); + } + } + settleReady(iframe); + } + iframe.title = options.title ?? 'HTML artifact preview'; + if (options.className) + iframe.className = options.className; + iframe.setAttribute('sandbox', options.sandbox ?? 'allow-scripts'); + iframe.referrerPolicy = 'no-referrer'; + iframe.style.display = 'block'; + iframe.style.width = '100%'; + iframe.style.height = `${initialHeight}px`; + iframe.style.border = '0'; + iframe.addEventListener('load', handleLoad, { once: true }); + iframe.srcdoc = shellDocument; + window.addEventListener('message', handleMessage); + target.replaceChildren(iframe); + function write(chunk) { + assertActive(); + return dispatch(applyHtmlArtifactProtocolChunk(state, chunk)); + } + function finish() { + assertActive(); + return dispatch(finalizeHtmlArtifactProtocol(state)); + } + return { + iframe, + ready, + write, + finish, + async consume(chunks) { + assertActive(); + if (consuming) + throw new Error('HTML artifact controller is already consuming a stream'); + consuming = true; + try { + for await (const chunk of chunks) { + write(chunk); + } + finish(); + return getSnapshot(); + } + finally { + consuming = false; + } + }, + getSnapshot, + reset() { + assertActive(); + state = createHtmlArtifactProtocolStreamState({ + enabled: true, + limits: options.protocolLimits, + }); + latestArtifactId = null; + pendingMessages.length = 0; + frameReady = false; + bridgeMessages = createBridgeMessages(); + inboundBridgeTypes = createInboundBridgeTypes(bridgeMessages); + shellDocument = createShellDocument(bridgeMessages); + iframe.style.height = `${initialHeight}px`; + iframe.addEventListener('load', handleLoad, { once: true }); + // Reloading the same shell keeps the iframe element stable while removing prior document + // scripts, styles, timers, observers, and event listeners before the next artifact stream. + iframe.srcdoc = shellDocument; + }, + dispose() { + if (disposed) + return; + disposed = true; + consuming = false; + pendingMessages.length = 0; + iframe.removeEventListener('load', handleLoad); + window.removeEventListener('message', handleMessage); + iframe.remove(); + settleReady(iframe); + }, + }; +} +//# sourceMappingURL=browser.js.map \ No newline at end of file diff --git a/dist/browser.js.map b/dist/browser.js.map new file mode 100644 index 0000000..0200cb0 --- /dev/null +++ b/dist/browser.js.map @@ -0,0 +1 @@ +{"version":3,"file":"browser.js","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index 7155828..405d85f 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -1,3 +1,4 @@ +export * from './browser.js'; export * from './frame-fit.js'; export * from './protocol.js'; export * from './security.js'; diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 70b11b3..3d29e99 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file +{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index 51ed48e..ca59aca 100644 --- a/dist/index.js +++ b/dist/index.js @@ -1,3 +1,4 @@ +export * from './browser.js'; export * from './frame-fit.js'; export * from './protocol.js'; export * from './security.js'; diff --git a/dist/index.js.map b/dist/index.js.map index fdbe901..b8dac80 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file diff --git a/dist/runtime.d.ts b/dist/runtime.d.ts index 58da771..9588416 100644 --- a/dist/runtime.d.ts +++ b/dist/runtime.d.ts @@ -1,4 +1,2 @@ -export * from './frame-fit.js'; -export * from './security.js'; -export * from './shell.js'; +export * from './sandbox.js'; //# sourceMappingURL=runtime.d.ts.map \ No newline at end of file diff --git a/dist/runtime.d.ts.map b/dist/runtime.d.ts.map index 8bd69c7..ef73067 100644 --- a/dist/runtime.d.ts.map +++ b/dist/runtime.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file +{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAEA,cAAc,cAAc,CAAA"} \ No newline at end of file diff --git a/dist/runtime.js b/dist/runtime.js index b6093a4..4cde9e6 100644 --- a/dist/runtime.js +++ b/dist/runtime.js @@ -1,4 +1,4 @@ -export * from './frame-fit.js'; -export * from './security.js'; -export * from './shell.js'; +// Backward-compatible alias. New integrations should import low-level iframe primitives from +// `@velaros/html-artifacts/sandbox`. +export * from './sandbox.js'; //# sourceMappingURL=runtime.js.map \ No newline at end of file diff --git a/dist/runtime.js.map b/dist/runtime.js.map index 2bec3cf..cbc1821 100644 --- a/dist/runtime.js.map +++ b/dist/runtime.js.map @@ -1 +1 @@ -{"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file +{"version":3,"file":"runtime.js","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA,6FAA6F;AAC7F,qCAAqC;AACrC,cAAc,cAAc,CAAA"} \ No newline at end of file diff --git a/dist/sandbox.d.ts b/dist/sandbox.d.ts new file mode 100644 index 0000000..0853a6f --- /dev/null +++ b/dist/sandbox.d.ts @@ -0,0 +1,4 @@ +export * from './frame-fit.js'; +export * from './security.js'; +export * from './shell.js'; +//# sourceMappingURL=sandbox.d.ts.map \ No newline at end of file diff --git a/dist/sandbox.d.ts.map b/dist/sandbox.d.ts.map new file mode 100644 index 0000000..5abf585 --- /dev/null +++ b/dist/sandbox.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file diff --git a/dist/sandbox.js b/dist/sandbox.js new file mode 100644 index 0000000..55e77f3 --- /dev/null +++ b/dist/sandbox.js @@ -0,0 +1,4 @@ +export * from './frame-fit.js'; +export * from './security.js'; +export * from './shell.js'; +//# sourceMappingURL=sandbox.js.map \ No newline at end of file diff --git a/dist/sandbox.js.map b/dist/sandbox.js.map new file mode 100644 index 0000000..4afd9d9 --- /dev/null +++ b/dist/sandbox.js.map @@ -0,0 +1 @@ +{"version":3,"file":"sandbox.js","sourceRoot":"","sources":["../src/sandbox.ts"],"names":[],"mappings":"AAAA,cAAc,gBAAgB,CAAA;AAC9B,cAAc,eAAe,CAAA;AAC7B,cAAc,YAAY,CAAA"} \ No newline at end of file diff --git a/package.json b/package.json index b1c1e99..b2f4442 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,10 @@ "types": "./dist/protocol.d.ts", "import": "./dist/protocol.js" }, + "./sandbox": { + "types": "./dist/sandbox.d.ts", + "import": "./dist/sandbox.js" + }, "./runtime": { "types": "./dist/runtime.d.ts", "import": "./dist/runtime.js" diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..dd28b99 --- /dev/null +++ b/src/browser.ts @@ -0,0 +1,390 @@ +import { + applyHtmlArtifactProtocolChunk, + createHtmlArtifactProtocolStreamState, + DEFAULT_HTML_ARTIFACT_HEIGHT, + finalizeHtmlArtifactProtocol, + type HtmlArtifactProtocolEvent, + type HtmlArtifactProtocolLimits, + type HtmlArtifactProtocolStreamState, + type HtmlArtifactSnapshot, +} from './protocol.js' +import { normalizeHtmlArtifactExternalUrl } from './security.js' +import { + buildHtmlArtifactShellDocument, + DEFAULT_HTML_ARTIFACT_MAX_REPORTED_HEIGHT, + HTML_ARTIFACT_WHEEL_MESSAGE_TYPE, + type HtmlArtifactBridgeMessages, +} from './shell.js' + +export type HtmlArtifactHostErrorPhase = 'host' | 'protocol' | 'runtime' | 'security' + +export interface HtmlArtifactHostError { + message: string + phase: HtmlArtifactHostErrorPhase + cause?: unknown + patchId?: string + patchType?: string +} + +export interface MountHtmlArtifactOptions { + /** Accessible title applied to the generated iframe. */ + title?: string + /** Optional class name applied to the generated iframe. */ + className?: string + /** iframe sandbox tokens. Defaults to the deliberately small `allow-scripts`. */ + sandbox?: string + /** Height used before the runtime publishes its first measurement. */ + initialHeight?: number + /** Smallest height the host will apply after a runtime measurement. */ + minHeight?: number + /** Hard height cap. Taller content scrolls inside the iframe. */ + maxHeight?: number + /** Product-neutral CSS injected before generated artifact styles. */ + designCss?: string + /** Root element id inside the iframe shell. */ + rootId?: string + /** Protocol resource limits for hostile or unexpectedly large streams. */ + protocolLimits?: HtmlArtifactProtocolLimits + /** URL protocols handed to `onLink`. Defaults to HTTP and HTTPS. */ + allowedLinkProtocols?: readonly string[] + onMarkdown?: (text: string) => void + onPrompt?: (prompt: string) => void + onLink?: (url: string) => void + onMessage?: (payload: unknown) => void + onWheel?: (deltaX: number, deltaY: number) => void + onEvent?: (event: HtmlArtifactProtocolEvent) => void + onError?: (error: HtmlArtifactHostError) => void +} + +export interface HtmlArtifactController { + readonly iframe: HTMLIFrameElement + /** Resolves after the sandbox shell has loaded and queued render events have been delivered. */ + readonly ready: Promise + write(chunk: string): HtmlArtifactProtocolEvent[] + finish(): HtmlArtifactProtocolEvent[] + consume(chunks: AsyncIterable | Iterable): Promise + getSnapshot(artifactId?: string): HtmlArtifactSnapshot | null + reset(): void + dispose(): void +} + +type MessagePayload = Record & { type: string } + +let nextBridgeId = 0 + +function createBridgeId(): string { + const cryptoApi = globalThis.crypto + if (cryptoApi && typeof cryptoApi.randomUUID === 'function') { + return cryptoApi.randomUUID() + } + + nextBridgeId += 1 + return `${Date.now().toString(36)}-${nextBridgeId.toString(36)}` +} + +function createBridgeMessages(): HtmlArtifactBridgeMessages { + const prefix = `velaros:html-artifact:${createBridgeId()}` + return { + render: `${prefix}:render`, + patch: `${prefix}:patch`, + resize: `${prefix}:resize`, + sendPrompt: `${prefix}:prompt`, + openLink: `${prefix}:link`, + generic: `${prefix}:message`, + error: `${prefix}:error`, + } +} + +function normalizeDimension(value: number | undefined, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.max(1, Math.ceil(value)) + : fallback +} + +function readMessagePayload(value: unknown): MessagePayload | null { + if (!value || typeof value !== 'object') return null + const type = (value as { type?: unknown }).type + return typeof type === 'string' ? (value as MessagePayload) : null +} + +function readString(value: unknown): string { + return typeof value === 'string' ? value : '' +} + +/** + * Mount one streaming HTML artifact surface into a DOM element. + * + * The controller owns protocol parsing, iframe creation, sandbox transport, height negotiation, + * action validation, and cleanup. Callers only feed model text and handle explicit capabilities. + */ +export function mountHtmlArtifact( + target: HTMLElement, + options: MountHtmlArtifactOptions = {} +): HtmlArtifactController { + if (!target || typeof target.replaceChildren !== 'function') { + throw new TypeError('mountHtmlArtifact target must be an HTMLElement') + } + + const minHeight = normalizeDimension(options.minHeight, 1) + const maxHeight = Math.max( + minHeight, + normalizeDimension(options.maxHeight, DEFAULT_HTML_ARTIFACT_MAX_REPORTED_HEIGHT) + ) + const initialHeight = Math.min( + maxHeight, + Math.max(minHeight, normalizeDimension(options.initialHeight, DEFAULT_HTML_ARTIFACT_HEIGHT)) + ) + let bridgeMessages = createBridgeMessages() + let inboundBridgeTypes = createInboundBridgeTypes(bridgeMessages) + const iframe = document.createElement('iframe') + let shellDocument = createShellDocument(bridgeMessages) + let state: HtmlArtifactProtocolStreamState = createHtmlArtifactProtocolStreamState({ + enabled: true, + limits: options.protocolLimits, + }) + let latestArtifactId: string | null = null + let disposed = false + let consuming = false + let frameReady = false + let settleReady: (frame: HTMLIFrameElement) => void = () => undefined + const pendingMessages: MessagePayload[] = [] + + const ready = new Promise((resolve) => { + settleReady = resolve + }) + + function createInboundBridgeTypes(messages: HtmlArtifactBridgeMessages): Set { + return new Set([ + messages.resize, + messages.sendPrompt, + messages.openLink, + messages.generic, + messages.error, + ]) + } + + function createShellDocument(messages: HtmlArtifactBridgeMessages): string { + return buildHtmlArtifactShellDocument({ + bridgeMessages: messages, + designCss: options.designCss, + maxReportedHeight: maxHeight, + rootId: options.rootId, + }) + } + + function reportError(error: HtmlArtifactHostError): void { + try { + options.onError?.(error) + } catch { + // Error callbacks are an application boundary and must not destabilize the stream runtime. + } + } + + function invoke( + callback: ((...args: T) => void) | undefined, + callbackName: string, + ...args: T + ): void { + if (!callback) return + try { + callback(...args) + } catch (cause) { + reportError({ + phase: 'host', + message: `${callbackName} callback failed`, + cause, + }) + } + } + + function assertActive(): void { + if (disposed) throw new Error('HTML artifact controller has been disposed') + } + + function post(payload: MessagePayload): void { + if (!frameReady) { + pendingMessages.push(payload) + return + } + + const frameWindow = iframe.contentWindow + if (!frameWindow) { + reportError({ phase: 'host', message: 'HTML artifact iframe is not available' }) + return + } + frameWindow.postMessage(payload, '*') + } + + function dispatch(events: HtmlArtifactProtocolEvent[]): HtmlArtifactProtocolEvent[] { + for (const event of events) { + if (event.type === 'markdown') { + invoke(options.onMarkdown, 'onMarkdown', event.text) + } else if (event.type === 'artifact-open') { + latestArtifactId = event.artifact.id + } else if (event.type === 'artifact-update') { + latestArtifactId = event.artifact.id + post({ type: bridgeMessages.render, html: event.html, patches: [] }) + } else if (event.type === 'artifact-patch') { + latestArtifactId = event.artifact.id + post({ type: bridgeMessages.patch, patches: [event.patch] }) + } else if (event.type === 'artifact-diagnostic') { + reportError({ + phase: 'protocol', + message: event.diagnostic.message, + patchId: event.diagnostic.patchId, + patchType: event.diagnostic.patchType, + }) + } else if (event.type === 'artifact-close') { + latestArtifactId = event.artifact.id + } + + invoke(options.onEvent, 'onEvent', event) + } + return events + } + + function getSnapshot(artifactId = latestArtifactId ?? ''): HtmlArtifactSnapshot | null { + const snapshot = state.artifactsById[artifactId] + return snapshot ? { ...snapshot } : null + } + + function applyReportedHeight(payload: MessagePayload): void { + const candidate = Number(payload.naturalHeight ?? payload.height) + if (!Number.isFinite(candidate) || candidate <= 0) return + const height = Math.min(maxHeight, Math.max(minHeight, Math.ceil(candidate))) + if (Math.round(iframe.getBoundingClientRect().height) !== height) { + iframe.style.height = `${height}px` + } + } + + function handleMessage(event: MessageEvent): void { + if (disposed) return + const payload = readMessagePayload(event.data) + if (!payload) return + + const sourceMatches = event.source === iframe.contentWindow + const isPrivateOpaqueMessage = + event.origin === 'null' && inboundBridgeTypes.has(payload.type) + if (!sourceMatches && !isPrivateOpaqueMessage) return + + if (payload.type === bridgeMessages.resize) { + applyReportedHeight(payload) + } else if (payload.type === bridgeMessages.sendPrompt) { + invoke(options.onPrompt, 'onPrompt', readString(payload.prompt)) + } else if (payload.type === bridgeMessages.openLink) { + const url = normalizeHtmlArtifactExternalUrl(payload.url, { + allowedProtocols: options.allowedLinkProtocols, + }) + if (url) { + invoke(options.onLink, 'onLink', url) + } else { + reportError({ phase: 'security', message: 'Blocked an invalid artifact URL' }) + } + } else if (payload.type === bridgeMessages.generic) { + invoke(options.onMessage, 'onMessage', payload.payload) + } else if (payload.type === bridgeMessages.error) { + reportError({ + phase: 'runtime', + message: readString(payload.message) || 'Artifact runtime error', + patchId: readString(payload.patchId) || undefined, + patchType: readString(payload.patchType) || undefined, + }) + } else if (payload.type === HTML_ARTIFACT_WHEEL_MESSAGE_TYPE && sourceMatches) { + const deltaX = Number(payload.deltaX) || 0 + const deltaY = Number(payload.deltaY) || 0 + if (options.onWheel) { + invoke(options.onWheel, 'onWheel', deltaX, deltaY) + } else { + window.scrollBy({ left: deltaX, top: deltaY }) + } + } + } + + function handleLoad(): void { + if (disposed) return + frameReady = true + const frameWindow = iframe.contentWindow + if (frameWindow) { + for (const payload of pendingMessages.splice(0)) { + frameWindow.postMessage(payload, '*') + } + } + settleReady(iframe) + } + + iframe.title = options.title ?? 'HTML artifact preview' + if (options.className) iframe.className = options.className + iframe.setAttribute('sandbox', options.sandbox ?? 'allow-scripts') + iframe.referrerPolicy = 'no-referrer' + iframe.style.display = 'block' + iframe.style.width = '100%' + iframe.style.height = `${initialHeight}px` + iframe.style.border = '0' + iframe.addEventListener('load', handleLoad, { once: true }) + iframe.srcdoc = shellDocument + + window.addEventListener('message', handleMessage) + target.replaceChildren(iframe) + + function write(chunk: string): HtmlArtifactProtocolEvent[] { + assertActive() + return dispatch(applyHtmlArtifactProtocolChunk(state, chunk)) + } + + function finish(): HtmlArtifactProtocolEvent[] { + assertActive() + return dispatch(finalizeHtmlArtifactProtocol(state)) + } + + return { + iframe, + ready, + write, + finish, + async consume( + chunks: AsyncIterable | Iterable + ): Promise { + assertActive() + if (consuming) throw new Error('HTML artifact controller is already consuming a stream') + consuming = true + try { + for await (const chunk of chunks) { + write(chunk) + } + finish() + return getSnapshot() + } finally { + consuming = false + } + }, + getSnapshot, + reset(): void { + assertActive() + state = createHtmlArtifactProtocolStreamState({ + enabled: true, + limits: options.protocolLimits, + }) + latestArtifactId = null + pendingMessages.length = 0 + frameReady = false + bridgeMessages = createBridgeMessages() + inboundBridgeTypes = createInboundBridgeTypes(bridgeMessages) + shellDocument = createShellDocument(bridgeMessages) + iframe.style.height = `${initialHeight}px` + iframe.addEventListener('load', handleLoad, { once: true }) + // Reloading the same shell keeps the iframe element stable while removing prior document + // scripts, styles, timers, observers, and event listeners before the next artifact stream. + iframe.srcdoc = shellDocument + }, + dispose(): void { + if (disposed) return + disposed = true + consuming = false + pendingMessages.length = 0 + iframe.removeEventListener('load', handleLoad) + window.removeEventListener('message', handleMessage) + iframe.remove() + settleReady(iframe) + }, + } +} diff --git a/src/index.ts b/src/index.ts index f79784a..11b243d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export * from './browser.js' export * from './frame-fit.js' export * from './protocol.js' export * from './security.js' diff --git a/src/runtime.ts b/src/runtime.ts index db0fddc..d8daf38 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,3 +1,3 @@ -export * from './frame-fit.js' -export * from './security.js' -export * from './shell.js' +// Backward-compatible alias. New integrations should import low-level iframe primitives from +// `@velaros/html-artifacts/sandbox`. +export * from './sandbox.js' diff --git a/src/sandbox.ts b/src/sandbox.ts new file mode 100644 index 0000000..db0fddc --- /dev/null +++ b/src/sandbox.ts @@ -0,0 +1,3 @@ +export * from './frame-fit.js' +export * from './security.js' +export * from './shell.js' diff --git a/tests/browser.test.mjs b/tests/browser.test.mjs new file mode 100644 index 0000000..4ac6b5d --- /dev/null +++ b/tests/browser.test.mjs @@ -0,0 +1,213 @@ +import assert from 'node:assert/strict' +import { describe, test } from 'node:test' + +import { mountHtmlArtifact } from '../dist/index.js' + +class FakeEventTarget { + listeners = new Map() + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? new Set() + listeners.add(listener) + this.listeners.set(type, listeners) + } + + removeEventListener(type, listener) { + this.listeners.get(type)?.delete(listener) + } + + dispatch(type, event = {}) { + for (const listener of this.listeners.get(type) ?? []) listener(event) + } +} + +class FakeWindow extends FakeEventTarget { + scrollCalls = [] + + scrollBy(options) { + this.scrollCalls.push(options) + } +} + +class FakeIframe extends FakeEventTarget { + attributes = new Map() + className = '' + referrerPolicy = '' + removed = false + srcdoc = '' + style = {} + title = '' + contentWindow = { + messages: [], + postMessage: (payload, targetOrigin) => { + this.contentWindow.messages.push({ payload, targetOrigin }) + }, + } + + setAttribute(name, value) { + this.attributes.set(name, value) + } + + getBoundingClientRect() { + return { height: Number.parseFloat(this.style.height) || 0 } + } + + remove() { + this.removed = true + } +} + +class FakeDocument { + iframe = new FakeIframe() + + createElement(name) { + assert.equal(name, 'iframe') + return this.iframe + } +} + +class FakeTarget { + children = [] + + replaceChildren(...children) { + this.children = children + } +} + +function bridgeType(source, suffix) { + return source.match(new RegExp(`velaros:html-artifact:[^"']+:${suffix}`))?.[0] +} + +async function withFakeDom(run) { + const previousWindow = globalThis.window + const previousDocument = globalThis.document + const window = new FakeWindow() + const document = new FakeDocument() + globalThis.window = window + globalThis.document = document + + try { + await run({ document, window }) + } finally { + if (previousWindow === undefined) delete globalThis.window + else globalThis.window = previousWindow + if (previousDocument === undefined) delete globalThis.document + else globalThis.document = previousDocument + } +} + +describe('browser host', () => { + test('owns streaming, sandbox transport, bounded sizing, callbacks, reset, and cleanup', async () => { + await withFakeDom(async ({ document, window }) => { + const prompts = [] + const links = [] + const errors = [] + const target = new FakeTarget() + const controller = mountHtmlArtifact(target, { + initialHeight: 100, + minHeight: 60, + maxHeight: 200, + onError: (error) => errors.push(error), + onLink: (url) => links.push(url), + onPrompt: (prompt) => prompts.push(prompt), + }) + const iframe = document.iframe + const source = + '' + + '
' + + '

Hello

' + + '
' + + assert.equal(target.children[0], iframe) + assert.equal(iframe.attributes.get('sandbox'), 'allow-scripts') + assert.equal(iframe.style.height, '100px') + + controller.write(source) + assert.equal(iframe.contentWindow.messages.length, 0) + iframe.dispatch('load') + assert.equal(await controller.ready, iframe) + assert.equal( + iframe.contentWindow.messages.some(({ payload }) => payload.html?.includes('<\/main>/) + assert.equal( + iframe.contentWindow.messages.some(({ payload }) => + payload.patches?.some((patch) => patch.html?.includes('Hello')) + ), + true + ) + + const resize = bridgeType(iframe.srcdoc, 'resize') + const prompt = bridgeType(iframe.srcdoc, 'prompt') + const link = bridgeType(iframe.srcdoc, 'link') + assert.ok(resize) + assert.ok(prompt) + assert.ok(link) + + window.dispatch('message', { + data: { type: resize, naturalHeight: 999 }, + origin: 'null', + source: null, + }) + assert.equal(iframe.style.height, '200px') + + window.dispatch('message', { + data: { type: prompt, prompt: 'Continue' }, + origin: 'null', + source: null, + }) + window.dispatch('message', { + data: { type: link, url: 'javascript:alert(1)' }, + origin: 'null', + source: null, + }) + window.dispatch('message', { + data: { type: link, url: 'https://example.com/docs' }, + origin: 'null', + source: null, + }) + assert.deepEqual(prompts, ['Continue']) + assert.deepEqual(links, ['https://example.com/docs']) + assert.equal(errors.at(-1)?.phase, 'security') + + window.dispatch('message', { + data: { type: 'velaros:html-artifact-wheel', deltaX: 2, deltaY: 12 }, + origin: 'null', + source: iframe.contentWindow, + }) + assert.deepEqual(window.scrollCalls, [{ left: 2, top: 12 }]) + + controller.reset() + assert.equal(iframe.style.height, '100px') + assert.equal(controller.getSnapshot(), null) + const messageCountBeforeReload = iframe.contentWindow.messages.length + controller.write(source) + assert.equal(iframe.contentWindow.messages.length, messageCountBeforeReload) + iframe.dispatch('load') + assert.equal(iframe.contentWindow.messages.length > messageCountBeforeReload, true) + + controller.dispose() + assert.equal(iframe.removed, true) + assert.equal(window.listeners.get('message')?.size ?? 0, 0) + assert.throws(() => controller.write('later'), /disposed/) + }) + }) + + test('consumes async iterables and returns the final snapshot', async () => { + await withFakeDom(async ({ document }) => { + const controller = mountHtmlArtifact(new FakeTarget()) + document.iframe.dispatch('load') + + async function* chunks() { + yield '' + yield '

Done

' + } + + const snapshot = await controller.consume(chunks()) + assert.equal(snapshot?.id, 'async') + assert.match(snapshot?.html ?? '', /Done/) + controller.dispose() + }) + }) +})