import { EventTarget } from "/js/eventEmitter.js"; import { SimpleUUID, isDev } from "/js/utils.js"; import { getPermissionsFromManifest } from "/js/plugins/pluginPermissions.js"; const SANDBOX_URL = "/plugin-sandbox.html"; export class Logger { static LEVELS = { info: 10, warn: 20, error: 30, silent: 40 }; constructor(prefix, logLevel = "warn") { this.prefix = prefix; this.logLevel = logLevel; } _enabled(level) { return Logger.LEVELS[level] >= Logger.LEVELS[this.logLevel]; } info(...args) { if (this._enabled("info")) console.info(this.prefix, ...args); } warn(...args) { if (this._enabled("warn")) console.warn(this.prefix, ...args); } error(...args) { if (this._enabled("error")) console.error(this.prefix, ...args); } } const logger = new Logger("[plugins]", isDev() ? "info" : "warn"); // Has same API as Worker, but runs code in a sandboxed iframe export class SandboxedWorker extends EventTarget { constructor(source) { super(); this.source = source; this.frame = this._createSandboxFrame(); this._messageTarget = this.frame.contentWindow; this._handleWindowMessage = this._handleWindowMessage.bind(this); window.addEventListener("message", this._handleWindowMessage); this.frame.addEventListener("load", () => { this.frame.contentWindow.postMessage( { type: "init", workerSource: wrapWorkerSource(source) }, "*", ); }); document.body.appendChild(this.frame); } _createSandboxFrame() { const frame = document.createElement("iframe"); frame.setAttribute("sandbox", "allow-scripts"); frame.setAttribute("aria-hidden", "true"); frame.style.display = "none"; frame.src = SANDBOX_URL; return frame; } postMessage(payload) { this.frame.contentWindow.postMessage({ type: "send", payload }, "*"); } terminate() { window.removeEventListener("message", this._handleWindowMessage); this.frame.remove(); this.dispatchEvent({ type: "terminate" }); } _handleWindowMessage(event) { if (event.source !== this.frame.contentWindow) return; const message = event.data; if (!message || typeof message !== "object") return; switch (message.type) { case "fromWorker": this.dispatchEvent({ type: "message", data: message.payload }); return; case "workerError": this.dispatchEvent({ type: "error", message: message.error }); return; } } } export function wrapWorkerSource(source) { return /* js */ ` delete self.BroadcastChannel; delete self.SharedWorker; self.module = {} ${source} const pluginClass = self.module.exports?.default if (pluginClass) { pluginClass.register() } `; } async function createSandboxedWorker(source) { const worker = new SandboxedWorker(source); // in the future, we could add a handshake here to ensure worker has loaded return worker; } // Direct (unsandboxed) Worker for e2e tests async function createDirectWorker(source) { const blob = new Blob([wrapWorkerSource(source)], { type: "text/javascript", }); return new Worker(URL.createObjectURL(blob), { type: "module" }); } export class PluginInstance { constructor(pluginId, manifest, worker, { onRegister, onHostCall }) { this.pluginId = pluginId; this.manifest = manifest; this.permissions = getPermissionsFromManifest(this.manifest); this.worker = worker; this._onRegister = onRegister; this._onHostCall = onHostCall; this.disposers = []; this._pendingCalls = new Map(); this.callUuid = new SimpleUUID(); this.worker.addEventListener("message", (event) => this._handleWorkerMessage(event), ); this.worker.addEventListener("error", (event) => logger.error(`"${this.pluginId}" worker error:`, event.message), ); this._readyPromise = new Promise((resolve, reject) => { this._setReady = () => resolve(); this._setFailed = (e) => reject(e); }); } _handleWorkerMessage(event) { const message = event.data; if (!message || typeof message !== "object") return; switch (message.type) { case "ready": { message.error ? this._setFailed(message.error) : this._setReady(); return; } case "register": { const dispose = this._onRegister(this, message); if (dispose) this.disposers.push(dispose); return; } case "result": { this._handleCallResult(message); return; } case "hostCall": { this._onHostCall(this, message); return; } default: return; } } static async loadFromSource(pluginId, manifest, source, callbacks) { const worker = !window.env.playwright // don't sandbox in e2e tests ? await createSandboxedWorker(source) : await createDirectWorker(source); const instance = new PluginInstance(pluginId, manifest, worker, callbacks); try { return await instance.waitForReady(2000); } catch (err) { instance.unload(); throw err; } } async waitForReady(timeout) { const timeoutPromise = new Promise((resolve, reject) => setTimeout(() => reject(new Error("Timed out")), timeout), ); await Promise.race([this._readyPromise, timeoutPromise]); return this; } async call(handlerId, ...args) { const callId = this.callUuid.create(); return new Promise((resolve, reject) => { this._pendingCalls.set(callId, { resolve, reject }); this.worker.postMessage({ type: "call", callId, handlerId, args, }); }); } async sendEvent(event, data) { this.worker.postMessage({ type: "event", event, data, }); } _handleCallResult(message) { const pending = this._pendingCalls.get(message.callId); if (!pending) return; this._pendingCalls.delete(message.callId); if (message.error) pending.reject(new Error(message.error)); else pending.resolve(message.value); } unload() { this.disposers.forEach((dispose) => dispose()); this.worker.terminate(); } } export class PluginBridge { constructor( sourceProvider, pluginStylesLoader, loadPluginInstance = PluginInstance.loadFromSource, ) { this._provider = sourceProvider; this._pluginStylesLoader = pluginStylesLoader; this._loadPluginInstance = loadPluginInstance; this._registrationTargets = new Map(); this._loadedPlugins = new Map(); this._hostCallHandlers = new Map(); } isLoaded(pluginId) { return this._loadedPlugins.has(pluginId); } getInstance(pluginId) { return this._loadedPlugins.get(pluginId) ?? null; } addRegistrationTarget(target, handler) { this._registrationTargets.set(target, handler); } _handleRegistration(pluginInstance, message) { const handler = this._registrationTargets.get(message.target); if (!handler) { logger.warn( `"${pluginInstance.pluginId}" attempted to register unknown target "${message.target}"`, ); return null; } return handler(pluginInstance, message); } // Request: {id, version, repo?} async loadPlugins(pluginRequests) { const loadedPlugins = []; const erroredPlugins = []; await Promise.all( pluginRequests.map(async ({ id, version, repo }) => { try { const plugin = await this.loadPlugin(id, version, repo); loadedPlugins.push(plugin); } catch (error) { erroredPlugins.push({ pluginId: id, version, error }); } }), ); return { loadedPlugins, erroredPlugins, }; } async loadPlugin(pluginId, version, repo) { if (this._loadedPlugins.has(pluginId)) return; let manifest; try { manifest = await this._provider.getManifest(pluginId, version, repo); } catch (error) { logger.warn(`failed to load "${pluginId}": invalid manifest`, error); throw new Error("Failed to load plugin manifest"); } let source; try { source = await this._provider.getSource(pluginId, version, repo); } catch (error) { logger.error( `failed to load "${pluginId}": could not fetch main.js`, error, ); throw new Error("Failed to load plugin source"); } let cssText; try { cssText = await this._provider.getStyles(pluginId, version, repo); } catch (error) { logger.error( `failed to load "${pluginId}": could not fetch styles.css`, error, ); throw new Error("Failed to load plugin styles"); } if (cssText != null) { try { this._pluginStylesLoader.mount(pluginId, cssText); } catch (error) { logger.error(`failed to load "${pluginId}": invalid styles.css`, error); throw new Error("Plugin styles failed validation"); } } try { const pluginInstance = await this._loadPluginInstance( pluginId, manifest, source, { onRegister: (instance, message) => this._handleRegistration(instance, message), onHostCall: (instance, message) => this._handleHostCall(instance, message), }, ); this._loadedPlugins.set(pluginId, pluginInstance); logger.info(`loaded "${pluginId}" v${manifest.version}`); return pluginInstance; } catch (error) { this._pluginStylesLoader.unmount(pluginId); logger.error(`"${pluginId}" failed during initialization:`, error); throw new Error("Plugin failed during initialization"); } } addHostMethod(method, handler) { this._hostCallHandlers.set(method, handler); } _handleHostCall(pluginInstance, message) { const handler = this._hostCallHandlers.get(message.method); const hostCallId = message.hostCallId; const sendResult = (result) => { if (hostCallId == null) return; pluginInstance.worker.postMessage({ type: "hostResult", hostCallId, ...result, }); }; if (!handler) { logger.warn( `"${pluginInstance.pluginId}" called unknown host method "${message.method}"`, ); sendResult({ error: `unknown host method "${message.method}"` }); return; } const args = message.args ?? []; Promise.resolve() .then(() => handler(pluginInstance, ...args)) .then( (value) => sendResult({ value }), (error) => { logger.error( `"${pluginInstance.pluginId}" host method "${message.method}" threw:`, error, ); sendResult({ error: error?.message ?? String(error) }); }, ); } handleNodeEvent(pluginId, handlerId, virtualEvent) { const instance = this._loadedPlugins.get(pluginId); if (!instance) { logger.warn( `received event for unknown plugin "${pluginId}", handler "${handlerId}"`, ); return; } instance.call(handlerId, virtualEvent).catch((error) => { logger.warn(`[plugins] "${pluginId}" event handler threw:`, error); }); } unloadPlugin(pluginId) { const instance = this._loadedPlugins.get(pluginId); if (!instance) return; instance.unload(); this._loadedPlugins.delete(pluginId); this._pluginStylesLoader.unmount(pluginId); } async reloadPlugin(pluginId, version, repo) { this.unloadPlugin(pluginId); return this.loadPlugin(pluginId, version, repo); } }