328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* 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<string, () => void>;
|
|
eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
|
|
cleanup: () => void;
|
|
}
|
|
|
|
type PluginCommandCallback = () => void;
|
|
|
|
// ── State ────────────────────────────────────────────────────────────────────
|
|
|
|
const loadedPlugins = new Map<string, LoadedPlugin>();
|
|
|
|
// 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<void> {
|
|
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<string, () => void>();
|
|
const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = [];
|
|
|
|
await new Promise<void>((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<typeof setTimeout> | 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);
|
|
}
|