Add runtime bridge architecture and fix HTML escaping

Major refactoring to unify runtime providers across sandbox and user script contexts:

1. Runtime Bridge & Router
   - Add RuntimeMessageBridge for unified messaging abstraction
   - Rename SandboxMessageRouter → RuntimeMessageRouter
   - Router now handles both iframe and user script messages
   - Guard for non-extension environments

2. Provider Refactoring
   - ArtifactsRuntimeProvider: Add offline mode with snapshot fallback
   - AttachmentsRuntimeProvider: Remove returnDownloadableFile (moved to dedicated provider)
   - ConsoleRuntimeProvider: Add message collection, remove lifecycle logic
   - FileDownloadRuntimeProvider: New provider for file downloads

3. HTML Escaping Fix
   - Escape </script> in JSON.stringify output to prevent premature tag closure
   - Applies when injecting provider data into <script> tags
   - JavaScript engine automatically unescapes, no runtime changes needed

4. Function Renaming
   - listFiles → listAttachments
   - readTextFile → readTextAttachment
   - readBinaryFile → readBinaryAttachment
   - returnFile → returnDownloadableFile

5. Updated Exports
   - Export new RuntimeMessageBridge and RuntimeMessageRouter
   - Export FileDownloadRuntimeProvider
   - Update all cross-references

This sets the foundation for reusing providers in browser-javascript tool.
This commit is contained in:
Mario Zechner 2025-10-09 17:32:45 +02:00
parent d7d79bd533
commit c2793d8017
11 changed files with 722 additions and 385 deletions

View file

@ -1,7 +1,8 @@
import { LitElement } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ConsoleRuntimeProvider } from "./sandbox/ConsoleRuntimeProvider.js";
import { type MessageConsumer, SANDBOX_MESSAGE_ROUTER } from "./sandbox/SandboxMessageRouter.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 {
@ -65,16 +66,17 @@ export class SandboxIframe extends LitElement {
): void {
// Unregister previous sandbox if exists
try {
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
} catch {
// Sandbox might not exist, that's ok
}
providers = [new ConsoleRuntimeProvider(), ...providers];
SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers);
// loadContent is always used for HTML artifacts
const completeHtml = this.prepareHtmlDocument(sandboxId, htmlContent, providers, true);
// Remove previous iframe if exists
this.iframe?.remove();
@ -99,7 +101,7 @@ export class SandboxIframe extends LitElement {
this.iframe.src = this.sandboxUrlProvider!();
// Update router with iframe reference BEFORE appending to DOM
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready message directly
const readyHandler = (e: MessageEvent) => {
@ -134,7 +136,7 @@ export class SandboxIframe extends LitElement {
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
this.appendChild(this.iframe);
}
@ -154,13 +156,14 @@ export class SandboxIframe extends LitElement {
providers: SandboxRuntimeProvider[] = [],
consumers: MessageConsumer[] = [],
signal?: AbortSignal,
isHtmlArtifact: boolean = false,
): Promise<SandboxResult> {
if (signal?.aborted) {
throw new Error("Execution aborted");
}
providers = [new ConsoleRuntimeProvider(), ...providers];
SANDBOX_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
RUNTIME_MESSAGE_ROUTER.registerSandbox(sandboxId, providers, consumers);
const logs: Array<{ type: string; text: string }> = [];
const files: SandboxFile[] = [];
@ -198,10 +201,10 @@ export class SandboxIframe extends LitElement {
},
};
SANDBOX_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
RUNTIME_MESSAGE_ROUTER.addConsumer(sandboxId, executionConsumer);
const cleanup = () => {
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
signal?.removeEventListener("abort", abortHandler);
clearTimeout(timeoutId);
this.iframe?.remove();
@ -236,7 +239,7 @@ export class SandboxIframe extends LitElement {
}, 30000);
// 4. Prepare HTML and create iframe
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers);
const completeHtml = this.prepareHtmlDocument(sandboxId, code, providers, isHtmlArtifact);
if (this.sandboxUrlProvider) {
// Browser extension mode: wait for sandbox-ready
@ -246,7 +249,7 @@ export class SandboxIframe extends LitElement {
this.iframe.src = this.sandboxUrlProvider();
// Update router with iframe reference BEFORE appending to DOM
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
// Listen for sandbox-ready message directly
const readyHandler = (e: MessageEvent) => {
@ -276,7 +279,7 @@ export class SandboxIframe extends LitElement {
this.iframe.srcdoc = completeHtml;
// Update router with iframe reference BEFORE appending to DOM
SANDBOX_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
RUNTIME_MESSAGE_ROUTER.setSandboxIframe(sandboxId, this.iframe);
this.appendChild(this.iframe);
}
@ -287,14 +290,18 @@ export class SandboxIframe extends LitElement {
* 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[] = []): string {
public prepareHtmlDocument(
sandboxId: string,
userCode: string,
providers: SandboxRuntimeProvider[] = [],
isHtmlArtifact: boolean = false,
): string {
// Runtime script that will be injected
const runtime = this.getRuntimeScript(sandboxId, providers);
// Check if user provided full HTML
const hasHtmlTag = /<html[^>]*>/i.test(userCode);
if (hasHtmlTag) {
// 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 (isHtmlArtifact) {
// HTML Artifact - inject runtime into existing HTML
const headMatch = userCode.match(/<head[^>]*>/i);
if (headMatch) {
@ -347,20 +354,31 @@ export class SandboxIframe extends LitElement {
Object.assign(allData, provider.getData());
}
// Generate bridge code
const bridgeCode = 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
// Build script with HTML escaping
// Escape </script> to prevent premature tag closure in HTML parser
const dataInjection = Object.entries(allData)
.map(([key, value]) => `window.${key} = ${JSON.stringify(value)};`)
.map(([key, value]) => {
const jsonStr = JSON.stringify(value).replace(/<\/script/gi, "<\\/script");
return `window.${key} = ${jsonStr};`;
})
.join("\n");
return `<script>
window.sandboxId = ${JSON.stringify(sandboxId)};
${dataInjection}
${bridgeCode}
${runtimeFunctions.join("\n")}
</script>`;
}

View file

@ -6,6 +6,7 @@ import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
*
* Provides programmatic access to session artifacts from sandboxed code.
* Allows code to create, read, update, and delete artifacts dynamically.
* Supports both online (extension) and offline (downloaded HTML) modes.
*/
export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
constructor(
@ -17,54 +18,59 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
) {}
getData(): Record<string, any> {
// No initial data injection needed - artifacts are accessed via async functions
return {};
// Inject artifact snapshot for offline mode
const snapshot: Record<string, string> = {};
const artifacts = this.getArtifactsFn();
artifacts.forEach((artifact, filename) => {
snapshot[filename] = artifact.content;
});
return { artifacts: snapshot };
}
getRuntime(): (sandboxId: string) => void {
// This function will be stringified, so no external references!
return (sandboxId: string) => {
// Helper to send message and wait for response
const sendArtifactMessage = (action: string, data: any): Promise<any> => {
console.log("Sending artifact message:", action, data);
return new Promise((resolve, reject) => {
const messageId = `artifact_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
const handler = (event: MessageEvent) => {
if (event.data.type === "artifact-response" && event.data.messageId === messageId) {
window.removeEventListener("message", handler);
if (event.data.success) {
resolve(event.data.result);
} else {
reject(new Error(event.data.error || "Artifact operation failed"));
}
}
};
window.addEventListener("message", handler);
window.parent.postMessage(
{
type: "artifact-operation",
sandboxId,
messageId,
action,
data,
},
"*",
);
});
};
return (_sandboxId: string) => {
// Auto-parse/stringify for .json files
const isJsonFile = (filename: string) => filename.endsWith(".json");
(window as any).hasArtifact = async (filename: string): Promise<boolean> => {
return await sendArtifactMessage("has", { filename });
// Online: ask extension
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "has",
filename,
});
if (!response.success) throw new Error(response.error);
return response.result;
}
// Offline: check snapshot
else {
return !!(window as any).artifacts?.[filename];
}
};
(window as any).getArtifact = async (filename: string): Promise<any> => {
const content = await sendArtifactMessage("get", { filename });
let content: string;
// Online: ask extension
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "get",
filename,
});
if (!response.success) throw new Error(response.error);
content = response.result;
}
// Offline: read snapshot
else {
if (!(window as any).artifacts?.[filename]) {
throw new Error(`Artifact not found (offline mode): ${filename}`);
}
content = (window as any).artifacts[filename];
}
// Auto-parse .json files
if (isJsonFile(filename)) {
try {
@ -77,45 +83,62 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
};
(window as any).createArtifact = async (filename: string, content: any, mimeType?: string): Promise<void> => {
let finalContent = content;
let finalMimeType = mimeType;
if (!(window as any).sendRuntimeMessage) {
throw new Error("Cannot create artifacts in offline mode (read-only)");
}
let finalContent = content;
// Auto-stringify .json files
if (isJsonFile(filename) && typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
} else if (typeof content === "string") {
finalContent = content;
finalMimeType = mimeType || "text/plain";
} else {
} else if (typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
}
await sendArtifactMessage("create", { filename, content: finalContent, mimeType: finalMimeType });
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "create",
filename,
content: finalContent,
mimeType,
});
if (!response.success) throw new Error(response.error);
};
(window as any).updateArtifact = async (filename: string, content: any, mimeType?: string): Promise<void> => {
let finalContent = content;
let finalMimeType = mimeType;
if (!(window as any).sendRuntimeMessage) {
throw new Error("Cannot update artifacts in offline mode (read-only)");
}
let finalContent = content;
// Auto-stringify .json files
if (isJsonFile(filename) && typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
} else if (typeof content === "string") {
finalContent = content;
finalMimeType = mimeType || "text/plain";
} else {
} else if (typeof content !== "string") {
finalContent = JSON.stringify(content, null, 2);
finalMimeType = mimeType || "application/json";
}
await sendArtifactMessage("update", { filename, content: finalContent, mimeType: finalMimeType });
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "update",
filename,
content: finalContent,
mimeType,
});
if (!response.success) throw new Error(response.error);
};
(window as any).deleteArtifact = async (filename: string): Promise<void> => {
await sendArtifactMessage("delete", { filename });
if (!(window as any).sendRuntimeMessage) {
throw new Error("Cannot delete artifacts in offline mode (read-only)");
}
const response = await (window as any).sendRuntimeMessage({
type: "artifact-operation",
action: "delete",
filename,
});
if (!response.success) throw new Error(response.error);
};
};
}
@ -125,103 +148,86 @@ export class ArtifactsRuntimeProvider implements SandboxRuntimeProvider {
return false;
}
const { action, data, messageId } = message;
const sendResponse = (success: boolean, result?: any, error?: string) => {
respond({
type: "artifact-response",
messageId,
success,
result,
error,
});
};
const { action, filename, content, mimeType } = message;
try {
switch (action) {
case "has": {
const artifacts = this.getArtifactsFn();
const exists = artifacts.has(data.filename);
sendResponse(true, exists);
const exists = artifacts.has(filename);
respond({ success: true, result: exists });
break;
}
case "get": {
const artifacts = this.getArtifactsFn();
const artifact = artifacts.get(data.filename);
const artifact = artifacts.get(filename);
if (!artifact) {
sendResponse(false, undefined, `Artifact not found: ${data.filename}`);
respond({ success: false, error: `Artifact not found: ${filename}` });
} else {
sendResponse(true, artifact.content);
respond({ success: true, result: artifact.content });
}
break;
}
case "create": {
try {
// Note: mimeType parameter is ignored - artifact type is inferred from filename extension
// Third parameter is title, defaults to filename
await this.createArtifactFn(data.filename, data.content, data.filename);
// Append artifact message for session persistence
await this.createArtifactFn(filename, content, filename);
this.appendMessageFn?.({
role: "artifact",
action: "create",
filename: data.filename,
content: data.content,
title: data.filename,
filename,
content,
title: filename,
timestamp: new Date().toISOString(),
});
sendResponse(true);
respond({ success: true });
} catch (err: any) {
sendResponse(false, undefined, err.message);
respond({ success: false, error: err.message });
}
break;
}
case "update": {
try {
// Note: mimeType parameter is ignored - artifact type is inferred from filename extension
// Third parameter is title, defaults to filename
await this.updateArtifactFn(data.filename, data.content, data.filename);
// Append artifact message for session persistence
await this.updateArtifactFn(filename, content, filename);
this.appendMessageFn?.({
role: "artifact",
action: "update",
filename: data.filename,
content: data.content,
filename,
content,
timestamp: new Date().toISOString(),
});
sendResponse(true);
respond({ success: true });
} catch (err: any) {
sendResponse(false, undefined, err.message);
respond({ success: false, error: err.message });
}
break;
}
case "delete": {
try {
await this.deleteArtifactFn(data.filename);
// Append artifact message for session persistence
await this.deleteArtifactFn(filename);
this.appendMessageFn?.({
role: "artifact",
action: "delete",
filename: data.filename,
filename,
timestamp: new Date().toISOString(),
});
sendResponse(true);
respond({ success: true });
} catch (err: any) {
sendResponse(false, undefined, err.message);
respond({ success: false, error: err.message });
}
break;
}
default:
sendResponse(false, undefined, `Unknown artifact action: ${action}`);
respond({ success: false, error: `Unknown artifact action: ${action}` });
}
return true;
} catch (error: any) {
sendResponse(false, undefined, error.message);
respond({ success: false, error: error.message });
return true;
}
}

View file

@ -7,6 +7,7 @@ import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
*
* OPTIONAL provider that provides file access APIs to sandboxed code.
* Only needed when attachments are present.
* Attachments are read-only snapshot data - no messaging needed.
*/
export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
constructor(private attachments: Attachment[]) {}
@ -26,9 +27,10 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
getRuntime(): (sandboxId: string) => void {
// This function will be stringified, so no external references!
return (sandboxId: string) => {
// Helper functions for attachments
(window as any).listFiles = () =>
// These functions read directly from window.attachments
// Works both online AND offline (no messaging needed!)
return (_sandboxId: string) => {
(window as any).listAttachments = () =>
((window as any).attachments || []).map((a: any) => ({
id: a.id,
fileName: a.fileName,
@ -36,7 +38,7 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
size: a.size,
}));
(window as any).readTextFile = (attachmentId: string) => {
(window as any).readTextAttachment = (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;
@ -47,7 +49,7 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
}
};
(window as any).readBinaryFile = (attachmentId: string) => {
(window as any).readBinaryAttachment = (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);
@ -55,46 +57,6 @@ export class AttachmentsRuntimeProvider implements SandboxRuntimeProvider {
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

@ -1,19 +1,30 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
export interface ConsoleLog {
type: "log" | "warn" | "error" | "info";
text: string;
args?: unknown[];
}
/**
* Console Runtime Provider
*
* REQUIRED provider that should always be included first.
* Provides console capture, error handling, and execution lifecycle management.
* Collects console output for retrieval by caller.
*/
export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
private logs: ConsoleLog[] = [];
private completionError: { message: string; stack: string } | null = null;
private completed = false;
getData(): Record<string, any> {
// No data needed
return {};
}
getRuntime(): (sandboxId: string) => void {
return (sandboxId: string) => {
return (_sandboxId: string) => {
// Console capture
const originalConsole = {
log: console.log,
@ -34,16 +45,21 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
})
.join(" ");
window.parent.postMessage(
{
type: "console",
sandboxId,
method,
text,
},
"*",
);
// Send to extension if available (online mode)
if ((window as any).sendRuntimeMessage) {
(window as any)
.sendRuntimeMessage({
type: "console",
method,
text,
args, // Send raw args for provider collection
})
.catch(() => {
// Ignore errors in fire-and-forget console messages
});
}
// Always log locally too
(originalConsole as any)[method].apply(console, args);
};
});
@ -61,15 +77,15 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
stack: e.error?.stack || text,
};
window.parent.postMessage(
{
type: "console",
sandboxId,
method: "error",
text,
},
"*",
);
if ((window as any).sendRuntimeMessage) {
(window as any)
.sendRuntimeMessage({
type: "console",
method: "error",
text,
})
.catch(() => {});
}
});
window.addEventListener("unhandledrejection", (e) => {
@ -80,15 +96,15 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
stack: e.reason?.stack || text,
};
window.parent.postMessage(
{
type: "console",
sandboxId,
method: "error",
text,
},
"*",
);
if ((window as any).sendRuntimeMessage) {
(window as any)
.sendRuntimeMessage({
type: "console",
method: "error",
text,
})
.catch(() => {});
}
});
// Expose complete() method for user code to call
@ -99,23 +115,21 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
const finalError = error || lastError;
if (finalError) {
window.parent.postMessage(
{
type: "execution-error",
sandboxId,
error: finalError,
},
"*",
);
} else {
window.parent.postMessage(
{
type: "execution-complete",
sandboxId,
},
"*",
);
if ((window as any).sendRuntimeMessage) {
if (finalError) {
(window as any)
.sendRuntimeMessage({
type: "execution-error",
error: finalError,
})
.catch(() => {});
} else {
(window as any)
.sendRuntimeMessage({
type: "execution-complete",
})
.catch(() => {});
}
}
};
@ -129,4 +143,66 @@ export class ConsoleRuntimeProvider implements SandboxRuntimeProvider {
}
};
}
async handleMessage(message: any, _respond: (response: any) => void): Promise<boolean> {
if (message.type === "console") {
// Collect console output
this.logs.push({
type:
message.method === "error"
? "error"
: message.method === "warn"
? "warn"
: message.method === "info"
? "info"
: "log",
text: message.text,
args: message.args,
});
return true;
}
if (message.type === "execution-complete") {
this.completed = true;
return true;
}
if (message.type === "execution-error") {
this.completed = true;
this.completionError = message.error;
return true;
}
return false;
}
/**
* Get collected console logs
*/
getLogs(): ConsoleLog[] {
return this.logs;
}
/**
* Get completion status
*/
isCompleted(): boolean {
return this.completed;
}
/**
* Get completion error if any
*/
getCompletionError(): { message: string; stack: string } | null {
return this.completionError;
}
/**
* Reset state for reuse
*/
reset(): void {
this.logs = [];
this.completionError = null;
this.completed = false;
}
}

View file

@ -0,0 +1,113 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
export interface DownloadableFile {
fileName: string;
content: string | Uint8Array;
mimeType: string;
}
/**
* File Download Runtime Provider
*
* Provides returnDownloadableFile() for creating user downloads.
* Files returned this way are NOT accessible to the LLM later (one-time download).
* Works both online (sends to extension) and offline (triggers browser download directly).
* Collects files for retrieval by caller.
*/
export class FileDownloadRuntimeProvider implements SandboxRuntimeProvider {
private files: DownloadableFile[] = [];
getData(): Record<string, any> {
// No data needed
return {};
}
getRuntime(): (sandboxId: string) => void {
return (_sandboxId: string) => {
(window as any).returnDownloadableFile = 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(
"returnDownloadableFile: 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(
"returnDownloadableFile: 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";
}
// Send to extension if available (online mode)
if ((window as any).sendRuntimeMessage) {
const response = await (window as any).sendRuntimeMessage({
type: "file-returned",
fileName,
content: finalContent,
mimeType: finalMimeType,
});
if (response.error) throw new Error(response.error);
} else {
// Offline mode: trigger browser download directly
const blob = new Blob([finalContent instanceof Uint8Array ? finalContent : finalContent], {
type: finalMimeType,
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
};
};
}
async handleMessage(message: any, respond: (response: any) => void): Promise<boolean> {
if (message.type !== "file-returned") {
return false;
}
// Collect file for caller
this.files.push({
fileName: message.fileName,
content: message.content,
mimeType: message.mimeType,
});
respond({ success: true });
return true;
}
/**
* Get collected files
*/
getFiles(): DownloadableFile[] {
return this.files;
}
/**
* Reset state for reuse
*/
reset(): void {
this.files = [];
}
getDescription(): string {
return "returnDownloadableFile(filename, content, mimeType?) - Create downloadable file for user (one-time download, not accessible later)";
}
}

View file

@ -0,0 +1,74 @@
/**
* Generates sendRuntimeMessage() function for injection into execution contexts.
* Provides unified messaging API that works in both sandbox iframe and user script contexts.
*/
export type MessageType = "request-response" | "fire-and-forget";
export interface RuntimeMessageBridgeOptions {
context: "sandbox-iframe" | "user-script";
sandboxId: string;
}
// biome-ignore lint/complexity/noStaticOnlyClass: fine
export class RuntimeMessageBridge {
/**
* Generate sendRuntimeMessage() function as injectable string.
* Returns the function source code to be injected into target context.
*/
static generateBridgeCode(options: RuntimeMessageBridgeOptions): string {
if (options.context === "sandbox-iframe") {
return RuntimeMessageBridge.generateSandboxBridge(options.sandboxId);
} else {
return RuntimeMessageBridge.generateUserScriptBridge(options.sandboxId);
}
}
private static generateSandboxBridge(sandboxId: string): string {
// Returns stringified function that uses window.parent.postMessage
return `
window.sendRuntimeMessage = async (message) => {
const messageId = 'msg_' + Date.now() + '_' + Math.random().toString(36).substring(2, 9);
return new Promise((resolve, reject) => {
const handler = (e) => {
if (e.data.type === 'runtime-response' && e.data.messageId === messageId) {
window.removeEventListener('message', handler);
if (e.data.success) {
resolve(e.data);
} else {
reject(new Error(e.data.error || 'Operation failed'));
}
}
};
window.addEventListener('message', handler);
window.parent.postMessage({
...message,
sandboxId: ${JSON.stringify(sandboxId)},
messageId: messageId
}, '*');
// Timeout after 30s
setTimeout(() => {
window.removeEventListener('message', handler);
reject(new Error('Runtime message timeout'));
}, 30000);
});
};
`.trim();
}
private static generateUserScriptBridge(sandboxId: string): string {
// Returns stringified function that uses chrome.runtime.sendMessage
return `
window.sendRuntimeMessage = async (message) => {
return await chrome.runtime.sendMessage({
...message,
sandboxId: ${JSON.stringify(sandboxId)}
});
};
`.trim();
}
}

View file

@ -0,0 +1,221 @@
import type { SandboxRuntimeProvider } from "./SandboxRuntimeProvider.js";
// Type declaration for chrome extension API (when available)
declare const chrome: any;
/**
* 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): Promise<boolean>;
}
/**
* Sandbox context - tracks active sandboxes and their consumers
*/
interface SandboxContext {
sandboxId: string;
iframe: HTMLIFrameElement | null; // null until setSandboxIframe() or null for user scripts
providers: SandboxRuntimeProvider[];
consumers: Set<MessageConsumer>;
}
/**
* Centralized message router for all runtime communication.
*
* This singleton replaces all individual window.addEventListener("message") calls
* with a single global listener that routes messages to the appropriate handlers.
* Also handles user script messages from chrome.runtime.onUserScriptMessage.
*
* Benefits:
* - Single global listener instead of multiple independent listeners
* - Automatic cleanup when sandboxes are destroyed
* - Support for bidirectional communication (providers) and broadcasting (consumers)
* - Works with both sandbox iframes and user scripts
* - Clear lifecycle management
*/
export class RuntimeMessageRouter {
private sandboxes = new Map<string, SandboxContext>();
private messageListener: ((e: MessageEvent) => void) | null = null;
private userScriptMessageListener:
| ((message: any, sender: any, sendResponse: (response: any) => void) => boolean)
| null = null;
/**
* Register a new sandbox with its runtime providers.
* Call this BEFORE creating the iframe (for sandbox contexts) or executing user script.
*/
registerSandbox(sandboxId: string, providers: SandboxRuntimeProvider[], consumers: MessageConsumer[]): void {
this.sandboxes.set(sandboxId, {
sandboxId,
iframe: null, // Will be set via setSandboxIframe() for sandbox contexts
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 listeners
if (this.sandboxes.size === 0) {
// Remove iframe listener
if (this.messageListener) {
window.removeEventListener("message", this.messageListener);
this.messageListener = null;
}
// Remove user script listener
if (this.userScriptMessageListener && typeof chrome !== "undefined" && chrome.runtime?.onUserScriptMessage) {
chrome.runtime.onUserScriptMessage.removeListener(this.userScriptMessageListener);
this.userScriptMessageListener = 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 listeners (called automatically)
*/
private setupListener(): void {
// Setup sandbox iframe listener
if (!this.messageListener) {
this.messageListener = async (e: MessageEvent) => {
const { sandboxId, messageId } = 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) => {
context.iframe?.contentWindow?.postMessage(
{
type: "runtime-response",
messageId,
sandboxId,
...response,
},
"*",
);
};
// 1. Try provider handlers first (for bidirectional comm)
for (const provider of context.providers) {
if (provider.handleMessage) {
const handled = await 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 = await consumer.handleMessage(e.data);
if (consumed) break; // Stop if consumed
}
};
window.addEventListener("message", this.messageListener);
}
// Setup user script message listener
if (!this.userScriptMessageListener) {
// Guard: check if we're in extension context
if (typeof chrome === "undefined" || !chrome.runtime?.onUserScriptMessage) {
console.log("[RuntimeMessageRouter] User script API not available (not in extension context)");
return;
}
this.userScriptMessageListener = (message: any, _sender: any, sendResponse: (response: any) => void) => {
const { sandboxId } = message;
if (!sandboxId) return false;
const context = this.sandboxes.get(sandboxId);
if (!context) return false;
const respond = (response: any) => {
sendResponse({
...response,
sandboxId,
});
};
// Route to providers (async)
(async () => {
for (const provider of context.providers) {
if (provider.handleMessage) {
const handled = await provider.handleMessage(message, respond);
if (handled) return;
}
}
// Broadcast to consumers
for (const consumer of context.consumers) {
const consumed = await consumer.handleMessage(message);
if (consumed) break;
}
})();
return true; // Indicates async response
};
chrome.runtime.onUserScriptMessage.addListener(this.userScriptMessageListener);
}
}
}
/**
* Global singleton instance.
* Import this from wherever you need to interact with the message router.
*/
export const RUNTIME_MESSAGE_ROUTER = new RuntimeMessageRouter();

View file

@ -1,152 +0,0 @@
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): Promise<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 = async (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 = await 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 = await 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

@ -35,6 +35,17 @@ export {
type SandboxUrlProvider,
} from "./components/SandboxedIframe.js";
export { StreamingMessageContainer } from "./components/StreamingMessageContainer.js";
// Sandbox Runtime Providers
export { ArtifactsRuntimeProvider } from "./components/sandbox/ArtifactsRuntimeProvider.js";
export { AttachmentsRuntimeProvider } from "./components/sandbox/AttachmentsRuntimeProvider.js";
export { type ConsoleLog, ConsoleRuntimeProvider } from "./components/sandbox/ConsoleRuntimeProvider.js";
export {
type DownloadableFile,
FileDownloadRuntimeProvider,
} from "./components/sandbox/FileDownloadRuntimeProvider.js";
export { RuntimeMessageBridge } from "./components/sandbox/RuntimeMessageBridge.js";
export { RUNTIME_MESSAGE_ROUTER } from "./components/sandbox/RuntimeMessageRouter.js";
export type { SandboxRuntimeProvider } from "./components/sandbox/SandboxRuntimeProvider.js";
export { ApiKeyPromptDialog } from "./dialogs/ApiKeyPromptDialog.js";
export { AttachmentOverlay } from "./dialogs/AttachmentOverlay.js";
// Dialogs

View file

@ -52,7 +52,7 @@ export const JAVASCRIPT_REPL_CHART_EXAMPLE = `
options: { responsive: false, animation: false }
});
const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
await returnFile('chart.png', blob, 'image/png');`;
await returnDownloadableFile('chart.png', blob, 'image/png');`;
export const JAVASCRIPT_REPL_FOOTER = `
@ -107,20 +107,20 @@ Commands:
export const ARTIFACTS_RUNTIME_EXAMPLE = `- Example HTML artifact that processes a CSV attachment:
<script>
// List available files
const files = listFiles();
const files = listAttachments();
console.log('Available files:', files);
// Find CSV file
const csvFile = files.find(f => f.mimeType === 'text/csv');
if (csvFile) {
const csvContent = readTextFile(csvFile.id);
const csvContent = readTextAttachment(csvFile.id);
// Process CSV data...
}
// Display image
const imageFile = files.find(f => f.mimeType.startsWith('image/'));
if (imageFile) {
const bytes = readBinaryFile(imageFile.id);
const bytes = readBinaryAttachment(imageFile.id);
const blob = new Blob([bytes], {type: imageFile.mimeType});
const url = URL.createObjectURL(blob);
document.body.innerHTML = '<img src="' + url + '">';
@ -223,29 +223,37 @@ Example:
// ============================================================================
export const ATTACHMENTS_RUNTIME_DESCRIPTION = `
Global variables:
- attachments[] - Array of attachment objects from user messages
* Properties:
- id: string (unique identifier)
- fileName: string (e.g., "data.xlsx")
- mimeType: string (e.g., "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
- size: number (bytes)
* Helper functions:
- listFiles() - Returns array of {id, fileName, mimeType, size} for all attachments
- readTextFile(attachmentId) - Returns text content of attachment (for CSV, JSON, text files)
- readBinaryFile(attachmentId) - Returns Uint8Array of binary data (for images, Excel, etc.)
* Examples:
- const files = listFiles();
- const csvContent = readTextFile(files[0].id); // Read CSV as text
- const xlsxBytes = readBinaryFile(files[0].id); // Read Excel as binary
- await returnFile(filename, content, mimeType?) - Create downloadable files (async function!)
* Always use await with returnFile
User Attachments (files the user added to the conversation):
- listAttachments() - List all attachments, returns array of {id, fileName, mimeType, size}
* Example: const files = listAttachments(); // [{id: '...', fileName: 'data.xlsx', mimeType: '...', size: 12345}]
- readTextAttachment(attachmentId) - Read attachment as text, returns string
* Use for: CSV, JSON, TXT, XML, and other text-based files
* Example: const csvContent = readTextAttachment(files[0].id);
* Example: const json = JSON.parse(readTextAttachment(jsonFile.id));
- readBinaryAttachment(attachmentId) - Read attachment as binary data, returns Uint8Array
* Use for: Excel (.xlsx), images, PDFs, and other binary files
* Example: const xlsxBytes = readBinaryAttachment(files[0].id);
* Example: const XLSX = await import('https://esm.run/xlsx'); const workbook = XLSX.read(xlsxBytes);
Downloadable Files (one-time downloads for the user - YOU cannot read these back):
- await returnDownloadableFile(filename, content, mimeType?) - Create downloadable file (async!)
* Use for: Processed/transformed data, generated images, analysis results
* Important: This creates a download for the user. You will NOT be able to access this file's content later.
* If you need to access the data later, use createArtifact() instead (if available).
* Always use await with returnDownloadableFile
* REQUIRED: For Blob/Uint8Array binary content, you MUST supply a proper MIME type (e.g., "image/png").
If omitted, the REPL throws an Error with stack trace pointing to the offending line.
If omitted, throws an Error with stack trace pointing to the offending line.
* Strings without a MIME default to text/plain.
* Objects are auto-JSON stringified and default to application/json unless a MIME is provided.
* Canvas images: Use toBlob() with await Promise wrapper
* Examples:
- await returnFile('data.txt', 'Hello World', 'text/plain')
- await returnFile('data.json', {key: 'value'}, 'application/json')
- await returnFile('data.csv', 'name,age\\nJohn,30', 'text/csv')`;
- await returnDownloadableFile('cleaned-data.csv', csvString, 'text/csv')
- await returnDownloadableFile('analysis.json', {results: [...]}, 'application/json')
- await returnDownloadableFile('chart.png', blob, 'image/png')
Common pattern - Process attachment and create download:
const files = listAttachments();
const csvFile = files.find(f => f.fileName.endsWith('.csv'));
const csvData = readTextAttachment(csvFile.id);
// Process csvData...
await returnDownloadableFile('processed-' + csvFile.fileName, processedData, 'text/csv');`;

View file

@ -5,7 +5,7 @@ import { customElement, property, state } from "lit/decorators.js";
import { createRef, type Ref, ref } from "lit/directives/ref.js";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import type { SandboxIframe } from "../../components/SandboxedIframe.js";
import { type MessageConsumer, SANDBOX_MESSAGE_ROUTER } from "../../components/sandbox/SandboxMessageRouter.js";
import { type MessageConsumer, RUNTIME_MESSAGE_ROUTER } from "../../components/sandbox/RuntimeMessageRouter.js";
import type { SandboxRuntimeProvider } from "../../components/sandbox/SandboxRuntimeProvider.js";
import { i18n } from "../../utils/i18n.js";
import "../../components/SandboxedIframe.js";
@ -48,7 +48,7 @@ export class HtmlArtifact extends ArtifactElement {
const sandbox = this.sandboxIframeRef.value;
const sandboxId = `artifact-${this.filename}`;
const downloadContent =
sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || []) || this._content;
sandbox?.prepareHtmlDocument(sandboxId, this._content, this.runtimeProviders || [], true) || this._content;
return html`
<div class="flex items-center gap-2">
@ -115,7 +115,7 @@ export class HtmlArtifact extends ArtifactElement {
super.disconnectedCallback();
// Unregister sandbox when element is removed from DOM
const sandboxId = `artifact-${this.filename}`;
SANDBOX_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
RUNTIME_MESSAGE_ROUTER.unregisterSandbox(sandboxId);
}
override firstUpdated() {