Fix various sandbox issues.

This commit is contained in:
Mario Zechner 2025-10-08 22:51:32 +02:00
parent 91c1dc6475
commit 0eaa879d46
10 changed files with 673 additions and 431 deletions

View file

@ -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>`;
}
}

View file

@ -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,
},
"*",
);
};
};
}
}

View 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);
});
}
};
}
}

View 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();

View file

@ -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;
}