camofox-browser/lib/plugins.js

176 lines
6.6 KiB
JavaScript

/**
* Camofox-browser plugin system.
*
* Plugins live in plugins/<name>/index.js and export a register(app, ctx) function.
* The ctx object provides access to sessions, config, logging, auth middleware,
* core functions, and an EventEmitter for lifecycle hooks.
*
* 29 events across 7 categories:
*
* BROWSER LIFECYCLE
* browser:launching { options } -- mutate launch options
* browser:launched { browser, display } -- after launch
* browser:restart { reason } -- before restart cycle
* browser:closed { reason } -- after browser closed
* browser:error { error } -- uncaught browser error
*
* SESSION LIFECYCLE
* session:creating { userId, contextOptions } -- mutate context options
* session:created { userId, context } -- after context stored
* session:destroying { userId, reason } -- before context close (context still alive)
* session:destroyed { userId, reason } -- after cleanup
* session:expired { userId, idleMs } -- reaper triggered
*
* TAB LIFECYCLE
* tab:created { userId, tabId, page, url }
* tab:navigated { userId, tabId, url, prevUrl }
* tab:destroyed { userId, tabId, reason }
* tab:recycled { userId, tabId }
* tab:error { userId, tabId, error }
*
* CONTENT
* tab:snapshot { userId, tabId, snapshot }
* tab:screenshot { userId, tabId, buffer }
* tab:evaluate { userId, tabId, expression }
* tab:evaluated { userId, tabId, result }
*
* INPUT
* tab:click { userId, tabId, ref, selector }
* tab:type { userId, tabId, text, ref, mode }
* tab:scroll { userId, tabId, direction, amount }
* tab:press { userId, tabId, key }
*
* DOWNLOADS
* tab:download:start { userId, tabId, filename, url }
* tab:download:complete { userId, tabId, filename, path, size }
*
* COOKIES / AUTH
* session:cookies:import { userId, count }
* session:storage:export { userId }
*
* SERVER
* server:starting { port }
* server:started { port, pid }
* server:shutdown { signal }
*
* Mutating hooks (browser:launching, session:creating) pass the options object
* by reference -- plugins can modify it in place before core uses it.
*/
import { EventEmitter } from 'events';
import fs from 'fs';
import path from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT_DIR = path.join(__dirname, '..');
const PLUGINS_DIR = path.join(ROOT_DIR, 'plugins');
const CONFIG_PATH = path.join(ROOT_DIR, 'camofox.config.json');
/**
* Read plugin configuration from camofox.config.json.
* Supports two formats:
* - Array of strings: ["youtube", "persistence"] (no per-plugin config)
* - Object with per-plugin config: { "youtube": { "enabled": true }, "persistence": { "enabled": true, "profileDir": "/data" } }
* Returns { list: string[] | null, configs: Map<string, object> }
*/
function readPluginConfig() {
const configs = new Map();
try {
const raw = fs.readFileSync(CONFIG_PATH, 'utf-8');
const config = JSON.parse(raw);
if (!config.plugins) return { list: null, configs };
if (Array.isArray(config.plugins)) {
return { list: config.plugins, configs };
}
if (typeof config.plugins === 'object') {
const list = [];
for (const [name, pluginConf] of Object.entries(config.plugins)) {
if (pluginConf === false || (typeof pluginConf === 'object' && pluginConf.enabled === false)) continue;
list.push(name);
if (typeof pluginConf === 'object') configs.set(name, pluginConf);
}
return { list, configs };
}
} catch {}
return { list: null, configs };
}
/**
* Create the plugin event bus.
*/
export function createPluginEvents() {
const events = new EventEmitter();
events.setMaxListeners(50); // generous for many plugins
/**
* Emit an event and await all listeners (including async ones).
* Use for mutating hooks where plugins must finish before core continues.
* Regular emit() is still used for fire-and-forget observational events.
*/
events.emitAsync = async function emitAsync(eventName, payload) {
const listeners = this.listeners(eventName);
await Promise.all(listeners.map(fn => fn(payload)));
};
return events;
}
/**
* Load and register all plugins from plugins/<name>/index.js.
*
* @param {object} app - Express app
* @param {object} ctx - Plugin context: { sessions, config, log, events, auth, ensureBrowser, getSession, destroySession }
* Mutable -- plugins can replace ctx.createVirtualDisplay etc.
* @returns {string[]} - Names of loaded plugins
*/
export async function loadPlugins(app, ctx) {
const loaded = [];
if (!fs.existsSync(PLUGINS_DIR)) {
ctx.log('info', 'no plugins directory found, skipping plugin load');
return loaded;
}
const { list: allowList, configs: pluginConfigs } = readPluginConfig();
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const name = entry.name;
// Skip directories starting with _ or .
if (name.startsWith('_') || name.startsWith('.')) continue;
// If camofox.config.json specifies a plugins list, only load those
if (allowList && !allowList.includes(name)) {
ctx.log('debug', `plugin "${name}" not in camofox.config.json plugins list, skipping`);
continue;
}
const indexPath = path.join(PLUGINS_DIR, name, 'index.js');
if (!fs.existsSync(indexPath)) {
ctx.log('warn', `plugin "${name}" has no index.js, skipping`);
continue;
}
try {
const mod = await import(pathToFileURL(indexPath).href);
const register = mod.default || mod.register;
if (typeof register !== 'function') {
ctx.log('warn', `plugin "${name}" does not export a register function, skipping`);
continue;
}
const pluginConfig = pluginConfigs.get(name) || {};
await register(app, ctx, pluginConfig);
loaded.push(name);
ctx.log('info', 'plugin loaded', { plugin: name });
} catch (err) {
ctx.log('error', 'plugin load failed', { plugin: name, error: err.message, stack: err.stack });
}
}
return loaded;
}