/** * Plugin Host — Web Worker sandbox for Electrobun plugins. * * Each plugin runs in a dedicated Web Worker with no DOM/IPC access. * Communication: Main <-> Worker via postMessage. * Permission-gated API (messages, events, notifications, palette). * On unload, Worker is terminated — all plugin state destroyed. */ import { appRpc } from './rpc.ts'; // ── Types ──────────────────────────────────────────────────────────────────── export interface PluginMeta { id: string; name: string; version: string; description: string; main: string; permissions: string[]; /** Feature 9: Allowed network origins for fetch-like operations. */ allowedOrigins?: string[]; /** Feature 9: Max runtime in seconds (CPU time quota). Default 30. */ maxRuntime?: number; /** Feature 9: Max memory display hint (bytes, informational only). */ maxMemory?: number; } interface LoadedPlugin { meta: PluginMeta; worker: Worker; callbacks: Map void>; eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>; cleanup: () => void; } type PluginCommandCallback = () => void; // ── State ──────────────────────────────────────────────────────────────────── const loadedPlugins = new Map(); // External command/event registries (set by plugin-store) let commandRegistry: ((pluginId: string, label: string, callback: PluginCommandCallback) => void) | null = null; let commandRemover: ((pluginId: string) => void) | null = null; let eventBus: { on: (event: string, handler: (data: unknown) => void) => void; off: (event: string, handler: (data: unknown) => void) => void } | null = null; /** Wire up external registries (called by plugin-store on init). */ export function setPluginRegistries(opts: { addCommand: (pluginId: string, label: string, cb: PluginCommandCallback) => void; removeCommands: (pluginId: string) => void; eventBus: { on: (e: string, h: (d: unknown) => void) => void; off: (e: string, h: (d: unknown) => void) => void }; }): void { commandRegistry = opts.addCommand; commandRemover = opts.removeCommands; eventBus = opts.eventBus; } // ── Worker script builder ──────────────────────────────────────────────────── function buildWorkerScript(): string { return ` "use strict"; const _callbacks = new Map(); let _callbackId = 0; function _nextCbId() { return '__cb_' + (++_callbackId); } const _pending = new Map(); let _rpcId = 0; function _rpc(method, args) { return new Promise((resolve, reject) => { const id = '__rpc_' + (++_rpcId); _pending.set(id, { resolve, reject }); self.postMessage({ type: 'rpc', id, method, args }); }); } self.onmessage = function(e) { const msg = e.data; if (msg.type === 'init') { const permissions = msg.permissions || []; const meta = msg.meta; const api = { meta: Object.freeze(meta) }; if (permissions.includes('palette')) { api.palette = { registerCommand(label, callback) { if (typeof label !== 'string' || !label.trim()) throw new Error('Command label must be non-empty string'); if (typeof callback !== 'function') throw new Error('Command callback must be a function'); const cbId = _nextCbId(); _callbacks.set(cbId, callback); self.postMessage({ type: 'palette-register', label, callbackId: cbId }); }, }; } if (permissions.includes('notifications')) { api.notifications = { show(message) { self.postMessage({ type: 'notification', message: String(message) }); }, }; } if (permissions.includes('messages')) { api.messages = { list() { return _rpc('messages.list', {}); }, }; } if (permissions.includes('network')) { api.network = { fetch(url, options) { // Check against allowedOrigins const allowedOrigins = msg.allowedOrigins || []; if (allowedOrigins.length > 0) { try { const parsed = new URL(url); const origin = parsed.origin; if (!allowedOrigins.some(o => origin === o || parsed.hostname.endsWith(o))) { return Promise.reject(new Error('Origin not in allowedOrigins: ' + origin)); } } catch (e) { return Promise.reject(new Error('Invalid URL: ' + url)); } } return _rpc('network.fetch', { url, options }); }, }; } if (permissions.includes('events')) { api.events = { on(event, callback) { if (typeof event !== 'string' || typeof callback !== 'function') { throw new Error('events.on requires (string, function)'); } const cbId = _nextCbId(); _callbacks.set(cbId, callback); self.postMessage({ type: 'event-on', event, callbackId: cbId }); }, off(event) { self.postMessage({ type: 'event-off', event }); }, }; } Object.freeze(api); try { const fn = (0, eval)('(function(agor) { "use strict"; ' + msg.code + '\\n})'); fn(api); self.postMessage({ type: 'loaded' }); } catch (err) { self.postMessage({ type: 'error', message: String(err) }); } } if (msg.type === 'invoke-callback') { const cb = _callbacks.get(msg.callbackId); if (cb) { try { cb(msg.data); } catch (err) { self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) }); } } } if (msg.type === 'rpc-result') { const pending = _pending.get(msg.id); if (pending) { _pending.delete(msg.id); if (msg.error) pending.reject(new Error(msg.error)); else pending.resolve(msg.result); } } }; `; } let workerBlobUrl: string | null = null; function getWorkerBlobUrl(): string { if (!workerBlobUrl) { const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' }); workerBlobUrl = URL.createObjectURL(blob); } return workerBlobUrl; } // ── Public API ─────────────────────────────────────────────────────────────── /** * Load and execute a plugin in a Web Worker sandbox. * Reads plugin code via RPC from Bun process. */ export async function loadPlugin(meta: PluginMeta): Promise { if (loadedPlugins.has(meta.id)) { console.warn(`Plugin '${meta.id}' is already loaded`); return; } // Validate permissions const validPerms = new Set(['palette', 'notifications', 'messages', 'events', 'network']); for (const p of meta.permissions) { if (!validPerms.has(p)) { throw new Error(`Plugin '${meta.id}' requests unknown permission: ${p}`); } } // Feature 9: Validate allowedOrigins const maxRuntime = (meta.maxRuntime ?? 30) * 1000; // default 30s, convert to ms // Read plugin code via RPC let code: string; try { const res = await appRpc.request['plugin.readFile']({ pluginId: meta.id, filePath: meta.main }); if (!res.ok) throw new Error(res.error ?? 'Failed to read plugin file'); code = res.content; } catch (e) { throw new Error(`Failed to read plugin '${meta.id}' entry '${meta.main}': ${e}`); } const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' }); const callbacks = new Map void>(); const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = []; await new Promise((resolve, reject) => { worker.onmessage = (e) => { const msg = e.data; switch (msg.type) { case 'loaded': resolve(); break; case 'error': commandRemover?.(meta.id); for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler); worker.terminate(); reject(new Error(`Plugin '${meta.id}' failed: ${msg.message}`)); break; case 'palette-register': { const cbId = msg.callbackId as string; const invoke = () => worker.postMessage({ type: 'invoke-callback', callbackId: cbId }); callbacks.set(cbId, invoke); commandRegistry?.(meta.id, msg.label, invoke); break; } case 'notification': console.log(`[plugin:${meta.id}] notification:`, msg.message); break; case 'event-on': { const cbId = msg.callbackId as string; const handler = (data: unknown) => { worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data }); }; eventSubscriptions.push({ event: msg.event, handler }); eventBus?.on(msg.event, handler); break; } case 'event-off': { const idx = eventSubscriptions.findIndex(s => s.event === msg.event); if (idx >= 0) { eventBus?.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler); eventSubscriptions.splice(idx, 1); } break; } case 'callback-error': console.error(`Plugin '${meta.id}' callback error:`, msg.message); break; } }; worker.onerror = (err) => reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`)); worker.postMessage({ type: 'init', code, permissions: meta.permissions, allowedOrigins: meta.allowedOrigins ?? [], meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description }, }); }); // Feature 9: maxRuntime — terminate Worker after timeout let runtimeTimer: ReturnType | null = null; if (maxRuntime > 0) { runtimeTimer = setTimeout(() => { console.warn(`Plugin '${meta.id}' exceeded maxRuntime (${maxRuntime}ms), terminating`); unloadPlugin(meta.id); }, maxRuntime); } const cleanup = () => { if (runtimeTimer) clearTimeout(runtimeTimer); commandRemover?.(meta.id); for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler); eventSubscriptions.length = 0; callbacks.clear(); worker.terminate(); }; loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup }); } /** Unload a plugin. */ export function unloadPlugin(id: string): void { const plugin = loadedPlugins.get(id); if (!plugin) return; plugin.cleanup(); loadedPlugins.delete(id); } /** Get all loaded plugin metas. */ export function getLoadedPlugins(): PluginMeta[] { return Array.from(loadedPlugins.values()).map(p => p.meta); } /** Unload all plugins. */ export function unloadAllPlugins(): void { for (const [id] of loadedPlugins) unloadPlugin(id); }