Spaces:
Running
Running
| import { | |
| deleteArtifactWorkspace, | |
| normalizeArtifactFiles, | |
| persistArtifactWorkspace, | |
| publishArtifactWorkspace, | |
| readArtifactWorkspace, | |
| type ArtifactFile, | |
| type ArtifactFileInput, | |
| type ArtifactWorkspacePersistence, | |
| type ArtifactWorkspaceSnapshot, | |
| } from './artifact-workspace'; | |
| export type { ArtifactFile, ArtifactFileInput } from './artifact-workspace'; | |
| export type ArtifactConsoleLevel = 'log' | 'info' | 'warn' | 'error'; | |
| export interface ArtifactConsoleEntry { | |
| level: ArtifactConsoleLevel; | |
| message: string; | |
| source?: string; | |
| line?: number; | |
| column?: number; | |
| } | |
| export interface ArtifactEvaluation { | |
| status: 'passed' | 'failed' | 'timeout'; | |
| entries: ArtifactConsoleEntry[]; | |
| dom?: { | |
| title: string; | |
| textPreview: string; | |
| elementCount: number; | |
| }; | |
| durationMs: number; | |
| testedAt: number; | |
| } | |
| export interface ArtifactDocument { | |
| id: string; | |
| title: string; | |
| source: string; | |
| sandboxedSource: string; | |
| runtimeToken: string; | |
| createdAt: number; | |
| entryPath?: string; | |
| files?: ArtifactFile[]; | |
| schemaVersion?: 2; | |
| workspaceStorage?: ArtifactWorkspacePersistence['backend']; | |
| workspaceWarning?: string; | |
| evaluation?: ArtifactEvaluation; | |
| } | |
| interface RuntimeDiagnostics { | |
| token: string; | |
| evaluation: ArtifactEvaluation; | |
| } | |
| const MAX_ARTIFACT_BYTES = 256 * 1024; | |
| const MAX_CONSOLE_ENTRIES = 100; | |
| const ARTIFACT_DIAGNOSTICS_EVENT = 'bonsai-artifact-diagnostics'; | |
| const diagnostics = new Map<string, RuntimeDiagnostics>(); | |
| const META_REFRESH = /<meta\b(?=[^>]*\bhttp-equiv\s*=\s*["']?refresh\b)[^>]*>/iu; | |
| const JAVASCRIPT_URL = /\b(?:href|src)\s*=\s*["']\s*javascript:/iu; | |
| const SCRIPT_BLOCK = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/giu; | |
| const SCRIPT_NAVIGATION = /(?:\b(?:window\s*\.\s*)?location\s*(?:=|\.\s*href\s*=|\.\s*(?:assign|replace|reload)\s*\()|\bwindow\s*\.\s*open\s*\()/iu; | |
| const ROOT_ABSOLUTE_HTML_RESOURCE = /\b(?:src|href|action)\s*=\s*["']\/(?!\/)/iu; | |
| const ROOT_ABSOLUTE_CSS_RESOURCE = /\burl\s*\(\s*["']?\/(?!\/)/iu; | |
| const ROOT_ABSOLUTE_MODULE_RESOURCE = /\b(?:from\s*|import\s*(?:\(\s*)?)["']\/(?!\/)/iu; | |
| const BASE_ELEMENT = /<base\b/iu; | |
| export const ARTIFACT_CONTENT_SECURITY_POLICY = [ | |
| "default-src 'none'", | |
| "base-uri 'none'", | |
| "connect-src 'none'", | |
| "form-action 'none'", | |
| "frame-src 'none'", | |
| "img-src data: blob:", | |
| "font-src data:", | |
| "media-src data: blob:", | |
| "object-src 'none'", | |
| "style-src 'unsafe-inline'", | |
| "script-src 'unsafe-inline' data:", | |
| "worker-src 'none'", | |
| ].join('; '); | |
| const PUBLISHED_ARTIFACT_CONTENT_SECURITY_POLICY = [ | |
| "default-src 'none'", | |
| "base-uri 'none'", | |
| "connect-src 'none'", | |
| "form-action 'none'", | |
| "frame-src 'none'", | |
| "img-src data:", | |
| "object-src 'none'", | |
| "style-src 'unsafe-inline'", | |
| "script-src 'none'", | |
| "worker-src 'none'", | |
| ].join('; '); | |
| export function configureArtifactIframe( | |
| iframe: HTMLIFrameElement, | |
| source: 'srcdoc' | 'deployment' = 'srcdoc', | |
| ): void { | |
| iframe.setAttribute('sandbox', 'allow-scripts'); | |
| iframe.setAttribute('referrerpolicy', 'no-referrer'); | |
| iframe.setAttribute('allow', ''); | |
| if (source === 'srcdoc') { | |
| iframe.setAttribute('credentialless', ''); | |
| iframe.setAttribute('csp', ARTIFACT_CONTENT_SECURITY_POLICY); | |
| } else { | |
| // Credentialless frames use a separate network context and cannot reach this page's Service Worker. | |
| iframe.removeAttribute('credentialless'); | |
| iframe.removeAttribute('csp'); | |
| } | |
| } | |
| function scriptSafeJson(value: unknown): string { | |
| return JSON.stringify(value).replaceAll('<', '\\u003c'); | |
| } | |
| function resolveWorkspaceSpecifier(fromPath: string, specifier: string): string | null { | |
| const clean = specifier.split(/[?#]/u, 1)[0]; | |
| if (!clean | |
| || clean.startsWith('#') | |
| || clean.startsWith('data:') | |
| || clean.startsWith('blob:') | |
| || clean.startsWith('//') | |
| || /^[a-z][a-z\d+.-]*:/iu.test(clean) | |
| || clean.startsWith('/')) return null; | |
| const segments = fromPath.split('/'); | |
| segments.pop(); | |
| for (const segment of clean.split('/')) { | |
| if (!segment || segment === '.') continue; | |
| if (segment === '..') { | |
| if (segments.length === 0) return null; | |
| segments.pop(); | |
| } else { | |
| segments.push(segment); | |
| } | |
| } | |
| return segments.length > 0 ? segments.join('/') : null; | |
| } | |
| function rewriteVfsFetches(source: string, fromPath: string): string { | |
| return source.replace( | |
| /\bfetch\s*\(\s*(["'])(\.{1,2}\/[^"']+)\1/gu, | |
| (match, _quote: string, specifier: string) => { | |
| const path = resolveWorkspaceSpecifier(fromPath, specifier); | |
| return path ? `__bonsaiVfsFetch(${JSON.stringify(path)}` : match; | |
| }, | |
| ); | |
| } | |
| function rewriteModuleSource(source: string, fromPath: string): string { | |
| const rewrite = (match: string, prefix: string, quote: string, specifier: string) => { | |
| const path = resolveWorkspaceSpecifier(fromPath, specifier); | |
| return path ? `${prefix}${quote}artifact:${path}${quote}` : match; | |
| }; | |
| return rewriteVfsFetches(source, fromPath) | |
| .replace( | |
| /(\b(?:import|export)\s+(?:[^"'`]*?\s+from\s*)?)(["'])(\.{1,2}\/[^"']+)\2/gu, | |
| rewrite, | |
| ) | |
| .replace( | |
| /(\bimport\s*\(\s*)(["'])(\.{1,2}\/[^"']+)\2/gu, | |
| rewrite, | |
| ); | |
| } | |
| function textDataUrl(file: Pick<ArtifactFile, 'mime' | 'content'>, content = file.content): string { | |
| return `data:${file.mime},${encodeURIComponent(content)}`; | |
| } | |
| function buildArtifactSrcdoc( | |
| files: ArtifactFile[], | |
| entryPath: string, | |
| artifactId: string, | |
| runtimeToken: string, | |
| ): string { | |
| const fileMap = new Map(files.map((file) => [file.path, file])); | |
| const entry = fileMap.get(entryPath); | |
| if (!entry) throw new Error(`artifact entry file is missing: ${entryPath}`); | |
| const rewriteCss = (source: string, fromPath: string, stack = new Set<string>()): string => { | |
| const withImports = source.replace( | |
| /@import\s+(?:url\(\s*)?(["'])([^"']+)\1\s*\)?\s*;/giu, | |
| (match, _quote: string, specifier: string) => { | |
| const path = resolveWorkspaceSpecifier(fromPath, specifier); | |
| const imported = path ? fileMap.get(path) : undefined; | |
| if (!path || !imported?.mime.startsWith('text/css') || stack.has(path)) return match; | |
| return rewriteCss(imported.content, path, new Set([...stack, path])); | |
| }, | |
| ); | |
| return withImports.replace( | |
| /url\(\s*(["']?)([^"')]+)\1\s*\)/giu, | |
| (match, _quote: string, specifier: string) => { | |
| const path = resolveWorkspaceSpecifier(fromPath, specifier.trim()); | |
| const asset = path ? fileMap.get(path) : undefined; | |
| return asset ? `url("${textDataUrl(asset)}")` : match; | |
| }, | |
| ); | |
| }; | |
| const moduleImports = Object.fromEntries( | |
| files | |
| .filter((file) => file.mime.startsWith('text/javascript')) | |
| .map((file) => [ | |
| `artifact:${file.path}`, | |
| textDataUrl(file, `${rewriteModuleSource(file.content, file.path)}\n//# sourceURL=artifact:${file.path}`), | |
| ]), | |
| ); | |
| let source = entry.content; | |
| source = source.replace(/<style\b([^>]*)>([\s\S]*?)<\/style\s*>/giu, (_match, attributes: string, css: string) => ( | |
| `<style${attributes}>${rewriteCss(css, entryPath, new Set([entryPath]))}</style>` | |
| )); | |
| source = source.replace(/<link\b([^>]*)>/giu, (match, attributes: string) => { | |
| const rel = /\brel\s*=\s*(["'])([^"']+)\1/iu.exec(attributes)?.[2]?.toLowerCase() ?? ''; | |
| const hrefMatch = /\bhref\s*=\s*(["'])([^"']+)\1/iu.exec(attributes); | |
| if (!hrefMatch) return match; | |
| const path = resolveWorkspaceSpecifier(entryPath, hrefMatch[2]!); | |
| const file = path ? fileMap.get(path) : undefined; | |
| if (path && rel.split(/\s+/u).includes('stylesheet') && file?.mime.startsWith('text/css')) { | |
| return `<style data-bonsai-file="${path}">${rewriteCss(file.content, path, new Set([path]))}</style>`; | |
| } | |
| if (rel.split(/\s+/u).includes('icon') && file) { | |
| return match.replace(hrefMatch[0], `href="${textDataUrl(file)}"`); | |
| } | |
| return match; | |
| }); | |
| source = source.replace(/<(img|source|video|audio|track|input)\b([^>]*)>/giu, (match, tag: string, attributes: string) => { | |
| const rewritten = attributes.replace( | |
| /\b(src|poster)\s*=\s*(["'])([^"']+)\2/giu, | |
| (attribute, name: string, _quote: string, specifier: string) => { | |
| const path = resolveWorkspaceSpecifier(entryPath, specifier); | |
| const file = path ? fileMap.get(path) : undefined; | |
| return file ? `${name}="${textDataUrl(file)}"` : attribute; | |
| }, | |
| ); | |
| return `<${tag}${rewritten}>`; | |
| }); | |
| source = source.replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/giu, (match, attributes: string, inline: string) => { | |
| const srcMatch = /\bsrc\s*=\s*(["'])([^"']+)\1/iu.exec(attributes); | |
| const moduleScript = /\btype\s*=\s*(["'])module\1/iu.test(attributes); | |
| const fromPath = srcMatch | |
| ? resolveWorkspaceSpecifier(entryPath, srcMatch[2]!) ?? entryPath | |
| : entryPath; | |
| const external = srcMatch ? fileMap.get(fromPath) : undefined; | |
| if (srcMatch && !external) return match; | |
| const code = external?.content ?? inline; | |
| const rewritten = moduleScript | |
| ? rewriteModuleSource(code, fromPath) | |
| : rewriteVfsFetches(code, fromPath); | |
| const cleanAttributes = srcMatch | |
| ? attributes | |
| .replace(srcMatch[0], '') | |
| .replace(/\b(?:integrity|crossorigin|referrerpolicy)\s*=\s*(["'])[^"']*\1/giu, '') | |
| : attributes; | |
| return `<script${cleanAttributes}>${rewritten}<\/script>`; | |
| }); | |
| const importMap = `<script type="importmap">${scriptSafeJson({ imports: moduleImports })}<\/script>`; | |
| const injection = `<meta http-equiv="Content-Security-Policy" content="${ARTIFACT_CONTENT_SECURITY_POLICY}">${runtimeBridge(artifactId, runtimeToken, files, entryPath)}${importMap}`; | |
| const doctype = /^\s*<!doctype\s+html[^>]*>/iu.exec(source); | |
| if (!doctype) return `${injection}${source}`; | |
| return `${doctype[0]}${injection}${source.slice(doctype[0].length)}`; | |
| } | |
| function runtimeBridge( | |
| artifactId: string, | |
| runtimeToken: string, | |
| files: ArtifactFile[] = [], | |
| entryPath = 'index.html', | |
| ): string { | |
| const serializedFiles = scriptSafeJson(Object.fromEntries(files.map((file) => [ | |
| file.path, | |
| { content: file.content, mime: file.mime }, | |
| ]))); | |
| return `<script data-bonsai-artifact-runtime> | |
| (() => { | |
| const artifactId = ${JSON.stringify(artifactId)}; | |
| const runtimeToken = ${JSON.stringify(runtimeToken)}; | |
| const vfsFiles = ${serializedFiles}; | |
| const entryPath = ${JSON.stringify(entryPath)}; | |
| const startedAt = performance.now(); | |
| const entries = []; | |
| const resolvePath = (fromPath, specifier) => { | |
| const value = String(specifier || '').split(/[?#]/, 1)[0]; | |
| if (!value || value.startsWith('/') || value.startsWith('//') || /^[a-z][a-z\\d+.-]*:/i.test(value)) return null; | |
| const segments = fromPath.split('/'); | |
| segments.pop(); | |
| for (const segment of value.split('/')) { | |
| if (!segment || segment === '.') continue; | |
| if (segment === '..') { | |
| if (segments.length === 0) return null; | |
| segments.pop(); | |
| } else segments.push(segment); | |
| } | |
| return segments.join('/'); | |
| }; | |
| const vfsFetch = (path, init = {}) => { | |
| const method = String(init?.method || 'GET').toUpperCase(); | |
| if (method !== 'GET' && method !== 'HEAD') return Promise.reject(new TypeError('Artifact VFS is read-only')); | |
| const file = vfsFiles[path]; | |
| if (!file) return Promise.resolve(new Response('Artifact file not found', { status: 404 })); | |
| return Promise.resolve(new Response(method === 'HEAD' ? null : file.content, { | |
| status: 200, | |
| headers: { 'Content-Type': file.mime, 'Cache-Control': 'no-store' }, | |
| })); | |
| }; | |
| globalThis.__bonsaiVfsFetch = vfsFetch; | |
| globalThis.fetch = (input, init) => { | |
| if (typeof input !== 'string') return Promise.reject(new TypeError('Artifact network access is disabled')); | |
| const path = resolvePath(entryPath, input); | |
| return path ? vfsFetch(path, init) : Promise.reject(new TypeError('Artifact network access is disabled')); | |
| }; | |
| const render = (value) => { | |
| if (value instanceof Error) return value.stack || value.message; | |
| if (typeof value === 'string') return value; | |
| try { return JSON.stringify(value); } catch { return String(value); } | |
| }; | |
| const send = (phase) => parent.postMessage({ | |
| channel: 'bonsai-artifact-runtime', | |
| artifactId, | |
| runtimeToken, | |
| phase, | |
| entries, | |
| dom: phase === 'settled' ? { | |
| title: document.title, | |
| textPreview: (document.body?.innerText || '').replace(/\\s+/g, ' ').trim().slice(0, 1000), | |
| elementCount: document.querySelectorAll('*').length, | |
| } : undefined, | |
| durationMs: performance.now() - startedAt, | |
| }, '*'); | |
| const record = (level, values, location = {}) => { | |
| entries.push({ | |
| level, | |
| message: values.map(render).join(' ').slice(0, 2048), | |
| ...location, | |
| }); | |
| if (entries.length > ${MAX_CONSOLE_ENTRIES}) entries.shift(); | |
| send('live'); | |
| }; | |
| const blockNavigation = (event) => { | |
| const target = event.target instanceof Element ? event.target.closest('a[href], form') : null; | |
| if (!target) return; | |
| const anchor = target instanceof HTMLAnchorElement ? target : null; | |
| if (anchor && (anchor.getAttribute('href') || '').startsWith('#')) return; | |
| event.preventDefault(); | |
| record('warn', ['Artifact navigation blocked']); | |
| }; | |
| addEventListener('click', blockNavigation, true); | |
| addEventListener('submit', blockNavigation, true); | |
| window.open = () => { record('warn', ['Artifact popup blocked']); return null; }; | |
| new MutationObserver((records) => { | |
| for (const mutation of records) { | |
| for (const node of mutation.addedNodes) { | |
| if (!(node instanceof Element)) continue; | |
| const candidates = [node, ...node.querySelectorAll('meta[http-equiv]')]; | |
| for (const candidate of candidates) { | |
| if (candidate instanceof HTMLMetaElement && candidate.httpEquiv.toLowerCase() === 'refresh') { | |
| candidate.remove(); | |
| record('error', ['Artifact meta refresh blocked']); | |
| } | |
| } | |
| } | |
| } | |
| }).observe(document, { childList: true, subtree: true }); | |
| for (const level of ['log', 'info', 'warn', 'error']) { | |
| const original = console[level].bind(console); | |
| console[level] = (...values) => { record(level, values); original(...values); }; | |
| } | |
| addEventListener('error', (event) => { | |
| const target = event.target; | |
| const fallback = target && target !== window | |
| ? (target.tagName || 'resource') + ' failed to load' | |
| : 'Unknown artifact error'; | |
| record('error', [event.error || event.message || fallback], { | |
| source: event.filename || undefined, | |
| line: event.lineno || undefined, | |
| column: event.colno || undefined, | |
| }); | |
| }, true); | |
| addEventListener('unhandledrejection', (event) => { | |
| record('error', ['Unhandled promise rejection:', event.reason]); | |
| }); | |
| addEventListener('DOMContentLoaded', () => setTimeout(() => send('settled'), 500), { once: true }); | |
| setTimeout(() => send('settled'), 1500); | |
| })(); | |
| <\/script>`; | |
| } | |
| function htmlTagEnd(source: string, tagStart: number): number { | |
| let quote: '"' | "'" | null = null; | |
| for (let index = tagStart + 1; index < source.length; index += 1) { | |
| const character = source[index]!; | |
| if (quote) { | |
| if (character === quote) quote = null; | |
| continue; | |
| } | |
| if (character === '"' || character === "'") { | |
| quote = character; | |
| continue; | |
| } | |
| if (character === '>') return index; | |
| } | |
| return -1; | |
| } | |
| function validateHtmlStructure(source: string, path: string): void { | |
| const lowerSource = source.toLowerCase(); | |
| let cursor = 0; | |
| while (cursor < source.length) { | |
| const tagStart = source.indexOf('<', cursor); | |
| if (tagStart < 0) return; | |
| if (source.startsWith('<!--', tagStart)) { | |
| const commentEnd = source.indexOf('-->', tagStart + 4); | |
| if (commentEnd < 0) throw new Error(`${path} contains an unterminated HTML comment`); | |
| cursor = commentEnd + 3; | |
| continue; | |
| } | |
| const rawTextMatch = /^<(script|style)(?:\s|>|\/)/iu.exec(source.slice(tagStart)); | |
| if (rawTextMatch) { | |
| const openEnd = htmlTagEnd(source, tagStart); | |
| if (openEnd < 0) throw new Error(`${path} contains an unterminated HTML tag or quoted attribute`); | |
| const tagName = rawTextMatch[1]!.toLowerCase(); | |
| const closeStart = lowerSource.indexOf(`</${tagName}`, openEnd + 1); | |
| if (closeStart < 0) throw new Error(`${path} contains an unterminated <${tagName}> element`); | |
| const closeEnd = htmlTagEnd(source, closeStart); | |
| if (closeEnd < 0) throw new Error(`${path} contains an unterminated HTML tag or quoted attribute`); | |
| cursor = closeEnd + 1; | |
| continue; | |
| } | |
| const nextCharacter = source[tagStart + 1] ?? ''; | |
| if (!/[a-z!/?]/iu.test(nextCharacter)) { | |
| cursor = tagStart + 1; | |
| continue; | |
| } | |
| const tagEnd = htmlTagEnd(source, tagStart); | |
| if (tagEnd < 0) throw new Error(`${path} contains an unterminated HTML tag or quoted attribute`); | |
| cursor = tagEnd + 1; | |
| } | |
| } | |
| function validateArtifactSource(file: ArtifactFile): void { | |
| const lowerPath = file.path.toLowerCase(); | |
| const isHtml = file.mime.startsWith('text/html'); | |
| const isCss = file.mime.startsWith('text/css'); | |
| const isJavaScript = file.mime.startsWith('text/javascript') | |
| || lowerPath.endsWith('.js') | |
| || lowerPath.endsWith('.mjs'); | |
| if (isHtml) validateHtmlStructure(file.content, file.path); | |
| if (isHtml && (META_REFRESH.test(file.content) || JAVASCRIPT_URL.test(file.content) || BASE_ELEMENT.test(file.content))) { | |
| throw new Error(`${file.path} cannot contain base, document navigation, or refresh directives`); | |
| } | |
| if (isHtml && ROOT_ABSOLUTE_HTML_RESOURCE.test(file.content)) { | |
| throw new Error(`${file.path} uses a root-absolute resource; use ./ or another relative path`); | |
| } | |
| if (isCss && ROOT_ABSOLUTE_CSS_RESOURCE.test(file.content)) { | |
| throw new Error(`${file.path} uses a root-absolute CSS resource; use a relative path`); | |
| } | |
| if (isJavaScript && ROOT_ABSOLUTE_MODULE_RESOURCE.test(file.content)) { | |
| throw new Error(`${file.path} uses a root-absolute module; use a relative path`); | |
| } | |
| if (isJavaScript && SCRIPT_NAVIGATION.test(file.content)) { | |
| throw new Error(`${file.path} cannot navigate or open another document`); | |
| } | |
| if (isHtml) { | |
| for (const match of file.content.matchAll(SCRIPT_BLOCK)) { | |
| if (SCRIPT_NAVIGATION.test(match[1] ?? '')) { | |
| throw new Error(`${file.path} scripts cannot navigate or open another document`); | |
| } | |
| } | |
| } | |
| } | |
| function workspaceSnapshot(artifact: ArtifactDocument): ArtifactWorkspaceSnapshot { | |
| const normalized = normalizeArtifactFiles( | |
| artifact.files?.length ? artifact.files : [{ path: 'index.html', content: artifact.source }], | |
| artifact.entryPath ?? 'index.html', | |
| ); | |
| for (const file of normalized.files) validateArtifactSource(file); | |
| return { | |
| id: artifact.id, | |
| runtimeToken: artifact.runtimeToken, | |
| entryPath: normalized.entryPath, | |
| files: normalized.files, | |
| }; | |
| } | |
| export function prepareArtifactDeployment( | |
| files: ArtifactFileInput[], | |
| title = 'Untitled artifact', | |
| entryPath = 'index.html', | |
| id: string = crypto.randomUUID(), | |
| runtimeToken: string = crypto.randomUUID(), | |
| ): ArtifactDocument { | |
| const normalized = normalizeArtifactFiles(files, entryPath); | |
| for (const file of normalized.files) validateArtifactSource(file); | |
| const entry = normalized.files.find((file) => file.path === normalized.entryPath); | |
| if (!entry) throw new Error(`artifact entry file is missing: ${normalized.entryPath}`); | |
| return { | |
| id, | |
| title: title.trim().slice(0, 120) || 'Untitled artifact', | |
| source: entry.content, | |
| sandboxedSource: buildArtifactSrcdoc(normalized.files, normalized.entryPath, id, runtimeToken), | |
| runtimeToken, | |
| createdAt: Date.now(), | |
| entryPath: normalized.entryPath, | |
| files: normalized.files, | |
| schemaVersion: 2, | |
| }; | |
| } | |
| export function prepareHtmlArtifact( | |
| source: string, | |
| title = 'Untitled artifact', | |
| id: string = crypto.randomUUID(), | |
| runtimeToken: string = crypto.randomUUID(), | |
| ): ArtifactDocument { | |
| const bytes = new TextEncoder().encode(source).byteLength; | |
| if (bytes > MAX_ARTIFACT_BYTES) throw new Error('html_artifact source exceeds 256 KiB'); | |
| return prepareArtifactDeployment( | |
| [{ path: 'index.html', content: source }], | |
| title, | |
| 'index.html', | |
| id, | |
| runtimeToken, | |
| ); | |
| } | |
| export function normalizeArtifactDocument(artifact: ArtifactDocument): ArtifactDocument { | |
| const normalized = prepareArtifactDeployment( | |
| artifact.files?.length ? artifact.files : [{ path: 'index.html', content: artifact.source }], | |
| artifact.title, | |
| artifact.entryPath ?? 'index.html', | |
| artifact.id, | |
| typeof artifact.runtimeToken === 'string' && artifact.runtimeToken | |
| ? artifact.runtimeToken | |
| : crypto.randomUUID(), | |
| ); | |
| return { | |
| ...normalized, | |
| createdAt: typeof artifact.createdAt === 'number' ? artifact.createdAt : normalized.createdAt, | |
| ...(artifact.workspaceStorage ? { workspaceStorage: artifact.workspaceStorage } : {}), | |
| ...(artifact.workspaceWarning ? { workspaceWarning: artifact.workspaceWarning } : {}), | |
| ...(artifact.evaluation ? { evaluation: artifact.evaluation } : {}), | |
| }; | |
| } | |
| export async function hydrateArtifactDocument(artifact: ArtifactDocument): Promise<ArtifactDocument> { | |
| const normalized = normalizeArtifactDocument(artifact); | |
| const snapshot = workspaceSnapshot(normalized); | |
| const stored = await readArtifactWorkspace(snapshot); | |
| if (!stored) { | |
| const persistence = await persistArtifactWorkspace(snapshot); | |
| return { | |
| ...normalized, | |
| workspaceStorage: persistence.backend, | |
| ...(persistence.warning ? { workspaceWarning: persistence.warning } : {}), | |
| }; | |
| } | |
| const files = normalizeArtifactFiles(stored, snapshot.entryPath).files; | |
| const entry = files.find((file) => file.path === snapshot.entryPath); | |
| const restored = normalizeArtifactDocument({ | |
| ...normalized, | |
| files, | |
| source: entry?.content ?? normalized.source, | |
| }); | |
| return { | |
| ...restored, | |
| workspaceStorage: 'opfs', | |
| workspaceWarning: undefined, | |
| }; | |
| } | |
| export interface ArtifactDeployment { | |
| artifact: ArtifactDocument; | |
| previewUrl: string | null; | |
| publishedUrl: string | null; | |
| } | |
| export async function deployArtifactDocument(artifact: ArtifactDocument): Promise<ArtifactDeployment> { | |
| const normalized = normalizeArtifactDocument(artifact); | |
| const snapshot = workspaceSnapshot(normalized); | |
| const persistence = await persistArtifactWorkspace(snapshot); | |
| const withStorage: ArtifactDocument = { | |
| ...normalized, | |
| workspaceStorage: persistence.backend, | |
| ...(persistence.warning ? { workspaceWarning: persistence.warning } : {}), | |
| }; | |
| const entry = snapshot.files.find((file) => file.path === snapshot.entryPath); | |
| if (!entry) throw new Error(`artifact entry file is missing: ${snapshot.entryPath}`); | |
| try { | |
| return { | |
| artifact: withStorage, | |
| previewUrl: null, | |
| publishedUrl: await publishArtifactWorkspace( | |
| snapshot, | |
| entry.content, | |
| PUBLISHED_ARTIFACT_CONTENT_SECURITY_POLICY, | |
| ), | |
| }; | |
| } catch { | |
| return { artifact: withStorage, previewUrl: null, publishedUrl: null }; | |
| } | |
| } | |
| export async function deleteArtifactDocumentStorage(artifactId: string): Promise<void> { | |
| await deleteArtifactWorkspace(artifactId); | |
| } | |
| export function evaluationFromRuntimeMessage( | |
| data: unknown, | |
| artifactId: string, | |
| runtimeToken: string, | |
| ): ArtifactEvaluation | null { | |
| if (!data || typeof data !== 'object') return null; | |
| const payload = data as Record<string, unknown>; | |
| if (payload.channel !== 'bonsai-artifact-runtime' | |
| || payload.artifactId !== artifactId | |
| || payload.runtimeToken !== runtimeToken | |
| || (payload.phase !== 'live' && payload.phase !== 'settled')) return null; | |
| const entries = normalizeConsoleEntries(payload.entries); | |
| const dom = payload.dom && typeof payload.dom === 'object' | |
| ? payload.dom as Record<string, unknown> | |
| : null; | |
| return { | |
| status: entries.some((entry) => entry.level === 'error') ? 'failed' : 'passed', | |
| entries, | |
| ...(dom ? { | |
| dom: { | |
| title: String(dom.title ?? '').slice(0, 200), | |
| textPreview: String(dom.textPreview ?? '').slice(0, 1000), | |
| elementCount: typeof dom.elementCount === 'number' ? Math.max(0, Math.round(dom.elementCount)) : 0, | |
| }, | |
| } : {}), | |
| durationMs: typeof payload.durationMs === 'number' && Number.isFinite(payload.durationMs) | |
| ? Math.max(0, payload.durationMs) | |
| : 0, | |
| testedAt: Date.now(), | |
| }; | |
| } | |
| function normalizeConsoleEntries(value: unknown): ArtifactConsoleEntry[] { | |
| if (!Array.isArray(value)) return []; | |
| const seen = new Set<string>(); | |
| const result: ArtifactConsoleEntry[] = []; | |
| for (const raw of value) { | |
| if (!raw || typeof raw !== 'object') continue; | |
| const entry = raw as Record<string, unknown>; | |
| const level = ['log', 'info', 'warn', 'error'].includes(String(entry.level)) | |
| ? String(entry.level) as ArtifactConsoleLevel | |
| : 'log'; | |
| const message = String(entry.message ?? '').slice(0, 2048); | |
| if (!message) continue; | |
| const normalized: ArtifactConsoleEntry = { | |
| level, | |
| message, | |
| ...(typeof entry.source === 'string' && entry.source ? { source: entry.source.slice(0, 512) } : {}), | |
| ...(typeof entry.line === 'number' ? { line: entry.line } : {}), | |
| ...(typeof entry.column === 'number' ? { column: entry.column } : {}), | |
| }; | |
| const key = `${normalized.level}\u0000${normalized.message}\u0000${normalized.source ?? ''}\u0000${normalized.line ?? ''}`; | |
| if (seen.has(key)) continue; | |
| seen.add(key); | |
| result.push(normalized); | |
| if (result.length >= MAX_CONSOLE_ENTRIES) break; | |
| } | |
| return result; | |
| } | |
| export function recordArtifactDiagnostics( | |
| artifactId: string, | |
| runtimeToken: string, | |
| evaluation: ArtifactEvaluation, | |
| ): void { | |
| diagnostics.set(artifactId, { token: runtimeToken, evaluation }); | |
| if (typeof window !== 'undefined') { | |
| window.dispatchEvent(new CustomEvent(ARTIFACT_DIAGNOSTICS_EVENT, { | |
| detail: { artifactId, runtimeToken, evaluation }, | |
| })); | |
| } | |
| } | |
| export function getArtifactDiagnostics(artifact: ArtifactDocument): ArtifactEvaluation | undefined { | |
| const current = diagnostics.get(artifact.id); | |
| return current?.token === artifact.runtimeToken ? current.evaluation : artifact.evaluation; | |
| } | |
| export function artifactDiagnosticsEventName(): string { | |
| return ARTIFACT_DIAGNOSTICS_EVENT; | |
| } | |
| export function isArtifactRuntimeMessageEvent( | |
| event: MessageEvent, | |
| contentWindow: Window | null | undefined, | |
| ): boolean { | |
| return Boolean(contentWindow) | |
| && event.source === contentWindow | |
| && event.origin === 'null'; | |
| } | |
| export async function evaluateHtmlArtifact( | |
| artifact: ArtifactDocument, | |
| timeoutMs = 2500, | |
| signal?: AbortSignal, | |
| ): Promise<ArtifactEvaluation> { | |
| if (typeof document === 'undefined' || !document.documentElement) { | |
| throw new Error('artifact evaluation requires a browser document'); | |
| } | |
| const deployment = await deployArtifactDocument(artifact); | |
| Object.assign(artifact, { | |
| files: deployment.artifact.files, | |
| entryPath: deployment.artifact.entryPath, | |
| schemaVersion: deployment.artifact.schemaVersion, | |
| workspaceStorage: deployment.artifact.workspaceStorage, | |
| workspaceWarning: deployment.artifact.workspaceWarning, | |
| }); | |
| const iframe = document.createElement('iframe'); | |
| configureArtifactIframe(iframe, deployment.previewUrl ? 'deployment' : 'srcdoc'); | |
| iframe.setAttribute('aria-hidden', 'true'); | |
| iframe.style.position = 'fixed'; | |
| iframe.style.left = '-10000px'; | |
| iframe.style.top = '0'; | |
| iframe.style.width = '1024px'; | |
| iframe.style.height = '768px'; | |
| iframe.style.visibility = 'hidden'; | |
| iframe.style.pointerEvents = 'none'; | |
| if (deployment.previewUrl) iframe.src = deployment.previewUrl; | |
| else iframe.srcdoc = deployment.artifact.sandboxedSource; | |
| const startedAt = performance.now(); | |
| return new Promise((resolve, reject) => { | |
| const cleanup = () => { | |
| window.removeEventListener('message', onMessage); | |
| signal?.removeEventListener('abort', onAbort); | |
| iframe.remove(); | |
| }; | |
| const finish = (evaluation: ArtifactEvaluation) => { | |
| window.clearTimeout(timer); | |
| cleanup(); | |
| recordArtifactDiagnostics(artifact.id, artifact.runtimeToken, evaluation); | |
| resolve(evaluation); | |
| }; | |
| const timer = window.setTimeout(() => finish({ | |
| status: 'timeout', | |
| entries: [{ level: 'error', message: `Artifact evaluation exceeded ${timeoutMs} ms` }], | |
| durationMs: performance.now() - startedAt, | |
| testedAt: Date.now(), | |
| }), timeoutMs); | |
| const onMessage = (event: MessageEvent) => { | |
| if (!isArtifactRuntimeMessageEvent(event, iframe.contentWindow)) return; | |
| const evaluation = evaluationFromRuntimeMessage(event.data, artifact.id, artifact.runtimeToken); | |
| if (!evaluation || event.data?.phase !== 'settled') return; | |
| finish(evaluation); | |
| }; | |
| const onAbort = () => { | |
| window.clearTimeout(timer); | |
| cleanup(); | |
| reject(new DOMException('The artifact evaluation was aborted.', 'AbortError')); | |
| }; | |
| window.addEventListener('message', onMessage); | |
| if (signal?.aborted) { | |
| onAbort(); | |
| return; | |
| } | |
| signal?.addEventListener('abort', onAbort, { once: true }); | |
| document.documentElement.append(iframe); | |
| }); | |
| } | |