diff --git a/packages/browser-extension/src/components/SandboxIframe.ts b/packages/browser-extension/src/components/SandboxIframe.ts deleted file mode 100644 index b7e2f097..00000000 --- a/packages/browser-extension/src/components/SandboxIframe.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { LitElement } from "lit"; -import { customElement, property } from "lit/decorators.js"; - -// @ts-ignore - browser global exists in Firefox -declare const browser: any; - -@customElement("sandbox-iframe") -export class SandboxIframe extends LitElement { - @property() content = ""; - private iframe?: HTMLIFrameElement; - - createRenderRoot() { - return this; - } - - override connectedCallback() { - super.connectedCallback(); - window.addEventListener("message", this.handleMessage); - this.createIframe(); - } - - override disconnectedCallback() { - super.disconnectedCallback(); - window.removeEventListener("message", this.handleMessage); - this.iframe?.remove(); - } - - private handleMessage = (e: MessageEvent) => { - if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) { - // Sandbox is ready, send content - this.iframe?.contentWindow?.postMessage( - { - type: "loadContent", - content: this.content, - artifactId: "test", - attachments: [], - }, - "*", - ); - } - }; - - private createIframe() { - this.iframe = document.createElement("iframe"); - this.iframe.sandbox.add("allow-scripts"); - this.iframe.sandbox.add("allow-modals"); - this.iframe.style.width = "100%"; - this.iframe.style.height = "100%"; - this.iframe.style.border = "none"; - - const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined; - if (isFirefox) { - this.iframe.src = browser.runtime.getURL("sandbox.html"); - } else { - this.iframe.src = chrome.runtime.getURL("sandbox.html"); - } - - this.appendChild(this.iframe); - } - - public updateContent(newContent: string) { - this.content = newContent; - // Recreate iframe for clean state - if (this.iframe) { - this.iframe.remove(); - this.iframe = undefined; - } - this.createIframe(); - } -} diff --git a/packages/browser-extension/src/components/SandboxedIframe.ts b/packages/browser-extension/src/components/SandboxedIframe.ts new file mode 100644 index 00000000..344ce421 --- /dev/null +++ b/packages/browser-extension/src/components/SandboxedIframe.ts @@ -0,0 +1,271 @@ +import { LitElement } from "lit"; +import { customElement, property } from "lit/decorators.js"; +import type { Attachment } from "../utils/attachment-utils.js"; + +// @ts-ignore - browser global exists in Firefox +declare const browser: any; + +@customElement("sandbox-iframe") +export class SandboxIframe extends LitElement { + @property() content = ""; + @property() artifactId = ""; + @property({ attribute: false }) attachments: Attachment[] = []; + + private iframe?: HTMLIFrameElement; + private logs: Array<{ type: "log" | "error"; text: string }> = []; + + createRenderRoot() { + return this; + } + + override connectedCallback() { + super.connectedCallback(); + window.addEventListener("message", this.handleMessage); + this.createIframe(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + window.removeEventListener("message", this.handleMessage); + this.iframe?.remove(); + } + + private handleMessage = (e: MessageEvent) => { + // Handle sandbox-ready message + if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) { + // Sandbox is ready, inject our runtime and send content + const enhancedContent = this.injectRuntimeScripts(this.content); + this.iframe?.contentWindow?.postMessage( + { + type: "loadContent", + content: enhancedContent, + artifactId: this.artifactId, + attachments: this.attachments, + }, + "*", + ); + return; + } + + // Only handle messages for this artifact + if (e.data.artifactId !== this.artifactId) return; + + // Handle console messages + if (e.data.type === "console") { + const log = { + type: e.data.method === "error" ? ("error" as const) : ("log" as const), + text: e.data.text, + }; + this.logs.push(log); + this.dispatchEvent( + new CustomEvent("console", { + detail: log, + bubbles: true, + composed: true, + }), + ); + } else if (e.data.type === "execution-complete") { + // Store final logs + this.logs = e.data.logs || []; + this.dispatchEvent( + new CustomEvent("execution-complete", { + detail: { logs: this.logs }, + bubbles: true, + composed: true, + }), + ); + + // Force reflow when iframe content is ready + if (this.iframe) { + this.iframe.style.display = "none"; + this.iframe.offsetHeight; // Force reflow + this.iframe.style.display = ""; + } + } + }; + + private injectRuntimeScripts(htmlContent: string): string { + // Define the runtime function that will be injected + const runtimeFunction = (artifactId: string, attachments: any[]) => { + // @ts-ignore - window extensions + window.__artifactLogs = []; + const originalConsole = { + log: console.log, + error: console.error, + warn: console.warn, + info: console.info, + }; + + ["log", "error", "warn", "info"].forEach((method) => { + // @ts-ignore + console[method] = (...args: any[]) => { + const text = args + .map((arg: any) => { + try { + return typeof arg === "object" ? JSON.stringify(arg) : String(arg); + } catch { + return String(arg); + } + }) + .join(" "); + // @ts-ignore + window.__artifactLogs.push({ type: method === "error" ? "error" : "log", text }); + window.parent.postMessage( + { + type: "console", + method, + text, + artifactId, + }, + "*", + ); + // @ts-ignore + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", (e: ErrorEvent) => { + const text = e.message + " at line " + e.lineno + ":" + e.colno; + // @ts-ignore + window.__artifactLogs.push({ type: "error", text }); + window.parent.postMessage( + { + type: "console", + method: "error", + text, + artifactId, + }, + "*", + ); + }); + + window.addEventListener("unhandledrejection", (e: PromiseRejectionEvent) => { + const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error"); + // @ts-ignore + window.__artifactLogs.push({ type: "error", text }); + window.parent.postMessage( + { + type: "console", + method: "error", + text, + artifactId, + }, + "*", + ); + }); + + // Attachment helpers + // @ts-ignore + window.attachments = attachments; + // @ts-ignore + window.listFiles = () => { + // @ts-ignore + return (window.attachments || []).map((a: any) => ({ + id: a.id, + fileName: a.fileName, + mimeType: a.mimeType, + size: a.size, + })); + }; + // @ts-ignore + window.readTextFile = (attachmentId: string) => { + // @ts-ignore + const a = (window.attachments || []).find((x: any) => x.id === attachmentId); + if (!a) throw new Error("Attachment not found: " + attachmentId); + if (a.extractedText) return a.extractedText; + try { + return atob(a.content); + } catch { + throw new Error("Failed to decode text content for: " + attachmentId); + } + }; + // @ts-ignore + window.readBinaryFile = (attachmentId: string) => { + // @ts-ignore + const a = (window.attachments || []).find((x: any) => x.id === attachmentId); + if (!a) throw new Error("Attachment not found: " + attachmentId); + const bin = atob(a.content); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return bytes; + }; + + // Send completion after 2 seconds + const sendCompletion = () => { + window.parent.postMessage( + { + type: "execution-complete", + // @ts-ignore + logs: window.__artifactLogs || [], + artifactId, + }, + "*", + ); + }; + + if (document.readyState === "complete" || document.readyState === "interactive") { + setTimeout(sendCompletion, 2000); + } else { + window.addEventListener("load", () => { + setTimeout(sendCompletion, 2000); + }); + } + }; + + // Convert function to string and wrap in IIFE with parameters + const runtimeScript = ` + + `; + + // Inject at start of or start of document + const headMatch = htmlContent.match(/]*>/i); + if (headMatch) { + const index = headMatch.index! + headMatch[0].length; + return htmlContent.slice(0, index) + runtimeScript + htmlContent.slice(index); + } + + const htmlMatch = htmlContent.match(/]*>/i); + if (htmlMatch) { + const index = htmlMatch.index! + htmlMatch[0].length; + return htmlContent.slice(0, index) + runtimeScript + htmlContent.slice(index); + } + + return runtimeScript + htmlContent; + } + + private createIframe() { + this.iframe = document.createElement("iframe"); + this.iframe.sandbox.add("allow-scripts"); + this.iframe.sandbox.add("allow-modals"); + this.iframe.style.width = "100%"; + this.iframe.style.height = "100%"; + this.iframe.style.border = "none"; + + const isFirefox = typeof browser !== "undefined" && browser.runtime !== undefined; + if (isFirefox) { + this.iframe.src = browser.runtime.getURL("sandbox.html"); + } else { + this.iframe.src = chrome.runtime.getURL("sandbox.html"); + } + + this.appendChild(this.iframe); + } + + public updateContent(newContent: string) { + this.content = newContent; + // Clear logs for new content + this.logs = []; + // Recreate iframe for clean state + if (this.iframe) { + this.iframe.remove(); + this.iframe = undefined; + } + this.createIframe(); + } + + public getLogs(): Array<{ type: "log" | "error"; text: string }> { + return this.logs; + } +} diff --git a/packages/browser-extension/src/sandbox.js b/packages/browser-extension/src/sandbox.js index 763a304b..90ca7888 100644 --- a/packages/browser-extension/src/sandbox.js +++ b/packages/browser-extension/src/sandbox.js @@ -68,7 +68,7 @@ const originalConsole = { // Error handlers window.addEventListener("error", (e) => { - const text = e.message + " at line " + e.lineno + ":" + e.colno; + const text = (e.error?.stack || e.message || String(e)) + " at line " + (e.lineno || "?") + ":" + (e.colno || "?"); window.__artifactLogs.push({ type: "error", text }); window.parent.postMessage( { @@ -79,6 +79,7 @@ window.addEventListener("error", (e) => { }, "*", ); + return false; }); window.addEventListener("unhandledrejection", (e) => { @@ -154,7 +155,7 @@ window.addEventListener("message", (event) => { "});\n\n" + "// Error handlers\n" + "window.addEventListener('error', (e) => {\n" + - " const text = e.message + ' at line ' + e.lineno + ':' + e.colno;\n" + + " const text = (e.error?.stack || e.message || String(e)) + ' at line ' + (e.lineno || '?') + ':' + (e.colno || '?');\n" + " window.__artifactLogs.push({ type: 'error', text });\n" + " window.parent.postMessage({\n" + " type: 'console',\n" + @@ -162,6 +163,7 @@ window.addEventListener("message", (event) => { " text,\n" + " artifactId: window.__currentArtifactId\n" + " }, '*');\n" + + " return false;\n" + "});\n\n" + "window.addEventListener('unhandledrejection', (e) => {\n" + " const text = 'Unhandled promise rejection: ' + (e.reason?.message || e.reason || 'Unknown error');\n" + @@ -173,8 +175,11 @@ window.addEventListener("message", (event) => { " artifactId: window.__currentArtifactId\n" + " }, '*');\n" + "});\n\n" + - "// Send completion when ready\n" + + "// Send completion after 2 seconds to collect all logs and errors\n" + + "let completionSent = false;\n" + "const sendCompletion = function() {\n" + + " if (completionSent) return;\n" + + " completionSent = true;\n" + " window.parent.postMessage({\n" + " type: 'execution-complete',\n" + " logs: window.__artifactLogs || [],\n" + @@ -182,10 +187,10 @@ window.addEventListener("message", (event) => { " }, '*');\n" + "};\n\n" + "if (document.readyState === 'complete' || document.readyState === 'interactive') {\n" + - " setTimeout(sendCompletion, 0);\n" + + " setTimeout(sendCompletion, 2000);\n" + "} else {\n" + - " window.addEventListener('DOMContentLoaded', function() {\n" + - " setTimeout(sendCompletion, 0);\n" + + " window.addEventListener('load', function() {\n" + + " setTimeout(sendCompletion, 2000);\n" + " });\n" + "}\n" + " = []; // Refs for DOM elements - private iframeContainerRef: Ref = createRef(); + private sandboxIframeRef: Ref = createRef(); private consoleLogsRef: Ref = createRef(); private consoleButtonRef: Ref = createRef(); @@ -55,16 +56,16 @@ export class HtmlArtifact extends ArtifactElement { const oldValue = this._content; this._content = value; if (oldValue !== value) { - // Delay to ensure component is rendered - requestAnimationFrame(async () => { - this.requestUpdate(); - await this.updateComplete; - this.updateIframe(); - // Ensure iframe gets attached - requestAnimationFrame(() => { - this.attachIframeToContainer(); - }); - }); + this.requestUpdate(); + // Update sandbox iframe if it exists + if (this.sandboxIframeRef.value) { + this.logs = []; + if (this.consoleLogsRef.value) { + this.consoleLogsRef.value.innerHTML = ""; + } + this.updateConsoleButton(); + this.sandboxIframeRef.value.updateContent(value); + } } } @@ -72,60 +73,14 @@ export class HtmlArtifact extends ArtifactElement { return this._content; } - override connectedCallback() { - super.connectedCallback(); - window.addEventListener("message", this.handleMessage); - window.addEventListener("message", this.sandboxReadyHandler); - } + private handleConsoleEvent = (e: CustomEvent) => { + this.addLog(e.detail); + }; - protected override firstUpdated() { - // Create iframe if we have content after first render - if (this._content) { - this.updateIframe(); - // Ensure iframe is attached after render completes - requestAnimationFrame(() => { - this.attachIframeToContainer(); - }); - } - } - - protected override updated() { - // Always try to attach iframe if it exists but isn't in DOM - if (this.iframe && !this.iframe.parentElement) { - this.attachIframeToContainer(); - } - } - - override disconnectedCallback() { - super.disconnectedCallback(); - window.removeEventListener("message", this.handleMessage); - window.removeEventListener("message", this.sandboxReadyHandler); - this.iframe?.remove(); - this.iframe = undefined; - } - - private handleMessage = (e: MessageEvent) => { - // Only handle messages for this artifact - if (e.data.artifactId !== this.filename) return; - - if (e.data.type === "console") { - this.addLog({ - type: e.data.method === "error" ? "error" : "log", - text: e.data.text, - }); - } else if (e.data.type === "execution-complete") { - // Store final logs - this.logs = e.data.logs || []; - this.updateConsoleButton(); - - // Force reflow when iframe content is ready - // This fixes the 0x0 size issue on initial load - if (this.iframe) { - this.iframe.style.display = "none"; - this.iframe.offsetHeight; // Force reflow - this.iframe.style.display = ""; - } - } + private handleExecutionComplete = (e: CustomEvent) => { + // Store final logs + this.logs = e.detail.logs || []; + this.updateConsoleButton(); }; private addLog(log: { type: "log" | "error"; text: string }) { @@ -155,56 +110,6 @@ export class HtmlArtifact extends ArtifactElement { button.innerHTML = `${text}${this.consoleOpen ? "▼" : "▶"}`; } - private updateIframe() { - // Clear logs for new content - this.logs = []; - if (this.consoleLogsRef.value) { - this.consoleLogsRef.value.innerHTML = ""; - } - this.updateConsoleButton(); - - // Remove and recreate iframe for clean state - if (this.iframe) { - this.iframe.remove(); - this.iframe = undefined; - } - this.createIframe(); - } - - private sandboxReadyHandler = (e: MessageEvent) => { - if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) { - // Sandbox is ready, send content - this.iframe?.contentWindow?.postMessage( - { - type: "loadContent", - content: this._content, - artifactId: this.filename, - attachments: this.attachments, - }, - "*", - ); - } - }; - - private createIframe() { - this.iframe = document.createElement("iframe"); - this.iframe.sandbox.add("allow-scripts"); - this.iframe.sandbox.add("allow-modals"); // Allow alert, confirm, prompt - this.iframe.className = "w-full h-full border-0"; - this.iframe.title = this.displayTitle || this.filename; - this.iframe.src = chrome.runtime.getURL("sandbox.html"); - this.attachIframeToContainer(); - } - - private attachIframeToContainer() { - if (!this.iframe || !this.iframeContainerRef.value) return; - - // Only append if not already in the container - if (this.iframe.parentElement !== this.iframeContainerRef.value) { - this.iframeContainerRef.value.appendChild(this.iframe); - } - } - private toggleConsole() { this.consoleOpen = !this.consoleOpen; this.requestUpdate(); @@ -237,7 +142,15 @@ export class HtmlArtifact extends ArtifactElement {
-
+ ${ this.logs.length > 0 ? html`