mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 13:04:08 +00:00
Fix various sandbox issues.
This commit is contained in:
parent
91c1dc6475
commit
0eaa879d46
10 changed files with 673 additions and 431 deletions
|
|
@ -1,6 +1,8 @@
|
|||
import { LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import type { Attachment } from "../utils/attachment-utils.js";
|
||||
import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
|
||||
import { type MessageConsumer, SANDBOX_MESSAGE_ROUTER } from "./sandbox/SandboxMessageRouter.js";
|
||||
import type { SandboxRuntimeProvider } from "./sandbox/SandboxRuntimeProvider.js";
|
||||
|
||||
export interface SandboxFile {
|
||||
fileName: string;
|
||||
|
|
@ -42,6 +44,9 @@ export class SandboxIframe extends LitElement {
|
|||
|
||||
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();
|
||||
}
|
||||
|
||||
|
|
@ -49,65 +54,88 @@ export class SandboxIframe extends LitElement {
|
|||
* Load HTML content into sandbox and keep it displayed (for HTML artifacts)
|
||||
* @param sandboxId Unique ID
|
||||
* @param htmlContent Full HTML content
|
||||
* @param attachments Attachments available
|
||||
* @param providers Runtime providers to inject
|
||||
* @param consumers Message consumers to register (optional)
|
||||
*/
|
||||
public loadContent(sandboxId: string, htmlContent: string, attachments: Attachment[]): void {
|
||||
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, attachments);
|
||||
public loadContent(
|
||||
sandboxId: string,
|
||||
htmlContent: string,
|
||||
providers: SandboxRuntimeProvider[] = [],
|
||||
consumers: MessageConsumer[] = [],
|
||||
): void {
|
||||
// Unregister previous sandbox if exists
|
||||
try {
|
||||
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
|
||||
} catch {
|
||||
// Sandbox might not exist, that's ok
|
||||
}
|
||||
|
||||
providers = [new ConsoleRuntimeProvider(), ...providers];
|
||||
|
||||
SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
|
||||
|
||||
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers);
|
||||
|
||||
// Remove previous iframe if exists
|
||||
this.iframe?.remove();
|
||||
|
||||
if (this.sandboxUrlProvider) {
|
||||
// Browser extension mode: use sandbox.html with postMessage
|
||||
this.loadViaSandboxUrl(sandboxId, completeHtml, attachments);
|
||||
this.loadViaSandboxUrl(sandboxId, completeHtml);
|
||||
} else {
|
||||
// Web mode: use srcdoc
|
||||
this.loadViaSrcdoc(completeHtml);
|
||||
this.loadViaSrcdoc(sandboxId, completeHtml);
|
||||
}
|
||||
}
|
||||
|
||||
private loadViaSandboxUrl(sandboxId: string, completeHtml: string, attachments: Attachment[]): void {
|
||||
// Wait for sandbox-ready and send content
|
||||
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
|
||||
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
// Listen for sandbox-ready message directly
|
||||
const readyHandler = (e: MessageEvent) => {
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
|
||||
// Send content to sandbox
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "sandbox-load",
|
||||
sandboxId,
|
||||
code: completeHtml,
|
||||
attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", readyHandler);
|
||||
|
||||
// Always recreate iframe to ensure fresh sandbox and sandbox-ready message
|
||||
this.iframe?.remove();
|
||||
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!();
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
private loadViaSrcdoc(completeHtml: string): void {
|
||||
// Always recreate iframe to ensure fresh sandbox
|
||||
this.iframe?.remove();
|
||||
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";
|
||||
|
||||
// Set content directly via srcdoc (no CSP restrictions in web apps)
|
||||
this.iframe.srcdoc = completeHtml;
|
||||
|
||||
// Update router with iframe reference BEFORE appending to DOM
|
||||
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
|
||||
|
|
@ -115,143 +143,141 @@ export class SandboxIframe extends LitElement {
|
|||
* 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 attachments Attachments available to the code
|
||||
* @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,
|
||||
attachments: Attachment[],
|
||||
providers: SandboxRuntimeProvider[] = [],
|
||||
consumers: MessageConsumer[] = [],
|
||||
signal?: AbortSignal,
|
||||
): Promise<SandboxResult> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("Execution aborted");
|
||||
}
|
||||
|
||||
// Prepare the complete HTML document with runtime + user code
|
||||
const completeHtml = this.prepareHtmlDocument(sandboxId, code, attachments);
|
||||
providers = [new ConsoleRuntimeProvider(), ...providers];
|
||||
SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
|
||||
|
||||
const logs: Array<{ type: string; text: string }> = [];
|
||||
const files: SandboxFile[] = [];
|
||||
let completed = false;
|
||||
|
||||
// Wait for execution to complete
|
||||
return new Promise((resolve, reject) => {
|
||||
const logs: Array<{ type: string; text: string }> = [];
|
||||
const files: SandboxFile[] = [];
|
||||
let completed = false;
|
||||
|
||||
const messageHandler = (e: MessageEvent) => {
|
||||
// Ignore messages not for this sandbox
|
||||
if (e.data.sandboxId !== sandboxId) return;
|
||||
|
||||
if (e.data.type === "console") {
|
||||
logs.push({
|
||||
type: e.data.method === "error" ? "error" : "log",
|
||||
text: e.data.text,
|
||||
});
|
||||
} else if (e.data.type === "file-returned") {
|
||||
files.push({
|
||||
fileName: e.data.fileName,
|
||||
content: e.data.content,
|
||||
mimeType: e.data.mimeType,
|
||||
});
|
||||
} else if (e.data.type === "execution-complete") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: true,
|
||||
console: logs,
|
||||
files: files,
|
||||
});
|
||||
} else if (e.data.type === "execution-error") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: false,
|
||||
console: logs,
|
||||
error: e.data.error,
|
||||
files,
|
||||
});
|
||||
}
|
||||
// 4. Create execution consumer for lifecycle messages
|
||||
const executionConsumer: MessageConsumer = {
|
||||
handleMessage(message: any): boolean {
|
||||
if (message.type === "console") {
|
||||
logs.push({
|
||||
type: message.method === "error" ? "error" : "log",
|
||||
text: message.text,
|
||||
});
|
||||
return true;
|
||||
} else if (message.type === "file-returned") {
|
||||
files.push({
|
||||
fileName: message.fileName,
|
||||
content: message.content,
|
||||
mimeType: message.mimeType,
|
||||
});
|
||||
return true;
|
||||
} else if (message.type === "execution-complete") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({ success: true, console: logs, files });
|
||||
return true;
|
||||
} else if (message.type === "execution-error") {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({ success: false, console: logs, error: message.error, files });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
SANDBOX_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
|
||||
|
||||
const cleanup = () => {
|
||||
SANDBOX_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"));
|
||||
}
|
||||
};
|
||||
|
||||
let readyHandler: ((e: MessageEvent) => void) | undefined;
|
||||
if (signal) {
|
||||
signal.addEventListener("abort", abortHandler);
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
window.removeEventListener("message", messageHandler);
|
||||
signal?.removeEventListener("abort", abortHandler);
|
||||
if (readyHandler) {
|
||||
window.removeEventListener("message", readyHandler);
|
||||
}
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
|
||||
// Set up listeners BEFORE creating iframe
|
||||
window.addEventListener("message", messageHandler);
|
||||
signal?.addEventListener("abort", abortHandler);
|
||||
|
||||
// Timeout after 30 seconds
|
||||
// Timeout handler (30 seconds)
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!completed) {
|
||||
completed = true;
|
||||
cleanup();
|
||||
resolve({
|
||||
success: false,
|
||||
error: { message: "Execution timeout (30s)", stack: "" },
|
||||
console: logs,
|
||||
error: { message: "Execution timeout (30s)", stack: "" },
|
||||
files,
|
||||
});
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// 4. Prepare HTML and create iframe
|
||||
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers);
|
||||
|
||||
if (this.sandboxUrlProvider) {
|
||||
// Browser extension mode: wait for sandbox-ready and send content
|
||||
readyHandler = (e: MessageEvent) => {
|
||||
// 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
|
||||
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
// Listen for sandbox-ready message directly
|
||||
const readyHandler = (e: MessageEvent) => {
|
||||
if (e.data.type === "sandbox-ready" && e.source === this.iframe?.contentWindow) {
|
||||
window.removeEventListener("message", readyHandler!);
|
||||
// Send the complete HTML
|
||||
window.removeEventListener("message", readyHandler);
|
||||
|
||||
// Send content to sandbox
|
||||
this.iframe?.contentWindow?.postMessage(
|
||||
{
|
||||
type: "sandbox-load",
|
||||
sandboxId,
|
||||
code: completeHtml,
|
||||
attachments,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", readyHandler);
|
||||
|
||||
// Create iframe AFTER all listeners are set up
|
||||
this.iframe?.remove();
|
||||
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();
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
} else {
|
||||
// Web mode: use srcdoc
|
||||
this.iframe?.remove();
|
||||
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";
|
||||
|
||||
// Set content via srcdoc BEFORE appending to DOM
|
||||
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
|
||||
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
|
||||
|
||||
this.appendChild(this.iframe);
|
||||
}
|
||||
});
|
||||
|
|
@ -259,10 +285,11 @@ export class SandboxIframe extends LitElement {
|
|||
|
||||
/**
|
||||
* Prepare complete HTML document with runtime + user code
|
||||
* PUBLIC so HtmlArtifact can use it for download button
|
||||
*/
|
||||
private prepareHtmlDocument(sandboxId: string, userCode: string, attachments: Attachment[]): string {
|
||||
public prepareHtmlDocument(sandboxId: string, userCode: string, providers: SandboxRuntimeProvider[] = []): string {
|
||||
// Runtime script that will be injected
|
||||
const runtime = this.getRuntimeScript(sandboxId, attachments);
|
||||
const runtime = this.getRuntimeScript(sandboxId, providers);
|
||||
|
||||
// Check if user provided full HTML
|
||||
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
|
||||
|
|
@ -311,215 +338,30 @@ export class SandboxIframe extends LitElement {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the runtime script that captures console, provides helpers, etc.
|
||||
* Generate runtime script from providers
|
||||
*/
|
||||
private getRuntimeScript(sandboxId: string, attachments: Attachment[]): string {
|
||||
// Convert attachments to serializable format
|
||||
const attachmentsData = attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
content: a.content,
|
||||
extractedText: a.extractedText,
|
||||
}));
|
||||
private getRuntimeScript(sandboxId: string, providers: SandboxRuntimeProvider[] = []): string {
|
||||
// Collect all data from providers
|
||||
const allData: Record<string, any> = {};
|
||||
for (const provider of providers) {
|
||||
Object.assign(allData, provider.getData());
|
||||
}
|
||||
|
||||
// Runtime function that will run in the sandbox (NO parameters - values injected before function)
|
||||
const runtimeFunc = () => {
|
||||
// Helper functions
|
||||
(window as any).listFiles = () =>
|
||||
(attachments || []).map((a: any) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
// 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)});`);
|
||||
}
|
||||
|
||||
(window as any).readTextFile = (attachmentId: string) => {
|
||||
const a = (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);
|
||||
}
|
||||
};
|
||||
// Build script
|
||||
const dataInjection = Object.entries(allData)
|
||||
.map(([key, value]) => `window.${key} = ${JSON.stringify(value)};`)
|
||||
.join("\n");
|
||||
|
||||
(window as any).readBinaryFile = (attachmentId: string) => {
|
||||
const a = (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;
|
||||
};
|
||||
|
||||
(window as any).returnFile = async (fileName: string, content: any, mimeType?: string) => {
|
||||
let finalContent: any, finalMimeType: string;
|
||||
|
||||
if (content instanceof Blob) {
|
||||
const arrayBuffer = await content.arrayBuffer();
|
||||
finalContent = new Uint8Array(arrayBuffer);
|
||||
finalMimeType = mimeType || content.type || "application/octet-stream";
|
||||
if (!mimeType && !content.type) {
|
||||
throw new Error(
|
||||
"returnFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
} else if (content instanceof Uint8Array) {
|
||||
finalContent = content;
|
||||
if (!mimeType) {
|
||||
throw new Error(
|
||||
"returnFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
finalMimeType = mimeType;
|
||||
} else if (typeof content === "string") {
|
||||
finalContent = content;
|
||||
finalMimeType = mimeType || "text/plain";
|
||||
} else {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
}
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "file-returned",
|
||||
sandboxId,
|
||||
fileName,
|
||||
content: finalContent,
|
||||
mimeType: finalMimeType,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
|
||||
// Console capture
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
};
|
||||
|
||||
["log", "error", "warn", "info"].forEach((method) => {
|
||||
(console as any)[method] = (...args: any[]) => {
|
||||
const text = args
|
||||
.map((arg) => {
|
||||
try {
|
||||
return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method,
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
(originalConsole as any)[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
// Track errors for HTML artifacts
|
||||
let lastError: { message: string; stack: string } | null = null;
|
||||
|
||||
// Error handlers
|
||||
window.addEventListener("error", (e) => {
|
||||
const text =
|
||||
(e.error?.stack || e.message || String(e)) + " at line " + (e.lineno || "?") + ":" + (e.colno || "?");
|
||||
|
||||
// Store the error
|
||||
lastError = {
|
||||
message: e.error?.message || e.message || String(e),
|
||||
stack: e.error?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
||||
|
||||
// Store the error
|
||||
lastError = {
|
||||
message: e.reason?.message || String(e.reason) || "Unhandled promise rejection",
|
||||
stack: e.reason?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
// Expose complete() method for user code to call
|
||||
let completionSent = false;
|
||||
(window as any).complete = (error?: { message: string; stack: string }) => {
|
||||
if (completionSent) return;
|
||||
completionSent = true;
|
||||
|
||||
// Use provided error or last caught error
|
||||
const finalError = error || lastError;
|
||||
|
||||
if (finalError) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-error",
|
||||
sandboxId,
|
||||
error: finalError,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-complete",
|
||||
sandboxId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback timeout for HTML artifacts that don't call complete()
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") {
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
} else {
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Prepend the const declarations, then the function
|
||||
return (
|
||||
`<script>\n` +
|
||||
`window.sandboxId = ${JSON.stringify(sandboxId)};\n` +
|
||||
`window.attachments = ${JSON.stringify(attachmentsData)};\n` +
|
||||
`(${runtimeFunc.toString()})();\n` +
|
||||
`</script>`
|
||||
);
|
||||
return `<script>
|
||||
window.sandboxId = ${JSON.stringify(sandboxId)};
|
||||
${dataInjection}
|
||||
${runtimeFunctions.join("\n")}
|
||||
</script>`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,99 @@
|
|||
import type { Attachment } from "../../utils/attachment-utils.js";
|
||||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
/**
|
||||
* Attachments Runtime Provider
|
||||
*
|
||||
* OPTIONAL provider that provides file access APIs to sandboxed code.
|
||||
* Only needed when attachments are present.
|
||||
*/
|
||||
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
|
||||
constructor(private attachments: Attachment[]) {}
|
||||
|
||||
getData(): Record<string, any> {
|
||||
const attachmentsData = this.attachments.map((a) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
content: a.content,
|
||||
extractedText: a.extractedText,
|
||||
}));
|
||||
|
||||
return { attachments: attachmentsData };
|
||||
}
|
||||
|
||||
getRuntime(): (sandboxId: string) => void {
|
||||
// This function will be stringified, so no external references!
|
||||
return (sandboxId: string) => {
|
||||
// Helper functions for attachments
|
||||
(window as any).listFiles = () =>
|
||||
((window as any).attachments || []).map((a: any) => ({
|
||||
id: a.id,
|
||||
fileName: a.fileName,
|
||||
mimeType: a.mimeType,
|
||||
size: a.size,
|
||||
}));
|
||||
|
||||
(window as any).readTextFile = (attachmentId: string) => {
|
||||
const a = ((window as any).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);
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).readBinaryFile = (attachmentId: string) => {
|
||||
const a = ((window as any).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;
|
||||
};
|
||||
|
||||
(window as any).returnFile = async (fileName: string, content: any, mimeType?: string) => {
|
||||
let finalContent: any, finalMimeType: string;
|
||||
|
||||
if (content instanceof Blob) {
|
||||
const arrayBuffer = await content.arrayBuffer();
|
||||
finalContent = new Uint8Array(arrayBuffer);
|
||||
finalMimeType = mimeType || content.type || "application/octet-stream";
|
||||
if (!mimeType && !content.type) {
|
||||
throw new Error(
|
||||
"returnFile: MIME type is required for Blob content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
} else if (content instanceof Uint8Array) {
|
||||
finalContent = content;
|
||||
if (!mimeType) {
|
||||
throw new Error(
|
||||
"returnFile: MIME type is required for Uint8Array content. Please provide a mimeType parameter (e.g., 'image/png').",
|
||||
);
|
||||
}
|
||||
finalMimeType = mimeType;
|
||||
} else if (typeof content === "string") {
|
||||
finalContent = content;
|
||||
finalMimeType = mimeType || "text/plain";
|
||||
} else {
|
||||
finalContent = JSON.stringify(content, null, 2);
|
||||
finalMimeType = mimeType || "application/json";
|
||||
}
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "file-returned",
|
||||
sandboxId,
|
||||
fileName,
|
||||
content: finalContent,
|
||||
mimeType: finalMimeType,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
132
packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts
Normal file
132
packages/web-ui/src/components/sandbox/ConsoleRuntimeProvider.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
/**
|
||||
* Console Runtime Provider
|
||||
*
|
||||
* REQUIRED provider that should always be included first.
|
||||
* Provides console capture, error handling, and execution lifecycle management.
|
||||
*/
|
||||
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
|
||||
getData(): Record<string, any> {
|
||||
// No data needed
|
||||
return {};
|
||||
}
|
||||
|
||||
getRuntime(): (sandboxId: string) => void {
|
||||
return (sandboxId: string) => {
|
||||
// Console capture
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
info: console.info,
|
||||
};
|
||||
|
||||
["log", "error", "warn", "info"].forEach((method) => {
|
||||
(console as any)[method] = (...args: any[]) => {
|
||||
const text = args
|
||||
.map((arg) => {
|
||||
try {
|
||||
return typeof arg === "object" ? JSON.stringify(arg) : String(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method,
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
|
||||
(originalConsole as any)[method].apply(console, args);
|
||||
};
|
||||
});
|
||||
|
||||
// Track errors for HTML artifacts
|
||||
let lastError: { message: string; stack: string } | null = null;
|
||||
|
||||
// Error handlers
|
||||
window.addEventListener("error", (e) => {
|
||||
const text =
|
||||
(e.error?.stack || e.message || String(e)) + " at line " + (e.lineno || "?") + ":" + (e.colno || "?");
|
||||
|
||||
lastError = {
|
||||
message: e.error?.message || e.message || String(e),
|
||||
stack: e.error?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
const text = "Unhandled promise rejection: " + (e.reason?.message || e.reason || "Unknown error");
|
||||
|
||||
lastError = {
|
||||
message: e.reason?.message || String(e.reason) || "Unhandled promise rejection",
|
||||
stack: e.reason?.stack || text,
|
||||
};
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "console",
|
||||
sandboxId,
|
||||
method: "error",
|
||||
text,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
});
|
||||
|
||||
// Expose complete() method for user code to call
|
||||
let completionSent = false;
|
||||
(window as any).complete = (error?: { message: string; stack: string }) => {
|
||||
if (completionSent) return;
|
||||
completionSent = true;
|
||||
|
||||
const finalError = error || lastError;
|
||||
|
||||
if (finalError) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-error",
|
||||
sandboxId,
|
||||
error: finalError,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
} else {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: "execution-complete",
|
||||
sandboxId,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback timeout for HTML artifacts that don't call complete()
|
||||
if (document.readyState === "complete" || document.readyState === "interactive") {
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
} else {
|
||||
window.addEventListener("load", () => {
|
||||
setTimeout(() => (window as any).complete(), 2000);
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
152
packages/web-ui/src/components/sandbox/SandboxMessageRouter.ts
Normal file
152
packages/web-ui/src/components/sandbox/SandboxMessageRouter.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
|
||||
|
||||
/**
|
||||
* Message consumer interface - components that want to receive messages from sandboxes
|
||||
*/
|
||||
export interface MessageConsumer {
|
||||
/**
|
||||
* Handle a message from a sandbox.
|
||||
* @returns true if message was consumed (stops propagation), false otherwise
|
||||
*/
|
||||
handleMessage(message: any): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sandbox context - tracks active sandboxes and their consumers
|
||||
*/
|
||||
interface SandboxContext {
|
||||
sandboxId: string;
|
||||
iframe: HTMLIFrameElement | null; // null until setSandboxIframe()
|
||||
providers: SandboxRuntimeProvider[];
|
||||
consumers: Set<MessageConsumer>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralized message router for all sandbox communication.
|
||||
*
|
||||
* This singleton replaces all individual window.addEventListener("message") calls
|
||||
* with a single global listener that routes messages to the appropriate handlers.
|
||||
*
|
||||
* Benefits:
|
||||
* - Single global listener instead of multiple independent listeners
|
||||
* - Automatic cleanup when sandboxes are destroyed
|
||||
* - Support for bidirectional communication (providers) and broadcasting (consumers)
|
||||
* - Clear lifecycle management
|
||||
*/
|
||||
export class SandboxMessageRouter {
|
||||
private sandboxes = new Map<string, SandboxContext>();
|
||||
private messageListener: ((e: MessageEvent) => void) | null = null;
|
||||
|
||||
/**
|
||||
* Register a new sandbox with its runtime providers.
|
||||
* Call this BEFORE creating the iframe.
|
||||
*/
|
||||
registerSandbox(sandboxId: string, providers: SandboxRuntimeProvider[], consumers: MessageConsumer[]): void {
|
||||
this.sandboxes.set(sandboxId, {
|
||||
sandboxId,
|
||||
iframe: null, // Will be set via setSandboxIframe()
|
||||
providers,
|
||||
consumers: new Set(consumers),
|
||||
});
|
||||
|
||||
// Setup global listener if not already done
|
||||
this.setupListener();
|
||||
console.log("Registered sandbox:", sandboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the iframe reference for a sandbox.
|
||||
* Call this AFTER creating the iframe.
|
||||
* This is needed so providers can send responses back to the sandbox.
|
||||
*/
|
||||
setSandboxIframe(sandboxId: string, iframe: HTMLIFrameElement): void {
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (context) {
|
||||
context.iframe = iframe;
|
||||
}
|
||||
console.log("Set iframe for sandbox:", sandboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister a sandbox and remove all its consumers.
|
||||
* Call this when the sandbox is destroyed.
|
||||
*/
|
||||
unregisterSandbox(sandboxId: string): void {
|
||||
this.sandboxes.delete(sandboxId);
|
||||
|
||||
// If no more sandboxes, remove global listener
|
||||
if (this.sandboxes.size === 0 && this.messageListener) {
|
||||
window.removeEventListener("message", this.messageListener);
|
||||
this.messageListener = null;
|
||||
}
|
||||
console.log("Unregistered sandbox:", sandboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message consumer for a sandbox.
|
||||
* Consumers receive broadcast messages (console, execution-complete, etc.)
|
||||
*/
|
||||
addConsumer(sandboxId: string, consumer: MessageConsumer): void {
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (context) {
|
||||
context.consumers.add(consumer);
|
||||
}
|
||||
console.log("Added consumer for sandbox:", sandboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a message consumer from a sandbox.
|
||||
*/
|
||||
removeConsumer(sandboxId: string, consumer: MessageConsumer): void {
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (context) {
|
||||
context.consumers.delete(consumer);
|
||||
}
|
||||
console.log("Removed consumer for sandbox:", sandboxId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the global message listener (called automatically)
|
||||
*/
|
||||
private setupListener(): void {
|
||||
if (this.messageListener) return;
|
||||
|
||||
this.messageListener = (e: MessageEvent) => {
|
||||
const { sandboxId } = e.data;
|
||||
if (!sandboxId) return;
|
||||
|
||||
console.log("Router received message for sandbox:", sandboxId, e.data);
|
||||
|
||||
const context = this.sandboxes.get(sandboxId);
|
||||
if (!context) return;
|
||||
|
||||
// Create respond() function for bidirectional communication
|
||||
const respond = (response: any) => {
|
||||
if (!response.sandboxId) response.sandboxId = sandboxId;
|
||||
context.iframe?.contentWindow?.postMessage(response, "*");
|
||||
};
|
||||
|
||||
// 1. Try provider handlers first (for bidirectional comm like memory)
|
||||
for (const provider of context.providers) {
|
||||
if (provider.handleMessage) {
|
||||
const handled = provider.handleMessage(e.data, respond);
|
||||
if (handled) return; // Stop if handled
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Broadcast to consumers (for one-way messages like console)
|
||||
for (const consumer of context.consumers) {
|
||||
const consumed = consumer.handleMessage(e.data);
|
||||
if (consumed) break; // Stop if consumed
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", this.messageListener);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Global singleton instance.
|
||||
* Import this from wherever you need to interact with the message router.
|
||||
*/
|
||||
export const SANDBOX_MESSAGE_ROUTER = new SandboxMessageRouter();
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Interface for providing runtime capabilities to sandboxed iframes.
|
||||
* Each provider injects data and runtime functions into the sandbox context.
|
||||
*/
|
||||
export interface SandboxRuntimeProvider {
|
||||
/**
|
||||
* Returns data to inject into window scope.
|
||||
* Keys become window properties (e.g., { attachments: [...] } -> window.attachments)
|
||||
*/
|
||||
getData(): Record<string, any>;
|
||||
|
||||
/**
|
||||
* Returns a runtime function that will be stringified and executed in the sandbox.
|
||||
* The function receives sandboxId and has access to data from getData() via window.
|
||||
*
|
||||
* IMPORTANT: This function will be converted to string via .toString() and injected
|
||||
* into the sandbox, so it cannot reference external variables or imports.
|
||||
*/
|
||||
getRuntime(): (sandboxId: string) => void;
|
||||
|
||||
/**
|
||||
* Optional message handler for bidirectional communication.
|
||||
* Return true if the message was handled, false to let other handlers try.
|
||||
*
|
||||
* @param message - The message from the sandbox
|
||||
* @param respond - Function to send a response back to the sandbox
|
||||
* @returns true if message was handled, false otherwise
|
||||
*/
|
||||
handleMessage?(message: any, respond: (response: any) => void): boolean;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue