mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 07:03:25 +00:00
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:
parent
d7d79bd533
commit
c2793d8017
11 changed files with 722 additions and 385 deletions
|
|
@ -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>`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)";
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
221
packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts
Normal file
221
packages/web-ui/src/components/sandbox/RuntimeMessageRouter.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');`;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue