import { EventTarget } from "/js/eventEmitter.js"; import { SimpleUUID, isDev } from "/js/utils.js"; import { SignalMap, ComputedMap, untrack } from "/js/signals.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(wrappedSource) { super(); 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: wrappedSource }, "*", ); }); 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 class PluginSdkError extends Error {} let __sdkSourcePromise = null; function getSdkSource() { if (__sdkSourcePromise == null) { __sdkSourcePromise = fetch(`/plugin-sdk/${window.env.pluginSdkFileName}`) .then((response) => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.text(); }) .catch((error) => { __sdkSourcePromise = null; throw new PluginSdkError( `Could not fetch plugin SDK: ${error.message}`, ); }); } return __sdkSourcePromise; } export async function wrapWorkerSource(source) { const sdkSource = await getSdkSource(); return /* js */ ` delete self.BroadcastChannel; delete self.SharedWorker; ${sdkSource} self.module = {}; self.require = (name) => { if (name === "@impro.social/impro-plugin") return ImproPlugin; throw new Error("Cannot find module \\"" + name + "\\""); }; ${source} const pluginClass = self.module.exports?.default; if (pluginClass) { pluginClass.register(); } `; } function createSandboxedWorker(wrappedSource) { const worker = new SandboxedWorker(wrappedSource); // in the future, we could add a handshake here to ensure worker has loaded return worker; } // Direct (unsandboxed) Worker for e2e tests function createDirectWorker(wrappedSource) { const blob = new Blob([wrappedSource], { type: "text/javascript", }); return new Worker(URL.createObjectURL(blob)); } export class PluginInstance { constructor(pluginId, manifest, worker, { onRegister, onHostCall }) { this.pluginId = pluginId; this.manifest = 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 wrappedSource = await wrapWorkerSource(source); const worker = !window.env.playwright // don't sandbox in e2e tests ? createSandboxedWorker(wrappedSource) : createDirectWorker(wrappedSource); 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._inFlightLoads = new Map(); this._hostCallHandlers = new Map(); // reactive loading state this.$loadedPlugins = new SignalMap(); this.$loading = new SignalMap(); this.$pluginLoadingErrors = new SignalMap(); this.$loadStatuses = new ComputedMap((pluginId) => ({ loading: this.$loading.get(pluginId) ?? false, error: this.$pluginLoadingErrors.get(pluginId) ?? null, })); } 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 (untrack(() => this.$loadedPlugins.has(pluginId))) return; const inFlightLoad = this._inFlightLoads.get(pluginId); if (inFlightLoad) return inFlightLoad; const load = (async () => { this.$loading.set(pluginId, true); this.$pluginLoadingErrors.set(pluginId, null); try { const instance = await this._loadPlugin(pluginId, version, repo); return instance; } catch (error) { this.$pluginLoadingErrors.set(pluginId, error); throw error; } finally { this.$loading.set(pluginId, false); this._inFlightLoads.delete(pluginId); } })(); this._inFlightLoads.set(pluginId, load); return load; } async _loadPlugin(pluginId, version, repo) { 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("Could not fetch 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("Could not fetch 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"); } } if (manifest.fonts?.length) { try { const descriptors = await Promise.all( manifest.fonts.map(async (font) => ({ ...font, blob: await this._provider.getFont( pluginId, version, repo, font.file, ), })), ); this._pluginStylesLoader.mountFonts(pluginId, descriptors); } catch (error) { this._pluginStylesLoader.unmount(pluginId); logger.error( `failed to load "${pluginId}": could not load fonts`, error, ); throw new Error("Failed to load plugin fonts"); } } 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); if (error instanceof PluginSdkError) { logger.error(`could not load "${pluginId}": plugin SDK unavailable`); throw new Error("Plugin system failed to load"); } 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 = untrack(() => this.$loadedPlugins.get(pluginId)); if (!instance) return; instance.unload(); this.$loadedPlugins.delete(pluginId); this.$loading.delete(pluginId); this.$pluginLoadingErrors.delete(pluginId); this._pluginStylesLoader.unmount(pluginId); } async reloadPlugin(pluginId, version, repo) { this.unloadPlugin(pluginId); return this.loadPlugin(pluginId, version, repo); } }