mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 22:01:38 +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
|
|
@ -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();
|
||||
Loading…
Add table
Add a link
Reference in a new issue