Bonsai-Chat-WebGPU / src /lib /artifact-runtime.ts
WaveCut's picture
Fix artifact tool termination and dynamic transcript scrolling
328256a verified
Raw
History Blame Contribute Delete
29.3 kB
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);
});
}