co-mono/packages/web-ui/src/components/SandboxedIframe.ts
2026-01-28 01:56:34 +00:00

626 lines
19 KiB
TypeScript

import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
import { RuntimeMessageBridge } from "./sandbox/RuntimeMessageBridge.js";
import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "./sandbox/RuntimeMessageRouter.js";
import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
export interface SandboxFile {
fileName: string;
content: string | Uint8Array;
mimeType: string;
}
export interface SandboxResult {
success: boolean;
console: Array<{ type: string; text: string }>;
files?: SandboxFile[];
error?: { message: string; stack: string };
returnValue?: any;
}
/**
* Function that returns the URL to the sandbox HTML file.
* Used in browser extensions to load sandbox.html via chrome.runtime.getURL().
*/
export type SandboxUrlProvider = () => string;
/**
* Configuration for prepareHtmlDocument
*/
export interface PrepareHtmlOptions {
/** True if this is an HTML artifact (inject into existing HTML), false if REPL (wrap in HTML) */
isHtmlArtifact: boolean;
/** True if this is a standalone download (no runtime bridge, no navigation interceptor) */
isStandalone?: boolean;
}
/**
* Escape HTML special sequences in code to prevent premature tag closure
* @param code Code that will be injected into <script> tags
* @returns Escaped code safe for injection
*/
function escapeScriptContent(code: string): string {
return code.replace(/<\/script/gi, "<\\/script");
}
@customElement("sandbox-iframe")
export class SandboxIframe extends LitElement {
private iframe?: HTMLIFrameElement;
/**
* Optional: Provide a function that returns the sandbox HTML URL.
* If provided, the iframe will use this URL instead of srcdoc.
* This is required for browser extensions with strict CSP.
*/
@property({ attribute: false }) sandboxUrlProvider?: SandboxUrlProvider;
createRenderRoot() {
return this;
}
override connectedCallback() {
super.connectedCallback();
}
override disconnectedCallback() {
super.disconnectedCallback();
// Note: We don't unregister the sandbox here for loadContent() mode
// because the caller (HtmlArtifact) owns the sandbox lifecycle.
// For execute() mode, the sandbox is unregistered in the cleanup function.
this.iframe?.remove();
}
/**
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
* @param sandboxId Unique ID
* @param htmlContent Full HTML content
* @param providers Runtime providers to inject
* @param consumers Message consumers to register (optional)
*/
public loadContent(
sandboxId: string,
htmlContent: string,
providers: SandboxRuntimeProvider[] = [],
consumers: MessageConsumer[] = [],
): void {
// Unregister previous sandbox if exists
try {
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
} catch {
// Sandbox might not exist, that's ok
}
providers = [new ConsoleRuntimeProvider(), ...providers];
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
// loadContent is always used for HTML artifacts (not standalone)
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers, {
isHtmlArtifact: true,
isStandalone: false,
});
// Validate HTML before loading
const validationError = this.validateHtml(completeHtml);
if (validationError) {
console.error("HTML validation failed:", validationError);
// Show error in iframe instead of crashing
this.iframe?.remove();
this.iframe = document.createElement("iframe");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.srcdoc = `
<html>
<body style="font-family: monospace; padding: 20px; background: #fff; color: #000;">
<h3 style="color: #c00;">HTML Validation Error</h3>
<pre style="background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; white-space: pre-wrap;">${validationError}</pre>
</body>
</html>
`;
this.appendChild(this.iframe);
return;
}
// Remove previous iframe if exists
this.iframe?.remove();
if (this.sandboxUrlProvider) {
// Browser extension mode: use sandbox.html with postMessage
this.loadViaSandboxUrl(sandboxId, completeHtml);
} else {
// Web mode: use srcdoc
this.loadViaSrcdoc(sandboxId, completeHtml);
}
}
private loadViaSandboxUrl(sandboxId: string, completeHtml: string): void {
// Create iframe pointing to sandbox URL
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";
this.iframe.src = this.sandboxUrlProvider!();
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for open-external-url messages from iframe
const externalUrlHandler = (e: MessageEvent) => {
if (e.data.type === "open-external-url" && e.source === this.iframe?.contentWindow) {
// Use chrome.tabs API to open in new tab
const chromeAPI = (globalThis as any).chrome;
if (chromeAPI?.tabs) {
chromeAPI.tabs.create({ url: e.data.url });
} else {
// Fallback for non-extension context
window.open(e.data.url, "_blank");
}
}
};
window.addEventListener("message", externalUrlHandler);
// Listen for sandbox-ready and sandbox-error messages directly
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
},
"*",
);
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// The sandbox.js already sent us the error via postMessage.
// We need to convert it to an execution-error message that the execute() consumer will handle.
// Simulate receiving an execution-error from the sandbox
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
}
private loadViaSrcdoc(sandboxId: string, completeHtml: string): void {
// Create iframe with srcdoc
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";
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for open-external-url messages from iframe
const externalUrlHandler = (e: MessageEvent) => {
if (e.data.type === "open-external-url" && e.source === this.iframe?.contentWindow) {
// Fallback for non-extension context
window.open(e.data.url, "_blank");
}
};
window.addEventListener("message", externalUrlHandler);
this.appendChild(this.iframe);
}
/**
* Execute code in sandbox
* @param sandboxId Unique ID for this execution
* @param code User code (plain JS for REPL, or full HTML for artifacts)
* @param providers Runtime providers to inject
* @param consumers Additional message consumers (optional, execute has its own internal consumer)
* @param signal Abort signal
* @returns Promise resolving to execution result
*/
public async execute(
sandboxId: string,
code: string,
providers: SandboxRuntimeProvider[] = [],
consumers: MessageConsumer[] = [],
signal?: AbortSignal,
isHtmlArtifact: boolean = false,
): Promise<SandboxResult> {
if (signal?.aborted) {
throw new Error("Execution aborted");
}
const consoleProvider = new ConsoleRuntimeProvider();
providers = [consoleProvider, ...providers];
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
// Notify providers that execution is starting
for (const provider of providers) {
provider.onExecutionStart?.(sandboxId, signal);
}
const files: SandboxFile[] = [];
let completed = false;
return new Promise((resolve, reject) => {
// 4. Create execution consumer for lifecycle messages
const executionConsumer: MessageConsumer = {
async handleMessage(message: any): Promise<void> {
if (message.type === "file-returned") {
files.push({
fileName: message.fileName,
content: message.content,
mimeType: message.mimeType,
});
} else if (message.type === "execution-complete") {
completed = true;
cleanup();
resolve({
success: true,
console: consoleProvider.getLogs(),
files,
returnValue: message.returnValue,
});
} else if (message.type === "execution-error") {
completed = true;
cleanup();
resolve({ success: false, console: consoleProvider.getLogs(), error: message.error, files });
}
},
};
RUNTIME_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
const cleanup = () => {
// Notify providers that execution has ended
for (const provider of providers) {
provider.onExecutionEnd?.(sandboxId);
}
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
signal?.removeEventListener("abort", abortHandler);
clearTimeout(timeoutId);
this.iframe?.remove();
this.iframe = undefined;
};
// Abort handler
const abortHandler = () => {
if (!completed) {
completed = true;
cleanup();
reject(new Error("Execution aborted"));
}
};
if (signal) {
signal.addEventListener("abort", abortHandler);
}
// Timeout handler (30 seconds)
const timeoutId = setTimeout(() => {
if (!completed) {
completed = true;
cleanup();
resolve({
success: false,
console: consoleProvider.getLogs(),
error: { message: "Execution timeout (120s)", stack: "" },
files,
});
}
}, 120000);
// 4. Prepare HTML and create iframe
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers, {
isHtmlArtifact,
isStandalone: false,
});
// 5. Validate HTML before sending to sandbox
const validationError = this.validateHtml(completeHtml);
if (validationError) {
reject(new Error(`HTML validation failed: ${validationError}`));
return;
}
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts", "allow-modals");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none;";
this.iframe.src = this.sandboxUrlProvider();
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready and sandbox-error messages
const readyHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Send content to sandbox
this.iframe?.contentWindow?.postMessage(
{
type: "sandbox-load",
sandboxId,
code: completeHtml,
},
"*",
);
}
};
const errorHandler = (e: MessageEvent) => {
if (e.data.type === "sandbox-error" && e.source === this.iframe?.contentWindow) {
window.removeEventListener("message", readyHandler);
window.removeEventListener("message", errorHandler);
// Convert sandbox-error to execution-error for the execution consumer
window.postMessage(
{
sandboxId: sandboxId,
type: "execution-error",
error: { message: e.data.error, stack: e.data.stack },
},
"*",
);
}
};
window.addEventListener("message", readyHandler);
window.addEventListener("message", errorHandler);
this.appendChild(this.iframe);
} else {
// Web mode: use srcdoc
this.iframe = document.createElement("iframe");
this.iframe.sandbox.add("allow-scripts", "allow-modals");
this.iframe.style.cssText = "width: 100%; height: 100%; border: none; display: none;";
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
this.appendChild(this.iframe);
}
});
}
/**
* Validate HTML using DOMParser - returns error message if invalid, null if valid
* Note: JavaScript syntax validation is done in sandbox.js to avoid CSP restrictions
*/
private validateHtml(html: string): string | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Check for parser errors
const parserError = doc.querySelector("parsererror");
if (parserError) {
return parserError.textContent || "Unknown parse error";
}
return null;
} catch (error: any) {
return error.message || "Unknown validation error";
}
}
/**
* Prepare complete HTML document with runtime + user code
* PUBLIC so HtmlArtifact can use it for download button
*/
public prepareHtmlDocument(
sandboxId: string,
userCode: string,
providers: SandboxRuntimeProvider[] = [],
options?: PrepareHtmlOptions,
): string {
// Default options
const opts: PrepareHtmlOptions = {
isHtmlArtifact: false,
isStandalone: false,
...options,
};
// Runtime script that will be injected
const runtime = this.getRuntimeScript(sandboxId, providers, opts.isStandalone || false);
// Only check for HTML tags if explicitly marked as HTML artifact
// For javascript_repl, userCode is JavaScript that may contain HTML in string literals
if (opts.isHtmlArtifact) {
// HTML Artifact - inject runtime into existing HTML
const headMatch = userCode.match(/<head[^>]*>/i);
if (headMatch) {
const index = headMatch.index! + headMatch[0].length;
return userCode.slice(0, index) + runtime + userCode.slice(index);
}
const htmlMatch = userCode.match(/<html[^>]*>/i);
if (htmlMatch) {
const index = htmlMatch.index! + htmlMatch[0].length;
return userCode.slice(0, index) + runtime + userCode.slice(index);
}
// Fallback: prepend runtime
return runtime + userCode;
} else {
// REPL - wrap code in HTML with runtime and call complete() when done
// Escape </script> in user code to prevent premature tag closure
const escapedUserCode = escapeScriptContent(userCode);
return `<!DOCTYPE html>
<html>
<head>
${runtime}
</head>
<body>
<script type="module">
(async () => {
try {
// Wrap user code in async function to capture return value
const userCodeFunc = async () => {
${escapedUserCode}
};
const returnValue = await userCodeFunc();
// Call completion callbacks before complete()
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
try {
await Promise.all(window.__completionCallbacks.map(cb => cb(true)));
} catch (e) {
console.error('Completion callback error:', e);
}
}
await window.complete(null, returnValue);
} catch (error) {
// Call completion callbacks before complete() (error path)
if (window.__completionCallbacks && window.__completionCallbacks.length > 0) {
try {
await Promise.all(window.__completionCallbacks.map(cb => cb(false)));
} catch (e) {
console.error('Completion callback error:', e);
}
}
await window.complete({
message: error?.message || String(error),
stack: error?.stack || new Error().stack
});
}
})();
</script>
</body>
</html>`;
}
}
/**
* Generate runtime script from providers
* @param sandboxId Unique sandbox ID
* @param providers Runtime providers
* @param isStandalone If true, skip runtime bridge and navigation interceptor (for standalone downloads)
*/
private getRuntimeScript(
sandboxId: string,
providers: SandboxRuntimeProvider[] = [],
isStandalone: boolean = false,
): string {
// Collect all data from providers
const allData: Record<string, any> = {};
for (const provider of providers) {
Object.assign(allData, provider.getData());
}
// Generate bridge code (skip if standalone)
const bridgeCode = isStandalone
? ""
: RuntimeMessageBridge.generateBridgeCode({
context: "sandbox-iframe",
sandboxId,
});
// Collect all runtime functions - pass sandboxId as string literal
const runtimeFunctions: string[] = [];
for (const provider of providers) {
runtimeFunctions.push(`(${provider.getRuntime().toString()})(${JSON.stringify(sandboxId)});`);
}
// Build script with HTML escaping
// Escape </script> to prevent premature tag closure in HTML parser
const dataInjection = Object.entries(allData)
.map(([key, value]) => {
const jsonStr = JSON.stringify(value).replace(/<\/script/gi, "<\\/script");
return `window.${key} = ${jsonStr};`;
})
.join("\n");
// TODO the font-size is needed, as chrome seems to inject a stylesheet into iframes
// found in an extension context like sidepanel, setting body { font-size: 75% }. It's
// definitely not our code doing that.
// See https://stackoverflow.com/questions/71480433/chrome-is-injecting-some-stylesheet-in-popup-ui-which-reduces-the-font-size-to-7
// Navigation interceptor (only if NOT standalone)
const navigationInterceptor = isStandalone
? ""
: `
// Navigation interceptor: prevent all navigation and open externally
(function() {
// Intercept link clicks
document.addEventListener('click', function(e) {
const link = e.target.closest('a');
if (link && link.href) {
// Check if it's an external link (not javascript: or #hash)
if (link.href.startsWith('http://') || link.href.startsWith('https://')) {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'open-external-url', url: link.href }, '*');
}
}
}, true);
// Intercept form submissions
document.addEventListener('submit', function(e) {
const form = e.target;
if (form && form.action) {
e.preventDefault();
e.stopPropagation();
window.parent.postMessage({ type: 'open-external-url', url: form.action }, '*');
}
}, true);
// Prevent window.location changes (only if not already redefined)
try {
const originalLocation = window.location;
Object.defineProperty(window, 'location', {
get: function() { return originalLocation; },
set: function(url) {
window.parent.postMessage({ type: 'open-external-url', url: url.toString() }, '*');
}
});
} catch (e) {
// Already defined, skip
}
})();
`;
return `<style>
html, body {
font-size: initial;
}
</style>
<script>
window.sandboxId = ${JSON.stringify(sandboxId)};
${dataInjection}
${bridgeCode}
${runtimeFunctions.join("\n")}
${navigationInterceptor}
</script>`;
}
}